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 ADDED
@@ -0,0 +1,3 @@
1
+ """simklcli package."""
2
+
3
+ __version__ = "0.1.0"
simklcli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from simklcli.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
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