simklcli 0.1.0__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.
- simklcli/__init__.py +3 -0
- simklcli/__main__.py +4 -0
- simklcli/api.py +431 -0
- simklcli/catalog.py +167 -0
- simklcli/cli.py +496 -0
- simklcli/commands.py +437 -0
- simklcli/filesystem.py +31 -0
- simklcli/media.py +190 -0
- simklcli/output.py +55 -0
- simklcli/rate_control.py +99 -0
- simklcli/runtime.py +242 -0
- simklcli/snapshots.py +162 -0
- simklcli/storage.py +347 -0
- simklcli/targets.py +117 -0
- simklcli/tracking.py +484 -0
- simklcli-0.1.0.dist-info/METADATA +217 -0
- simklcli-0.1.0.dist-info/RECORD +20 -0
- simklcli-0.1.0.dist-info/WHEEL +4 -0
- simklcli-0.1.0.dist-info/entry_points.txt +2 -0
- simklcli-0.1.0.dist-info/licenses/LICENSE +21 -0
simklcli/__init__.py
ADDED
simklcli/__main__.py
ADDED
simklcli/api.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from simklcli import __version__
|
|
14
|
+
from simklcli.storage import Account
|
|
15
|
+
|
|
16
|
+
API_BASE_URL = "https://api.simkl.com"
|
|
17
|
+
APP_NAME = "simklcli"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SimklResponseError(RuntimeError):
|
|
21
|
+
"""Simkl returned a response that cannot fulfill the requested operation."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidAccessTokenError(SimklResponseError):
|
|
25
|
+
"""Simkl confirmed that an Access Token is invalid or revoked."""
|
|
26
|
+
|
|
27
|
+
remote_success = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PostBlockedError(SimklResponseError):
|
|
31
|
+
"""The server explicitly blocked this POST; stop without replaying it."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PartialWriteError(SimklResponseError):
|
|
35
|
+
"""The response reports unmatched items or an unverified partial result."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PollSlowDown(SimklResponseError):
|
|
39
|
+
"""Wait five additional seconds before the next PIN poll."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OAuthGrantError(SimklResponseError):
|
|
43
|
+
def __init__(self, error: str) -> None:
|
|
44
|
+
self.error = error
|
|
45
|
+
super().__init__(f"Simkl authorization failed ({error}); run simkl auth login explicitly.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RateGate(Protocol):
|
|
49
|
+
def limit(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
access_token: str | None,
|
|
53
|
+
) -> AbstractContextManager[None]: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NoWaitRateGate:
|
|
57
|
+
@contextmanager
|
|
58
|
+
def limit(self, method: str, access_token: str | None) -> Iterator[None]:
|
|
59
|
+
del method, access_token
|
|
60
|
+
yield
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PinAuthorization:
|
|
65
|
+
user_code: str
|
|
66
|
+
verification_url: str
|
|
67
|
+
expires_in: int
|
|
68
|
+
interval: int
|
|
69
|
+
device_code: str = field(repr=False)
|
|
70
|
+
verification_url_complete: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class TokenPair:
|
|
75
|
+
access_token: str = field(repr=False)
|
|
76
|
+
refresh_token: str = field(repr=False)
|
|
77
|
+
expires_at: float
|
|
78
|
+
refresh_expires_at: float
|
|
79
|
+
scope: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SimklClient:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
client_id: str,
|
|
87
|
+
transport: httpx.BaseTransport | None = None,
|
|
88
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
89
|
+
jitter: Callable[[float], float] | None = None,
|
|
90
|
+
rate_gate: RateGate | None = None,
|
|
91
|
+
clock: Callable[[], float] = time.time,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._params = {
|
|
94
|
+
"client_id": client_id,
|
|
95
|
+
"app-name": APP_NAME,
|
|
96
|
+
"app-version": __version__,
|
|
97
|
+
}
|
|
98
|
+
self._client = httpx.Client(
|
|
99
|
+
base_url=API_BASE_URL,
|
|
100
|
+
headers={"User-Agent": f"{APP_NAME}/{__version__}"},
|
|
101
|
+
transport=transport,
|
|
102
|
+
)
|
|
103
|
+
self._sleep = sleep
|
|
104
|
+
self._jitter = jitter or (lambda delay: random.uniform(delay * 0.8, delay * 1.2))
|
|
105
|
+
self._rate_gate = rate_gate or NoWaitRateGate()
|
|
106
|
+
self._clock = clock
|
|
107
|
+
self.access_token: str | None = None
|
|
108
|
+
self._refresh: Callable[[str], str] | None = None
|
|
109
|
+
self.account_plan: str | None = None
|
|
110
|
+
|
|
111
|
+
def start_pin_authorization(self) -> PinAuthorization:
|
|
112
|
+
response = self._oauth_request("/oauth2/device", {"scope": "media:read media:write"})
|
|
113
|
+
self._check_oauth_error(response)
|
|
114
|
+
payload = _json_payload(response, "Simkl returned a malformed PIN response.")
|
|
115
|
+
if not isinstance(payload, dict):
|
|
116
|
+
raise SimklResponseError("Simkl did not issue a PIN Authorization code.")
|
|
117
|
+
try:
|
|
118
|
+
return PinAuthorization(
|
|
119
|
+
user_code=_nonempty_string(payload["user_code"]),
|
|
120
|
+
verification_url=_nonempty_string(payload["verification_uri"]),
|
|
121
|
+
expires_in=_positive_int(payload["expires_in"]),
|
|
122
|
+
interval=_positive_int(payload.get("interval", 5)),
|
|
123
|
+
device_code=_nonempty_string(payload["device_code"]),
|
|
124
|
+
verification_url_complete=(
|
|
125
|
+
_nonempty_string(payload["verification_uri_complete"])
|
|
126
|
+
if "verification_uri_complete" in payload
|
|
127
|
+
else None
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
131
|
+
raise SimklResponseError("Simkl returned a malformed PIN response.") from exc
|
|
132
|
+
|
|
133
|
+
def poll_pin_authorization(self, pin: PinAuthorization) -> TokenPair | None:
|
|
134
|
+
response = self._oauth_request(
|
|
135
|
+
"/oauth2/token",
|
|
136
|
+
{
|
|
137
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
138
|
+
"device_code": pin.device_code,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
error = _error_code(response)
|
|
142
|
+
if response.status_code == 400 and error == "authorization_pending":
|
|
143
|
+
return None
|
|
144
|
+
if response.status_code == 400 and error == "slow_down":
|
|
145
|
+
raise PollSlowDown()
|
|
146
|
+
if response.status_code == 400 and error == "expired_token":
|
|
147
|
+
raise SimklResponseError("The PIN Authorization code expired or is no longer valid.")
|
|
148
|
+
return self._token_pair(response)
|
|
149
|
+
|
|
150
|
+
def refresh_tokens(self, refresh_token: str) -> TokenPair:
|
|
151
|
+
response = self._oauth_request(
|
|
152
|
+
"/oauth2/token",
|
|
153
|
+
{
|
|
154
|
+
"grant_type": "refresh_token",
|
|
155
|
+
"refresh_token": refresh_token,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return self._token_pair(response)
|
|
159
|
+
|
|
160
|
+
def revoke_token(self, token: str) -> None:
|
|
161
|
+
response = self._oauth_request("/oauth2/revoke", {"token": token})
|
|
162
|
+
self._check_oauth_error(response)
|
|
163
|
+
if response.status_code != 200:
|
|
164
|
+
raise SimklResponseError("Simkl did not acknowledge the revocation request.")
|
|
165
|
+
|
|
166
|
+
def configure_auth(self, token: str, refresh: Callable[[str], str] | None) -> None:
|
|
167
|
+
self.access_token = token
|
|
168
|
+
self._refresh = refresh
|
|
169
|
+
|
|
170
|
+
def _oauth_request(self, path: str, payload: dict[str, str]) -> httpx.Response:
|
|
171
|
+
try:
|
|
172
|
+
return self._request(
|
|
173
|
+
"POST",
|
|
174
|
+
path,
|
|
175
|
+
json_body={
|
|
176
|
+
"client_id": self._params["client_id"],
|
|
177
|
+
**payload,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
except httpx.HTTPError as exc:
|
|
181
|
+
raise SimklResponseError(
|
|
182
|
+
"Authorization request failed; no automatic replay was attempted."
|
|
183
|
+
) from exc
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _check_oauth_error(response: httpx.Response) -> None:
|
|
187
|
+
if response.status_code >= 400 or _error_code(response):
|
|
188
|
+
error = _error_code(response)
|
|
189
|
+
if error not in {
|
|
190
|
+
"invalid_client",
|
|
191
|
+
"unauthorized_client",
|
|
192
|
+
"invalid_grant",
|
|
193
|
+
"invalid_scope",
|
|
194
|
+
"invalid_request",
|
|
195
|
+
"unsupported_grant_type",
|
|
196
|
+
"access_denied",
|
|
197
|
+
}:
|
|
198
|
+
error = "request_failed"
|
|
199
|
+
if error in {"invalid_client", "unauthorized_client"}:
|
|
200
|
+
raise SimklResponseError(
|
|
201
|
+
"Simkl rejected the client registration. If SIMKL_CLIENT_ID is set, it "
|
|
202
|
+
"must be an AUTH V2 app registered as TV, devices & command line."
|
|
203
|
+
)
|
|
204
|
+
raise OAuthGrantError(error)
|
|
205
|
+
|
|
206
|
+
def _token_pair(self, response: httpx.Response) -> TokenPair:
|
|
207
|
+
self._check_oauth_error(response)
|
|
208
|
+
payload = _json_payload(response, "Simkl returned a malformed token response.")
|
|
209
|
+
try:
|
|
210
|
+
if not isinstance(payload, dict) or payload["token_type"].lower() != "bearer":
|
|
211
|
+
raise ValueError
|
|
212
|
+
scope = _nonempty_string(payload["scope"])
|
|
213
|
+
if not {"media:read", "media:write"} <= set(scope.split()):
|
|
214
|
+
raise SimklResponseError(
|
|
215
|
+
"Simkl did not grant media:read media:write; authorize again with write access."
|
|
216
|
+
)
|
|
217
|
+
now = self._clock()
|
|
218
|
+
return TokenPair(
|
|
219
|
+
access_token=_nonempty_string(payload["access_token"]),
|
|
220
|
+
refresh_token=_nonempty_string(payload["refresh_token"]),
|
|
221
|
+
expires_at=now + _positive_int(payload["expires_in"]),
|
|
222
|
+
refresh_expires_at=now + 180 * 24 * 60 * 60,
|
|
223
|
+
scope=scope,
|
|
224
|
+
)
|
|
225
|
+
except (AttributeError, KeyError, TypeError, ValueError) as exc:
|
|
226
|
+
raise SimklResponseError("Simkl returned a malformed token response.") from exc
|
|
227
|
+
|
|
228
|
+
def get_authenticated_account(self, access_token: str) -> Account:
|
|
229
|
+
response = self._request("POST", "/users/settings", access_token=access_token)
|
|
230
|
+
payload = self.checked_json(response)
|
|
231
|
+
try:
|
|
232
|
+
if not isinstance(payload, dict):
|
|
233
|
+
raise TypeError
|
|
234
|
+
account_payload = payload["account"]
|
|
235
|
+
user_payload = payload["user"]
|
|
236
|
+
if not isinstance(account_payload, dict) or not isinstance(user_payload, dict):
|
|
237
|
+
raise TypeError
|
|
238
|
+
account_id = _positive_int(account_payload["id"])
|
|
239
|
+
name = _nonempty_string(user_payload["name"])
|
|
240
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
241
|
+
raise SimklResponseError(
|
|
242
|
+
"Simkl returned account settings without a stable account ID and display name."
|
|
243
|
+
) from exc
|
|
244
|
+
plan = account_payload.get("type")
|
|
245
|
+
self.account_plan = plan if isinstance(plan, str) else None
|
|
246
|
+
bind_account = getattr(self._rate_gate, "bind_account", None)
|
|
247
|
+
if callable(bind_account):
|
|
248
|
+
bind_account(access_token, account_id)
|
|
249
|
+
return Account(id=account_id, name=name)
|
|
250
|
+
|
|
251
|
+
def _get(
|
|
252
|
+
self,
|
|
253
|
+
path: str,
|
|
254
|
+
*,
|
|
255
|
+
access_token: str | None = None,
|
|
256
|
+
params: dict[str, str | int] | None = None,
|
|
257
|
+
) -> httpx.Response:
|
|
258
|
+
retry_delays = (1.0, 2.0, 4.0, 8.0, 16.0)
|
|
259
|
+
for retry_delay in retry_delays:
|
|
260
|
+
response = self._request("GET", path, access_token=access_token, params=params)
|
|
261
|
+
if response.status_code not in {429, 500, 502, 503}:
|
|
262
|
+
return response
|
|
263
|
+
delay = self._jitter(retry_delay)
|
|
264
|
+
retry_after = _retry_after_seconds(response)
|
|
265
|
+
if retry_after is not None:
|
|
266
|
+
delay = max(delay, retry_after)
|
|
267
|
+
self._sleep(delay)
|
|
268
|
+
return self._request("GET", path, access_token=access_token, params=params)
|
|
269
|
+
|
|
270
|
+
def get_json(
|
|
271
|
+
self,
|
|
272
|
+
path: str,
|
|
273
|
+
*,
|
|
274
|
+
access_token: str | None = None,
|
|
275
|
+
params: dict[str, str | int] | None = None,
|
|
276
|
+
) -> Any:
|
|
277
|
+
return self.checked_json(self._get(path, access_token=access_token, params=params))
|
|
278
|
+
|
|
279
|
+
def post_json(
|
|
280
|
+
self,
|
|
281
|
+
path: str,
|
|
282
|
+
payload: object,
|
|
283
|
+
*,
|
|
284
|
+
access_token: str,
|
|
285
|
+
params: dict[str, str | int] | None = None,
|
|
286
|
+
) -> Any:
|
|
287
|
+
response = self._request(
|
|
288
|
+
"POST", path, access_token=access_token, params=params, json_body=payload
|
|
289
|
+
)
|
|
290
|
+
if response.status_code in {429, 412} or _error_code(response) in {
|
|
291
|
+
"RATE_LIMIT",
|
|
292
|
+
"rate_limit",
|
|
293
|
+
}:
|
|
294
|
+
raise PostBlockedError(
|
|
295
|
+
"Simkl blocked this POST. Stop and verify your account before retrying."
|
|
296
|
+
)
|
|
297
|
+
return self.checked_json(response)
|
|
298
|
+
|
|
299
|
+
def write_state(
|
|
300
|
+
self,
|
|
301
|
+
path: str,
|
|
302
|
+
payload: object,
|
|
303
|
+
*,
|
|
304
|
+
access_token: str,
|
|
305
|
+
params: dict[str, str | int] | None = None,
|
|
306
|
+
) -> dict[str, Any]:
|
|
307
|
+
result = self.post_json(path, payload, access_token=access_token, params=params)
|
|
308
|
+
if not isinstance(result, dict):
|
|
309
|
+
raise PartialWriteError("Malformed write response; outcome unknown.")
|
|
310
|
+
unmatched = result.get("not_found", {})
|
|
311
|
+
if not isinstance(unmatched, dict) or any(
|
|
312
|
+
not isinstance(v, list) for v in unmatched.values()
|
|
313
|
+
):
|
|
314
|
+
raise PartialWriteError("Malformed partial-success data; outcome unknown.")
|
|
315
|
+
if any(unmatched.values()):
|
|
316
|
+
raise PartialWriteError("Simkl reported unmatched items; the write may be partial.")
|
|
317
|
+
if not isinstance(result.get("added", result.get("deleted")), dict):
|
|
318
|
+
raise PartialWriteError("Missing write result; outcome unknown.")
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def checked_json(response: httpx.Response) -> Any:
|
|
323
|
+
if response.status_code == 401 and _error_code(response) in {
|
|
324
|
+
"user_token_failed",
|
|
325
|
+
"invalid_token",
|
|
326
|
+
}:
|
|
327
|
+
raise InvalidAccessTokenError("The Access Token is invalid or revoked.")
|
|
328
|
+
response.raise_for_status()
|
|
329
|
+
payload = _json_payload(response, "Simkl returned malformed JSON.")
|
|
330
|
+
if isinstance(payload, dict) and payload.get("error"):
|
|
331
|
+
raise SimklResponseError(f"Simkl rejected the request: {payload['error']}.")
|
|
332
|
+
return payload
|
|
333
|
+
|
|
334
|
+
def _request(
|
|
335
|
+
self,
|
|
336
|
+
method: str,
|
|
337
|
+
path: str,
|
|
338
|
+
*,
|
|
339
|
+
access_token: str | None = None,
|
|
340
|
+
params: dict[str, str | int] | None = None,
|
|
341
|
+
json_body: object = None,
|
|
342
|
+
) -> httpx.Response:
|
|
343
|
+
if not self._params["client_id"]:
|
|
344
|
+
raise SimklResponseError(
|
|
345
|
+
"Set SIMKL_CLIENT_ID to your AUTH V2 app's public client ID before online use."
|
|
346
|
+
)
|
|
347
|
+
authenticated = access_token is not None
|
|
348
|
+
if authenticated and self.access_token is not None:
|
|
349
|
+
access_token = self.access_token
|
|
350
|
+
headers: dict[str, str] = {}
|
|
351
|
+
if access_token is not None:
|
|
352
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
353
|
+
if method == "POST":
|
|
354
|
+
headers["Content-Type"] = "application/json"
|
|
355
|
+
with self._rate_gate.limit(method, access_token):
|
|
356
|
+
response = self._client.request(
|
|
357
|
+
method,
|
|
358
|
+
path,
|
|
359
|
+
params={**self._params, **(params or {})},
|
|
360
|
+
headers=headers,
|
|
361
|
+
json=json_body,
|
|
362
|
+
)
|
|
363
|
+
# A definitive authentication rejection precedes the endpoint operation.
|
|
364
|
+
# Retry once only in this case, never after transport loss, 5xx, or a block.
|
|
365
|
+
if (
|
|
366
|
+
authenticated
|
|
367
|
+
and response.status_code == 401
|
|
368
|
+
and _error_code(response)
|
|
369
|
+
in {
|
|
370
|
+
"user_token_failed",
|
|
371
|
+
"invalid_token",
|
|
372
|
+
}
|
|
373
|
+
and self._refresh is not None
|
|
374
|
+
):
|
|
375
|
+
assert access_token is not None
|
|
376
|
+
token = self._refresh(access_token)
|
|
377
|
+
self.access_token = token
|
|
378
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
379
|
+
with self._rate_gate.limit(method, token):
|
|
380
|
+
response = self._client.request(
|
|
381
|
+
method,
|
|
382
|
+
path,
|
|
383
|
+
params={**self._params, **(params or {})},
|
|
384
|
+
headers=headers,
|
|
385
|
+
json=json_body,
|
|
386
|
+
)
|
|
387
|
+
return response
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _nonempty_string(value: object) -> str:
|
|
391
|
+
if not isinstance(value, str) or not value.strip():
|
|
392
|
+
raise ValueError("expected a nonempty string")
|
|
393
|
+
return value
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _positive_int(value: object) -> int:
|
|
397
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
398
|
+
raise ValueError("expected a positive integer")
|
|
399
|
+
return value
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _error_code(response: httpx.Response) -> str | None:
|
|
403
|
+
try:
|
|
404
|
+
payload: Any = response.json()
|
|
405
|
+
except ValueError:
|
|
406
|
+
return None
|
|
407
|
+
if not isinstance(payload, dict):
|
|
408
|
+
return None
|
|
409
|
+
error = payload.get("error")
|
|
410
|
+
return error if isinstance(error, str) else None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _json_payload(response: httpx.Response, message: str) -> Any:
|
|
414
|
+
try:
|
|
415
|
+
return response.json()
|
|
416
|
+
except ValueError as exc:
|
|
417
|
+
raise SimklResponseError(message) from exc
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _retry_after_seconds(response: httpx.Response) -> float | None:
|
|
421
|
+
value = response.headers.get("Retry-After")
|
|
422
|
+
if value is None:
|
|
423
|
+
return None
|
|
424
|
+
try:
|
|
425
|
+
delay = float(value)
|
|
426
|
+
except ValueError:
|
|
427
|
+
try:
|
|
428
|
+
return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
|
|
429
|
+
except (TypeError, ValueError, OverflowError):
|
|
430
|
+
return None
|
|
431
|
+
return max(0.0, delay)
|
simklcli/catalog.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Catalog reads and Media Reference resolution. Nothing here persists metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from simklcli.api import SimklClient, SimklResponseError
|
|
10
|
+
from simklcli.media import Media, MediaKind, positive_int
|
|
11
|
+
|
|
12
|
+
PROVIDERS = {
|
|
13
|
+
"simkl",
|
|
14
|
+
"imdb",
|
|
15
|
+
"tmdb",
|
|
16
|
+
"tvdb",
|
|
17
|
+
"mal",
|
|
18
|
+
"anidb",
|
|
19
|
+
"anilist",
|
|
20
|
+
"kitsu",
|
|
21
|
+
"livechart",
|
|
22
|
+
"anisearch",
|
|
23
|
+
"animeplanet",
|
|
24
|
+
"crunchyroll",
|
|
25
|
+
"netflix",
|
|
26
|
+
"hulu",
|
|
27
|
+
"letterboxd",
|
|
28
|
+
"trakt",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AmbiguousReferenceError(ValueError):
|
|
33
|
+
def __init__(self, candidates: list[Media]) -> None:
|
|
34
|
+
super().__init__("Media Reference is ambiguous; use a qualified Simkl ID or --kind/--year.")
|
|
35
|
+
self.candidates = candidates
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SearchResults:
|
|
40
|
+
items: list[Media]
|
|
41
|
+
next_page: int | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Catalog:
|
|
45
|
+
def __init__(self, api: SimklClient, access_token: str | None = None) -> None:
|
|
46
|
+
self.api = api
|
|
47
|
+
self.access_token = access_token or api.access_token
|
|
48
|
+
|
|
49
|
+
def search(
|
|
50
|
+
self,
|
|
51
|
+
query: str,
|
|
52
|
+
*,
|
|
53
|
+
kind: MediaKind | None = None,
|
|
54
|
+
year: int | None = None,
|
|
55
|
+
limit: int = 10,
|
|
56
|
+
page: int = 1,
|
|
57
|
+
) -> SearchResults:
|
|
58
|
+
if not query.strip():
|
|
59
|
+
raise ValueError("Search query cannot be empty.")
|
|
60
|
+
if self.access_token is None:
|
|
61
|
+
raise SimklResponseError(
|
|
62
|
+
"AUTH V2 search requires sign-in. Run simkl auth login explicitly."
|
|
63
|
+
)
|
|
64
|
+
items = []
|
|
65
|
+
more = False
|
|
66
|
+
for selected in [kind] if kind is not None else list(MediaKind):
|
|
67
|
+
response = self.api._get(
|
|
68
|
+
f"/search/{'tv' if selected is MediaKind.SHOW else selected.value}",
|
|
69
|
+
params={"q": query, "limit": limit, "page": page, "extended": "full"},
|
|
70
|
+
access_token=self.access_token,
|
|
71
|
+
)
|
|
72
|
+
rows = self.api.checked_json(response)
|
|
73
|
+
if not isinstance(rows, list):
|
|
74
|
+
raise SimklResponseError("Malformed catalog search response.")
|
|
75
|
+
try:
|
|
76
|
+
for row in rows:
|
|
77
|
+
item = Media.from_catalog(row, selected)
|
|
78
|
+
if year is None or item.year == year:
|
|
79
|
+
items.append(item)
|
|
80
|
+
more |= page < int(response.headers.get("X-Pagination-Page-Count", "1"))
|
|
81
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
82
|
+
raise SimklResponseError("Malformed catalog search response.") from exc
|
|
83
|
+
# Limit applies per media kind, preserving candidates across all three namespaces.
|
|
84
|
+
return SearchResults(items, page + 1 if more and page < 20 else None)
|
|
85
|
+
|
|
86
|
+
def details(self, media_id: int, kind: MediaKind) -> Media:
|
|
87
|
+
payload = self.api.get_json(
|
|
88
|
+
f"/{kind.endpoint}/{media_id}",
|
|
89
|
+
params={"extended": "full"},
|
|
90
|
+
access_token=self.access_token,
|
|
91
|
+
)
|
|
92
|
+
if not payload:
|
|
93
|
+
raise ValueError(f"No {kind.value} found for Simkl ID {media_id}.")
|
|
94
|
+
try:
|
|
95
|
+
item = Media.from_catalog(payload, kind)
|
|
96
|
+
if item.simkl_id != media_id:
|
|
97
|
+
raise ValueError("Catalog returned a different Simkl ID.")
|
|
98
|
+
return item
|
|
99
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
100
|
+
raise SimklResponseError("Malformed catalog detail response.") from exc
|
|
101
|
+
|
|
102
|
+
def resolve(
|
|
103
|
+
self, reference: str, *, kind: MediaKind | None = None, year: int | None = None
|
|
104
|
+
) -> Media:
|
|
105
|
+
prefix = reference.split(":", 1)[0]
|
|
106
|
+
if prefix == "slug" and ":" in reference:
|
|
107
|
+
raise ValueError("A Slug is a URL hint, not a Media Reference identity.")
|
|
108
|
+
if ":" not in reference or prefix not in PROVIDERS:
|
|
109
|
+
matches = self.search(reference, kind=kind, year=year, limit=50)
|
|
110
|
+
if not matches.items:
|
|
111
|
+
raise ValueError("No Media Item matched this Media Reference.")
|
|
112
|
+
if len(matches.items) != 1 or matches.next_page is not None:
|
|
113
|
+
raise AmbiguousReferenceError(matches.items)
|
|
114
|
+
chosen = matches.items[0]
|
|
115
|
+
return self.details(chosen.simkl_id, chosen.kind)
|
|
116
|
+
provider, value = reference.split(":", 1)
|
|
117
|
+
if provider not in PROVIDERS or not value.strip():
|
|
118
|
+
raise ValueError("Use a supported provider-qualified External ID or a title.")
|
|
119
|
+
if provider == "tmdb" and kind not in {MediaKind.MOVIE, MediaKind.SHOW}:
|
|
120
|
+
raise ValueError("TMDB references require --kind movie or --kind show.")
|
|
121
|
+
if provider == "simkl":
|
|
122
|
+
try:
|
|
123
|
+
media_id = positive_int(int(value))
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ValueError("Simkl IDs must be positive integers.") from exc
|
|
126
|
+
if kind is not None:
|
|
127
|
+
return self.details(media_id, kind)
|
|
128
|
+
params: dict[str, str | int] = {provider: value}
|
|
129
|
+
if kind is not None:
|
|
130
|
+
params["type"] = "tv" if kind is MediaKind.SHOW else kind.value
|
|
131
|
+
if self.access_token is None:
|
|
132
|
+
raise SimklResponseError(
|
|
133
|
+
"AUTH V2 reference resolution requires sign-in; use a Simkl ID with --kind "
|
|
134
|
+
"for an anonymous lookup, or run simkl auth login explicitly."
|
|
135
|
+
)
|
|
136
|
+
response = self.api._get("/redirect", params=params, access_token=self.access_token)
|
|
137
|
+
if response.status_code not in {301, 302}:
|
|
138
|
+
self.api.checked_json(response)
|
|
139
|
+
raise ValueError("Simkl could not resolve this Media Reference.")
|
|
140
|
+
target = urlparse(response.headers.get("Location", ""))
|
|
141
|
+
parts = target.path.strip("/").split("/")
|
|
142
|
+
try:
|
|
143
|
+
if target.hostname != "simkl.com" or len(parts) < 2:
|
|
144
|
+
raise ValueError
|
|
145
|
+
resolved_kind = {
|
|
146
|
+
"movies": MediaKind.MOVIE,
|
|
147
|
+
"tv": MediaKind.SHOW,
|
|
148
|
+
"anime": MediaKind.ANIME,
|
|
149
|
+
}[parts[0]]
|
|
150
|
+
media_id = positive_int(int(parts[1]))
|
|
151
|
+
if any(part.startswith("episode-") for part in parts[2:]):
|
|
152
|
+
raise ValueError
|
|
153
|
+
if provider == "simkl" and media_id != int(value):
|
|
154
|
+
raise ValueError
|
|
155
|
+
if kind is not None and kind is not resolved_kind:
|
|
156
|
+
raise ValueError
|
|
157
|
+
except (KeyError, ValueError) as exc:
|
|
158
|
+
raise SimklResponseError("Invalid or mismatched Simkl redirect target.") from exc
|
|
159
|
+
return self.details(media_id, resolved_kind)
|
|
160
|
+
|
|
161
|
+
def episodes(self, item: Media) -> list[dict[str, Any]]:
|
|
162
|
+
rows = self.api.get_json(
|
|
163
|
+
f"/{item.kind.endpoint}/episodes/{item.simkl_id}", access_token=self.access_token
|
|
164
|
+
)
|
|
165
|
+
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
|
166
|
+
raise SimklResponseError("Malformed catalog episode response.")
|
|
167
|
+
return rows
|