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/output.py ADDED
@@ -0,0 +1,55 @@
1
+ """Keep argument-validation failures machine readable when --json is requested."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from typing import Any
9
+
10
+ from typer import Abort
11
+ from typer import _click as click
12
+ from typer.core import TyperGroup
13
+
14
+
15
+ class JsonGroup(TyperGroup):
16
+ def main(
17
+ self,
18
+ args: Sequence[str] | None = None,
19
+ prog_name: str | None = None,
20
+ complete_var: str | None = None,
21
+ standalone_mode: bool = True,
22
+ windows_expand_args: bool = True,
23
+ **extra: Any,
24
+ ) -> Any:
25
+ arguments = list(sys.argv[1:] if args is None else args)
26
+ try:
27
+ result = super().main(
28
+ args=arguments,
29
+ prog_name=prog_name,
30
+ complete_var=complete_var,
31
+ standalone_mode=False,
32
+ windows_expand_args=windows_expand_args,
33
+ **extra,
34
+ )
35
+ except click.ClickException as exc:
36
+ if "--json" in arguments:
37
+ click.echo(
38
+ json.dumps({"error": "invalid_arguments", "message": exc.format_message()})
39
+ )
40
+ exc.show()
41
+ if standalone_mode:
42
+ raise SystemExit(exc.exit_code) from None
43
+ raise
44
+ except Abort as exc:
45
+ if "--json" in arguments:
46
+ click.echo(json.dumps({"error": "cancelled", "message": "Aborted."}))
47
+ click.echo("Aborted!", err=True)
48
+ if standalone_mode:
49
+ raise SystemExit(
50
+ 130 if isinstance(exc.__cause__, KeyboardInterrupt) else 1
51
+ ) from None
52
+ raise
53
+ if standalone_mode:
54
+ raise SystemExit(result if isinstance(result, int) else 0)
55
+ return result
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ from collections.abc import Callable, Iterator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ from filelock import FileLock
11
+
12
+ from simklcli.filesystem import atomic_write_json
13
+
14
+
15
+ class CrossProcessRateGate:
16
+ """Space Simkl requests across processes by application or account scope."""
17
+
18
+ def __init__(
19
+ self,
20
+ root: Path,
21
+ *,
22
+ client_id: str,
23
+ clock: Callable[[], float] = time.time,
24
+ sleep: Callable[[float], None] = time.sleep,
25
+ ) -> None:
26
+ self._root = root
27
+ self._client_id = client_id
28
+ self._clock = clock
29
+ self._sleep = sleep
30
+ self._accounts: dict[str, int] = {}
31
+
32
+ def bind_account(self, access_token: str, account_id: int) -> None:
33
+ # Carry the initial token-validation cadence into the validated account gate.
34
+ self._root.mkdir(mode=0o700, parents=True, exist_ok=True)
35
+ token_key = hashlib.sha256(f"account:{access_token}".encode()).hexdigest()
36
+ account_key = hashlib.sha256(f"account:{account_id}".encode()).hexdigest()
37
+ path = self._root / f"{account_key}.json"
38
+ with FileLock(self._root / f"{account_key}.lock", mode=0o600):
39
+ state = _read_state(path)
40
+ token_state = _read_state(self._root / f"{token_key}.json")
41
+ for field, value in token_state.items():
42
+ state[field] = max(value, state.get(field, value))
43
+ atomic_write_json(path, state)
44
+ atomic_write_json(self._root / f"{token_key}.account.json", {"account_id": account_id})
45
+ self._accounts[access_token] = account_id
46
+
47
+ def remembered_account_id(self, access_token: str) -> int | None:
48
+ key = hashlib.sha256(f"account:{access_token}".encode()).hexdigest()
49
+ try:
50
+ payload = json.loads((self._root / f"{key}.account.json").read_text(encoding="utf-8"))
51
+ account_id = payload["account_id"]
52
+ if isinstance(account_id, int) and not isinstance(account_id, bool) and account_id > 0:
53
+ return account_id
54
+ except (OSError, KeyError, TypeError, ValueError):
55
+ pass
56
+ return None
57
+
58
+ @contextmanager
59
+ def limit(self, method: str, access_token: str | None) -> Iterator[None]:
60
+ interval = 1.0 if method == "POST" else 0.1
61
+ account_id = (
62
+ None
63
+ if access_token is None
64
+ else (self._accounts.get(access_token) or self.remembered_account_id(access_token))
65
+ )
66
+ scope = (
67
+ f"account:{account_id if account_id is not None else access_token}"
68
+ if access_token is not None
69
+ else f"application:{self._client_id}"
70
+ )
71
+ key = hashlib.sha256(scope.encode()).hexdigest()
72
+ self._root.mkdir(mode=0o700, parents=True, exist_ok=True)
73
+ state_path = self._root / f"{key}.json"
74
+ lock = FileLock(self._root / f"{key}.lock")
75
+ with lock:
76
+ state = _read_state(state_path)
77
+ field = "last_post" if method == "POST" else "last_get"
78
+ previous = state.get(field)
79
+ if previous is not None:
80
+ delay = previous + interval - self._clock()
81
+ if delay > 0:
82
+ self._sleep(delay)
83
+ state[field] = self._clock()
84
+ atomic_write_json(state_path, state)
85
+ yield
86
+
87
+
88
+ def _read_state(path: Path) -> dict[str, float]:
89
+ try:
90
+ payload = json.loads(path.read_text(encoding="utf-8"))
91
+ except (FileNotFoundError, OSError, ValueError):
92
+ return {}
93
+ if not isinstance(payload, dict):
94
+ return {}
95
+ return {
96
+ key: float(value)
97
+ for key, value in payload.items()
98
+ if key in {"last_get", "last_post"} and isinstance(value, int | float)
99
+ }
simklcli/runtime.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ import webbrowser
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import httpx
12
+ import keyring
13
+ from platformdirs import user_config_path, user_data_path
14
+
15
+ from simklcli.rate_control import CrossProcessRateGate
16
+ from simklcli.snapshots import LibrarySnapshotStore
17
+ from simklcli.storage import (
18
+ Credential,
19
+ CredentialRepository,
20
+ CredentialStorageError,
21
+ EmptyKeyring,
22
+ Keyring,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from simklcli.api import SimklClient
27
+
28
+
29
+ # Public AUTH V2 "TV, devices & command line" app ID for simklcli. It carries no
30
+ # secret: Simkl refuses user-scoped requests without a user Access Token.
31
+ DEFAULT_CLIENT_ID = "6d50d4e07aa47b6409a4cd0c2b16729fab7ddc7f5c111fcba7d0001ac054c164"
32
+
33
+
34
+ @dataclass
35
+ class Runtime:
36
+ credentials: CredentialRepository
37
+ snapshots: LibrarySnapshotStore
38
+ environ: Mapping[str, str]
39
+ data_dir: Path
40
+ transport: httpx.BaseTransport | None
41
+ rate_gate: CrossProcessRateGate | None
42
+ sleep: Callable[[float], None]
43
+ monotonic: Callable[[], float]
44
+ open_browser: Callable[[str], bool]
45
+ interactive: bool
46
+ clock: Callable[[], float] = time.time
47
+
48
+ def client_id(self) -> str:
49
+ configured = self.environ.get("SIMKL_CLIENT_ID", "").strip()
50
+ if configured:
51
+ return configured
52
+ if not self.environ.get("SIMKL_ACCESS_TOKEN", "").strip():
53
+ try:
54
+ stored = self.credentials.read()
55
+ except CredentialStorageError:
56
+ stored = None
57
+ if stored is not None and stored.client_id:
58
+ return stored.client_id
59
+ return DEFAULT_CLIENT_ID
60
+
61
+ def identity_gate(self) -> CrossProcessRateGate:
62
+ return self.rate_gate or CrossProcessRateGate(
63
+ self.data_dir / "rate-gates",
64
+ client_id=self.client_id(),
65
+ )
66
+
67
+ def api_client(self) -> SimklClient:
68
+ from simklcli.api import SimklClient
69
+
70
+ if self.rate_gate is not None and not self.environ.get("SIMKL_ACCESS_TOKEN", "").strip():
71
+ try:
72
+ stored = self.credentials.read()
73
+ except CredentialStorageError:
74
+ stored = None
75
+ if stored is not None and stored.account is not None:
76
+ self.rate_gate.bind_account(stored.access_token, stored.account.id)
77
+ return SimklClient(
78
+ client_id=self.client_id(),
79
+ transport=self.transport,
80
+ sleep=self.sleep,
81
+ rate_gate=self.rate_gate,
82
+ clock=self.clock,
83
+ )
84
+
85
+ def authenticated_client(self) -> tuple[Credential, SimklClient]:
86
+ from simklcli.api import SimklResponseError
87
+ from simklcli.storage import CredentialSource
88
+
89
+ credential = self.active_credential()
90
+ if credential is None:
91
+ raise SimklResponseError(
92
+ "No Access Token is available. Run simkl auth login explicitly."
93
+ )
94
+ if credential.source is not CredentialSource.ENVIRONMENT and credential.account is None:
95
+ raise SimklResponseError(
96
+ "The active credential has no validated Authenticated Account."
97
+ )
98
+ if credential.legacy or credential.invalidated:
99
+ raise SimklResponseError(
100
+ "This account requires AUTH V2 authorization. Run simkl auth login; "
101
+ "the Library Snapshot will be retained."
102
+ )
103
+ if credential.source is CredentialSource.ENVIRONMENT:
104
+ # Recognize genuine V1 credentials without accepting them on a V2 registration.
105
+ token = credential.access_token
106
+ if len(token) == 64 and all(c in "0123456789abcdef" for c in token):
107
+ raise SimklResponseError("SIMKL_ACCESS_TOKEN is a V1 token; authorize your V2 app.")
108
+ elif credential.client_id != self.client_id():
109
+ raise SimklResponseError(
110
+ "SIMKL_CLIENT_ID differs from the stored token's app; restore it or log out "
111
+ "before authorizing a different app."
112
+ )
113
+ api = self.api_client()
114
+ refresh = None
115
+ if credential.source is not CredentialSource.ENVIRONMENT:
116
+ refresh = self._refresh_credential
117
+ if credential.expires_at is not None and credential.expires_at <= self.clock() + 60:
118
+ self._refresh_credential(credential.access_token)
119
+ credential = self.credentials.read() or credential
120
+ api.configure_auth(credential.access_token, refresh)
121
+ if self.rate_gate is not None and credential.account is not None:
122
+ self.rate_gate.bind_account(credential.access_token, credential.account.id)
123
+ return credential, api
124
+
125
+ def _refresh_credential(self, failed_token: str) -> str:
126
+ from simklcli.api import InvalidAccessTokenError, OAuthGrantError, SimklResponseError
127
+
128
+ with self.credentials.account_transaction():
129
+ credential = self.credentials.read()
130
+ if credential is None or credential.invalidated or credential.refresh_token is None:
131
+ raise InvalidAccessTokenError(
132
+ "Refresh credentials are unavailable; authorize again."
133
+ )
134
+ if credential.client_id != self.client_id():
135
+ raise SimklResponseError(
136
+ "The stored token belongs to a different client registration."
137
+ )
138
+ # Another invocation may already have refreshed while this one waited for the lock.
139
+ if credential.access_token != failed_token:
140
+ return credential.access_token
141
+ if (
142
+ credential.refresh_expires_at is None
143
+ or credential.refresh_expires_at <= self.clock()
144
+ ):
145
+ raise InvalidAccessTokenError("The Refresh Token expired; authorize again.")
146
+ api = self.api_client()
147
+ try:
148
+ pair = api.refresh_tokens(credential.refresh_token)
149
+ except OAuthGrantError as exc:
150
+ if exc.error == "invalid_grant":
151
+ raise InvalidAccessTokenError(
152
+ "The Refresh Token is invalid or revoked."
153
+ ) from exc
154
+ raise
155
+ account = api.get_authenticated_account(pair.access_token)
156
+ if credential.account is None or account.id != credential.account.id:
157
+ raise SimklResponseError(
158
+ "Refreshed credentials did not match the Authenticated Account."
159
+ )
160
+ updated = replace(
161
+ credential,
162
+ access_token=pair.access_token,
163
+ refresh_token=pair.refresh_token,
164
+ expires_at=pair.expires_at,
165
+ refresh_expires_at=pair.refresh_expires_at,
166
+ scope=pair.scope,
167
+ account=account,
168
+ )
169
+ self.credentials.save(updated)
170
+ if self.rate_gate is not None:
171
+ self.rate_gate.bind_account(pair.access_token, account.id)
172
+ return pair.access_token
173
+
174
+ def active_credential(self) -> Credential | None:
175
+ token = self.environ.get("SIMKL_ACCESS_TOKEN", "").strip()
176
+ if token:
177
+ from simklcli.storage import CredentialSource
178
+
179
+ return Credential(
180
+ access_token=token,
181
+ source=CredentialSource.ENVIRONMENT,
182
+ account=None,
183
+ )
184
+ return self.credentials.read()
185
+
186
+ @classmethod
187
+ def for_testing(
188
+ cls,
189
+ *,
190
+ config_dir: Path,
191
+ data_dir: Path,
192
+ environ: Mapping[str, str] | None = None,
193
+ transport: httpx.BaseTransport | None = None,
194
+ rate_gate: CrossProcessRateGate | None = None,
195
+ sleep: Callable[[float], None] = time.sleep,
196
+ monotonic: Callable[[], float] = time.monotonic,
197
+ open_browser: Callable[[str], bool] = webbrowser.open,
198
+ interactive: bool = False,
199
+ clock: Callable[[], float] = time.time,
200
+ ) -> Runtime:
201
+ return cls(
202
+ credentials=CredentialRepository(config_dir, EmptyKeyring()),
203
+ snapshots=LibrarySnapshotStore(data_dir),
204
+ environ={"SIMKL_CLIENT_ID": "test-v2-client", **(environ or {})},
205
+ data_dir=data_dir,
206
+ transport=transport or httpx.MockTransport(_unexpected_test_request),
207
+ rate_gate=rate_gate,
208
+ sleep=sleep,
209
+ monotonic=monotonic,
210
+ open_browser=open_browser,
211
+ interactive=interactive,
212
+ clock=clock,
213
+ )
214
+
215
+
216
+ def default_runtime() -> Runtime:
217
+ config_dir = Path(user_config_path("simklcli", appauthor=False))
218
+ data_dir = Path(user_data_path("simklcli", appauthor=False))
219
+ backend: Keyring = keyring.get_keyring()
220
+ client_id = os.environ.get("SIMKL_CLIENT_ID", "").strip() or DEFAULT_CLIENT_ID
221
+ return Runtime(
222
+ credentials=CredentialRepository(config_dir, backend),
223
+ snapshots=LibrarySnapshotStore(data_dir),
224
+ environ=os.environ,
225
+ data_dir=data_dir,
226
+ transport=None,
227
+ rate_gate=CrossProcessRateGate(data_dir / "rate-gates", client_id=client_id),
228
+ sleep=time.sleep,
229
+ monotonic=time.monotonic,
230
+ open_browser=webbrowser.open,
231
+ interactive=_is_interactive(),
232
+ )
233
+
234
+
235
+ def _unexpected_test_request(request: httpx.Request) -> httpx.Response:
236
+ raise AssertionError(f"No authored response exists for {request.method} {request.url.path}.")
237
+
238
+
239
+ def _is_interactive() -> bool:
240
+ import sys
241
+
242
+ return sys.stdin.isatty() and sys.stdout.isatty()
simklcli/snapshots.py ADDED
@@ -0,0 +1,162 @@
1
+ import json
2
+ from collections.abc import Iterator
3
+ from contextlib import contextmanager
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from filelock import FileLock
9
+
10
+ from simklcli.filesystem import atomic_write_bytes, atomic_write_json
11
+ from simklcli.media import LibraryEntry, ListStatus, Media, MediaKind, WatchedEpisode, positive_int
12
+
13
+
14
+ @dataclass
15
+ class LibrarySnapshot:
16
+ account_id: int
17
+ entries: dict[int, LibraryEntry] = field(default_factory=dict)
18
+ activities: dict[str, Any] = field(default_factory=dict)
19
+ complete: bool = False
20
+ dirty: bool = False
21
+ reconciled_at: float = 0.0
22
+
23
+ def payload(self) -> dict[str, Any]:
24
+ return {
25
+ "version": 1,
26
+ "account_id": self.account_id,
27
+ "entries": [entry.payload() for entry in self.entries.values()],
28
+ "activities": self.activities,
29
+ "complete": self.complete,
30
+ "dirty": self.dirty,
31
+ "reconciled_at": self.reconciled_at,
32
+ }
33
+
34
+
35
+ class LibrarySnapshotStore:
36
+ """Own per-account Library Snapshot paths and locking."""
37
+
38
+ def __init__(self, data_dir: Path) -> None:
39
+ self._data_dir = data_dir
40
+ self._locks: dict[int, FileLock] = {}
41
+
42
+ def lock(self, account_id: int) -> FileLock:
43
+ lock_dir = self._data_dir / "snapshot-locks"
44
+ lock_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
45
+ if account_id not in self._locks:
46
+ self._locks[account_id] = FileLock(lock_dir / f"{account_id}.lock", mode=0o600)
47
+ return self._locks[account_id]
48
+
49
+ def load(self, account_id: int) -> LibrarySnapshot | None:
50
+ with self.lock(account_id):
51
+ try:
52
+ payload = json.loads(self.path(account_id).read_text(encoding="utf-8"))
53
+ if payload["version"] != 1 or payload["account_id"] != account_id:
54
+ return None
55
+ if (
56
+ not isinstance(payload["complete"], bool)
57
+ or not isinstance(payload["dirty"], bool)
58
+ or not isinstance(payload["activities"], dict)
59
+ or not isinstance(payload["reconciled_at"], int | float)
60
+ ):
61
+ return None
62
+ entries = {}
63
+ for row in payload["entries"]:
64
+ kind = MediaKind(row["kind"])
65
+ item = Media.from_catalog(
66
+ {
67
+ "ids": {"simkl": row["simkl_id"]},
68
+ "title": row["title"],
69
+ "year": row["year"],
70
+ },
71
+ kind,
72
+ )
73
+ # Reuse the API validators and strip any unexpected catalog fields.
74
+ parsed = LibraryEntry.from_server(
75
+ {
76
+ "movie" if kind is MediaKind.MOVIE else "show": {
77
+ "ids": {"simkl": item.simkl_id},
78
+ "title": item.title,
79
+ "year": item.year,
80
+ },
81
+ "status": ListStatus(row["list_status"]).api_value,
82
+ "user_rating": row["user_rating"],
83
+ "last_watched_at": row["last_watched_at"],
84
+ "watched_episodes_count": row["watched_episodes_count"],
85
+ "seasons": [
86
+ {
87
+ "number": e["season"] if e["season"] is not None else 1,
88
+ "episodes": [
89
+ {"number": e["number"], "watched_at": e["watched_at"]}
90
+ ],
91
+ }
92
+ for e in row["episodes"]
93
+ ],
94
+ },
95
+ kind,
96
+ )
97
+ if not isinstance(row["watched"], bool):
98
+ return None
99
+ entries[item.simkl_id] = LibraryEntry(
100
+ item,
101
+ parsed.list_status,
102
+ row["watched"],
103
+ parsed.last_watched_at,
104
+ parsed.user_rating,
105
+ tuple(
106
+ WatchedEpisode(positive_int(e["number"]), e["season"], e["watched_at"])
107
+ for e in row["episodes"]
108
+ ),
109
+ parsed.watched_episodes_count,
110
+ )
111
+ if payload["complete"] and "all" not in payload["activities"]:
112
+ return None
113
+ return LibrarySnapshot(
114
+ account_id,
115
+ entries,
116
+ payload["activities"],
117
+ payload["complete"],
118
+ payload["dirty"] or self.dirty_path(account_id).exists(),
119
+ payload["reconciled_at"],
120
+ )
121
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
122
+ return None
123
+
124
+ def save(self, snapshot: LibrarySnapshot) -> None:
125
+ with self.lock(snapshot.account_id):
126
+ atomic_write_json(self.path(snapshot.account_id), snapshot.payload())
127
+ if not snapshot.dirty:
128
+ self.dirty_path(snapshot.account_id).unlink(missing_ok=True)
129
+
130
+ def path(self, account_id: int) -> Path:
131
+ return self._data_dir / "accounts" / str(account_id) / "library.json"
132
+
133
+ def dirty_path(self, account_id: int) -> Path:
134
+ return self._data_dir / "snapshot-locks" / f"{account_id}.dirty"
135
+
136
+ def mark_dirty(self, account_id: int) -> None:
137
+ with self.lock(account_id):
138
+ atomic_write_bytes(self.dirty_path(account_id), b"1")
139
+
140
+ def delete(self, account_id: int) -> None:
141
+ with self.lock(account_id):
142
+ self.path(account_id).unlink(missing_ok=True)
143
+ self.dirty_path(account_id).unlink(missing_ok=True)
144
+
145
+ @contextmanager
146
+ def reversible_delete(self, account_id: int) -> Iterator[None]:
147
+ """Delete a snapshot, restoring it if the surrounding update fails."""
148
+ with self.lock(account_id):
149
+ path = self.path(account_id)
150
+ previous_content = path.read_bytes() if path.exists() else None
151
+ dirty_path = self.dirty_path(account_id)
152
+ was_dirty = dirty_path.exists()
153
+ try:
154
+ path.unlink(missing_ok=True)
155
+ dirty_path.unlink(missing_ok=True)
156
+ yield
157
+ except BaseException:
158
+ if previous_content is not None:
159
+ atomic_write_bytes(path, previous_content)
160
+ if was_dirty:
161
+ atomic_write_bytes(dirty_path, b"1")
162
+ raise