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/storage.py ADDED
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from dataclasses import dataclass, field
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Protocol, cast
12
+
13
+ from filelock import FileLock
14
+
15
+ from simklcli.filesystem import atomic_write_bytes, atomic_write_json
16
+
17
+
18
+ class CredentialSource(StrEnum):
19
+ ENVIRONMENT = "environment"
20
+ KEYRING = "keyring"
21
+ FILE = "file"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Account:
26
+ id: int
27
+ name: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Credential:
32
+ access_token: str = field(repr=False)
33
+ source: CredentialSource
34
+ account: Account | None
35
+ refresh_token: str | None = field(default=None, repr=False)
36
+ expires_at: float | None = None
37
+ refresh_expires_at: float | None = None
38
+ client_id: str | None = None
39
+ scope: str | None = None
40
+ invalidated: bool = False
41
+
42
+ @property
43
+ def legacy(self) -> bool:
44
+ return self.source is not CredentialSource.ENVIRONMENT and self.client_id is None
45
+
46
+
47
+ class Keyring(Protocol):
48
+ def get_password(self, service: str, username: str) -> str | None: ...
49
+
50
+ def set_password(self, service: str, username: str, password: str) -> None: ...
51
+
52
+ def delete_password(self, service: str, username: str) -> None: ...
53
+
54
+
55
+ class EmptyKeyring:
56
+ def get_password(self, service: str, username: str) -> str | None:
57
+ del service, username
58
+ return None
59
+
60
+ def set_password(self, service: str, username: str, password: str) -> None:
61
+ del service, username, password
62
+ raise RuntimeError("No keyring is available.")
63
+
64
+ def delete_password(self, service: str, username: str) -> None:
65
+ del service, username
66
+
67
+
68
+ class CredentialStorageError(RuntimeError):
69
+ """Persistent credential storage could not be read or updated."""
70
+
71
+
72
+ class CredentialRepository:
73
+ def __init__(self, config_dir: Path, keyring: Keyring) -> None:
74
+ self._path = config_dir / "auth.json"
75
+ self._lock_path = config_dir / "account.lock"
76
+ self._lock = FileLock(self._lock_path, mode=0o600)
77
+ self._keyring = keyring
78
+
79
+ @contextmanager
80
+ def account_transaction(self) -> Iterator[None]:
81
+ self._lock_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
82
+ if os.name != "nt":
83
+ self._lock_path.parent.chmod(0o700)
84
+ with self._lock:
85
+ yield
86
+
87
+ def read_account(self) -> Account | None:
88
+ if not self._path.exists():
89
+ return None
90
+ return _account_from_payload(self._read_payload())
91
+
92
+ def read(self) -> Credential | None:
93
+ if not self._path.exists():
94
+ return None
95
+ payload = self._read_payload()
96
+ source = CredentialSource(cast(str, payload["storage"]))
97
+ account = _account_from_payload(payload)
98
+ if source is CredentialSource.FILE:
99
+ token = payload.get("access_token")
100
+ else:
101
+ try:
102
+ token = self._keyring.get_password("simklcli", "access-token")
103
+ except Exception as exc:
104
+ raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE) from exc
105
+ if not isinstance(token, str) or not token:
106
+ return None
107
+ if payload["version"] == 2:
108
+ if source is CredentialSource.KEYRING:
109
+ try:
110
+ secrets = json.loads(token)
111
+ except ValueError as exc:
112
+ raise CredentialStorageError("Stored token pair could not be read.") from exc
113
+ else:
114
+ secrets = payload
115
+ if not isinstance(secrets, dict) or any(
116
+ not isinstance(secrets.get(key), str) or not secrets[key]
117
+ for key in ("access_token", "refresh_token")
118
+ ):
119
+ raise CredentialStorageError("Stored token pair could not be read.")
120
+ return Credential(
121
+ access_token=secrets["access_token"],
122
+ refresh_token=secrets["refresh_token"],
123
+ account=account,
124
+ source=source,
125
+ expires_at=cast(float, payload["expires_at"]),
126
+ refresh_expires_at=cast(float, payload["refresh_expires_at"]),
127
+ client_id=cast(str, payload["client_id"]),
128
+ scope=cast(str, payload["scope"]),
129
+ invalidated=bool(payload.get("invalidated", False)),
130
+ )
131
+ return Credential(access_token=token, source=source, account=account)
132
+
133
+ def ensure_available(self, source: CredentialSource) -> None:
134
+ if source is not CredentialSource.KEYRING:
135
+ return
136
+ priority = getattr(self._keyring, "priority", 0)
137
+ try:
138
+ usable = float(priority) > 0
139
+ self._keyring.get_password("simklcli", "access-token")
140
+ except Exception as exc:
141
+ raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE) from exc
142
+ if not usable:
143
+ raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE)
144
+
145
+ def write(
146
+ self,
147
+ *,
148
+ access_token: str,
149
+ account: Account,
150
+ source: CredentialSource,
151
+ refresh_token: str | None = None,
152
+ expires_at: float | None = None,
153
+ refresh_expires_at: float | None = None,
154
+ client_id: str | None = None,
155
+ scope: str | None = None,
156
+ invalidated: bool = False,
157
+ ) -> None:
158
+ if not access_token:
159
+ raise CredentialStorageError("Refusing to persist an empty Access Token.")
160
+ payload: dict[str, object] = {
161
+ "version": 1,
162
+ "storage": source.value,
163
+ "account": {"id": account.id, "name": account.name},
164
+ }
165
+ keyring_value = access_token
166
+ if refresh_token is not None:
167
+ if (
168
+ not refresh_token
169
+ or not client_id
170
+ or not scope
171
+ or any(
172
+ not isinstance(value, (int, float))
173
+ or isinstance(value, bool)
174
+ or value <= 0
175
+ or not math.isfinite(value)
176
+ for value in (expires_at, refresh_expires_at)
177
+ )
178
+ ):
179
+ raise CredentialStorageError("Refusing to persist an incomplete V2 credential.")
180
+ payload.update(
181
+ version=2,
182
+ client_id=client_id,
183
+ scope=scope,
184
+ expires_at=expires_at,
185
+ refresh_expires_at=refresh_expires_at,
186
+ invalidated=invalidated,
187
+ )
188
+ keyring_value = json.dumps(
189
+ {"access_token": access_token, "refresh_token": refresh_token}
190
+ )
191
+ previous_metadata = self._path.read_bytes() if self._path.exists() else None
192
+ replacing_keyring = (
193
+ source is CredentialSource.FILE
194
+ and previous_metadata is not None
195
+ and json.loads(previous_metadata).get("storage") == CredentialSource.KEYRING.value
196
+ )
197
+ previous_keyring_token: str | None = None
198
+ keyring_changed = False
199
+ if source is CredentialSource.FILE:
200
+ payload["access_token"] = access_token
201
+ if refresh_token is not None:
202
+ payload["refresh_token"] = refresh_token
203
+ elif source is not CredentialSource.KEYRING:
204
+ raise CredentialStorageError("Environment credentials cannot be persisted.")
205
+ try:
206
+ if replacing_keyring:
207
+ previous_keyring_token = self._keyring.get_password("simklcli", "access-token")
208
+ if source is CredentialSource.KEYRING:
209
+ try:
210
+ previous_keyring_token = self._keyring.get_password("simklcli", "access-token")
211
+ keyring_changed = previous_keyring_token != keyring_value
212
+ if keyring_changed:
213
+ self._keyring.set_password("simklcli", "access-token", keyring_value)
214
+ except Exception as keyring_error:
215
+ raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE) from keyring_error
216
+ atomic_write_json(self._path, payload)
217
+ if replacing_keyring and previous_keyring_token is not None:
218
+ keyring_changed = True
219
+ self._keyring.delete_password("simklcli", "access-token")
220
+ except BaseException:
221
+ try:
222
+ _restore_file(self._path, previous_metadata)
223
+ if (source is CredentialSource.KEYRING or replacing_keyring) and keyring_changed:
224
+ if previous_keyring_token is None:
225
+ self._keyring.delete_password("simklcli", "access-token")
226
+ else:
227
+ self._keyring.set_password(
228
+ "simklcli", "access-token", previous_keyring_token
229
+ )
230
+ except Exception as rollback_error:
231
+ raise CredentialStorageError(
232
+ "Credential persistence failed and could not be rolled back completely."
233
+ ) from rollback_error
234
+ raise
235
+
236
+ def save(self, credential: Credential) -> None:
237
+ if credential.account is None:
238
+ raise CredentialStorageError("The credential needs a validated account.")
239
+ self.write(
240
+ access_token=credential.access_token,
241
+ account=credential.account,
242
+ source=credential.source,
243
+ refresh_token=credential.refresh_token,
244
+ expires_at=credential.expires_at,
245
+ refresh_expires_at=credential.refresh_expires_at,
246
+ client_id=credential.client_id,
247
+ scope=credential.scope,
248
+ invalidated=credential.invalidated,
249
+ )
250
+
251
+ def delete(self) -> Account | None:
252
+ if not self._path.exists():
253
+ return None
254
+ payload = self._read_payload()
255
+ account_payload = payload.get("account")
256
+ account = (
257
+ Account(id=account_payload["id"], name=account_payload["name"])
258
+ if isinstance(account_payload, dict)
259
+ else None
260
+ )
261
+ keyring_token: str | None = None
262
+ keyring_touched = False
263
+ try:
264
+ if payload.get("storage") == CredentialSource.KEYRING.value:
265
+ try:
266
+ keyring_token = self._keyring.get_password("simklcli", "access-token")
267
+ if keyring_token is not None:
268
+ keyring_touched = True
269
+ self._keyring.delete_password("simklcli", "access-token")
270
+ except Exception as keyring_error:
271
+ raise CredentialStorageError(
272
+ "Could not delete the OS keyring credential."
273
+ ) from keyring_error
274
+ self._path.unlink(missing_ok=True)
275
+ except BaseException:
276
+ if keyring_touched and keyring_token is not None:
277
+ try:
278
+ self._keyring.set_password("simklcli", "access-token", keyring_token)
279
+ except Exception as rollback_error:
280
+ raise CredentialStorageError(
281
+ "Credential deletion failed and could not be rolled back completely."
282
+ ) from rollback_error
283
+ raise
284
+ return account
285
+
286
+ def _read_payload(self) -> dict[str, object]:
287
+ try:
288
+ payload = json.loads(self._path.read_text(encoding="utf-8"))
289
+ if (
290
+ not isinstance(payload, dict)
291
+ or isinstance(payload.get("version"), bool)
292
+ or payload.get("version") not in {1, 2}
293
+ ):
294
+ raise ValueError
295
+ if CredentialSource(payload["storage"]) is CredentialSource.ENVIRONMENT:
296
+ raise ValueError
297
+ account = payload["account"]
298
+ if not isinstance(account, dict):
299
+ raise ValueError
300
+ account_id = account.get("id")
301
+ name = account.get("name")
302
+ if (
303
+ not isinstance(account_id, int)
304
+ or isinstance(account_id, bool)
305
+ or account_id <= 0
306
+ or not isinstance(name, str)
307
+ or not name.strip()
308
+ ):
309
+ raise ValueError
310
+ if payload["version"] == 2 and (
311
+ any(
312
+ not isinstance(payload.get(key), str) or not payload[key]
313
+ for key in ("client_id", "scope")
314
+ )
315
+ or any(
316
+ not isinstance(payload.get(key), (int, float))
317
+ or isinstance(payload[key], bool)
318
+ or payload[key] <= 0
319
+ or not math.isfinite(payload[key])
320
+ for key in ("expires_at", "refresh_expires_at")
321
+ )
322
+ or not isinstance(payload.get("invalidated", False), bool)
323
+ ):
324
+ raise ValueError
325
+ except (KeyError, OSError, TypeError, ValueError) as exc:
326
+ raise CredentialStorageError("Stored account metadata could not be read.") from exc
327
+ return payload
328
+
329
+
330
+ def _restore_file(path: Path, previous_content: bytes | None) -> None:
331
+ if previous_content is None:
332
+ if path.exists():
333
+ path.unlink()
334
+ else:
335
+ atomic_write_bytes(path, previous_content)
336
+
337
+
338
+ _KEYRING_UNAVAILABLE_MESSAGE = (
339
+ "No usable OS keyring is available. Re-run login with --storage file "
340
+ "to explicitly select plaintext-file storage."
341
+ )
342
+
343
+
344
+ def _account_from_payload(payload: dict[str, object]) -> Account:
345
+ account_data = payload["account"]
346
+ assert isinstance(account_data, dict)
347
+ return Account(id=account_data["id"], name=account_data["name"])
simklcli/targets.py ADDED
@@ -0,0 +1,117 @@
1
+ """Validate selectors and build explicit Episode targets before any write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from simklcli.catalog import Catalog
10
+ from simklcli.media import LibraryEntry, Media, MediaKind, positive_int
11
+
12
+
13
+ def viewing_time(value: str | None) -> str:
14
+ time = (
15
+ datetime.now(UTC) if value is None else datetime.fromisoformat(value.replace("Z", "+00:00"))
16
+ )
17
+ if time.tzinfo is None:
18
+ raise ValueError("--at requires an ISO-8601 timestamp with a timezone.")
19
+ return time.astimezone(UTC).isoformat().replace("+00:00", "Z")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class WatchedTarget:
24
+ coordinates: tuple[tuple[int | None, int], ...] = ()
25
+ bulk: bool = False
26
+ external_id: tuple[str, str] | None = None
27
+
28
+ def item_payload(self, item: Media, *, watched_at: str | None = None) -> dict[str, Any]:
29
+ payload: dict[str, Any] = {"ids": {"simkl": item.simkl_id}}
30
+ if watched_at is not None:
31
+ payload["watched_at"] = watched_at
32
+ if self.external_id is not None:
33
+ provider, value = self.external_id
34
+ payload["episodes"] = [{"ids": {provider: value}}]
35
+ return payload
36
+ if self.coordinates:
37
+ episodes_by_season: dict[int, list[dict[str, Any]]] = {}
38
+ for season, number in self.coordinates:
39
+ episode: dict[str, Any] = {"number": number}
40
+ if watched_at is not None:
41
+ episode["watched_at"] = watched_at
42
+ episodes_by_season.setdefault(season if season is not None else 1, []).append(
43
+ episode
44
+ )
45
+ payload["seasons"] = [
46
+ {"number": season, "episodes": episodes}
47
+ for season, episodes in episodes_by_season.items()
48
+ ]
49
+ return payload
50
+
51
+ def matches(self, entry: LibraryEntry | None, *, watched: bool) -> bool | None:
52
+ if self.external_id is not None:
53
+ # The catalog API does not guarantee external episode IDs in its response.
54
+ # A lost write response cannot be verified from a parent-level boolean.
55
+ return None
56
+ if not self.coordinates:
57
+ return (entry is not None and entry.watched) == watched
58
+ actual = set() if entry is None else {(e.season, e.number) for e in entry.episodes}
59
+ return all((coordinate in actual) == watched for coordinate in self.coordinates)
60
+
61
+
62
+ def select_target(
63
+ catalog: Catalog,
64
+ item: Media,
65
+ *,
66
+ season: int | None,
67
+ episode: int | None,
68
+ all_episodes: bool,
69
+ episode_id: str | None = None,
70
+ marking: bool = True,
71
+ ) -> WatchedTarget:
72
+ if episode_id is not None and (season is not None or episode is not None or all_episodes):
73
+ raise ValueError("--episode-id cannot be combined with other target selectors.")
74
+ if episode is not None and all_episodes:
75
+ raise ValueError("--episode and --all cannot be combined.")
76
+ if item.standalone:
77
+ if season is not None or episode is not None or all_episodes or episode_id is not None:
78
+ raise ValueError("Standalone Media Items do not accept Episode selectors.")
79
+ return WatchedTarget()
80
+ if item.kind is MediaKind.ANIME and season is not None:
81
+ raise ValueError("Anime uses canonical Episode Numbers; omit --season.")
82
+ if not all_episodes and episode is None and episode_id is None:
83
+ raise ValueError("Episodic Media Items require --episode, --season … --all, or --all.")
84
+ if item.kind is MediaKind.SHOW and episode is not None and season is None:
85
+ raise ValueError("A Show Episode requires --season and --episode.")
86
+ if episode_id is not None:
87
+ provider, separator, value = episode_id.partition(":")
88
+ if not separator or provider not in {"tvdb", "anidb"} or not value:
89
+ raise ValueError("--episode-id must be a qualified TVDB or AniDB Episode ID.")
90
+ return WatchedTarget(external_id=(provider, value))
91
+ rows = catalog.episodes(item)
92
+ coordinates: list[tuple[int | None, int]] = []
93
+ for row in rows:
94
+ row_season = row.get("season") if item.kind is MediaKind.SHOW else None
95
+ number = positive_int(row.get("episode", row.get("number")))
96
+ if season is not None and season != row_season:
97
+ continue
98
+ if episode is not None and episode != number:
99
+ continue
100
+ if all_episodes and marking and not _has_aired(row):
101
+ continue
102
+ coordinates.append((row_season, number))
103
+ if not coordinates:
104
+ raise ValueError("No applicable Episodes matched this parent and the selected coordinates.")
105
+ return WatchedTarget(tuple(coordinates), all_episodes)
106
+
107
+
108
+ def _has_aired(row: dict[str, Any]) -> bool:
109
+ if isinstance(row.get("aired"), bool):
110
+ return bool(row["aired"])
111
+ date = row.get("date", row.get("aired"))
112
+ if not isinstance(date, str):
113
+ return False
114
+ aired = datetime.fromisoformat(date.replace("Z", "+00:00"))
115
+ if aired.tzinfo is None:
116
+ aired = aired.replace(tzinfo=UTC)
117
+ return aired <= datetime.now(UTC)