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/tracking.py ADDED
@@ -0,0 +1,484 @@
1
+ """Activities-gated reconciliation and single-attempt, verified tracking writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from contextlib import suppress
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from simklcli.api import InvalidAccessTokenError, PostBlockedError, SimklClient, SimklResponseError
13
+ from simklcli.media import (
14
+ LibraryEntry,
15
+ ListStatus,
16
+ Media,
17
+ MediaKind,
18
+ library_entries,
19
+ positive_int,
20
+ simkl_id,
21
+ validate_status,
22
+ )
23
+ from simklcli.snapshots import LibrarySnapshot, LibrarySnapshotStore
24
+ from simklcli.targets import WatchedTarget
25
+
26
+
27
+ class WriteOutcomeError(SimklResponseError):
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ *,
32
+ outcome: str = "unknown",
33
+ details: dict[str, Any] | None = None,
34
+ exit_code: int = 1,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.outcome = outcome
38
+ self.details = details or {}
39
+ self.exit_code = exit_code
40
+
41
+
42
+ class Tracking:
43
+ def __init__(
44
+ self,
45
+ api: SimklClient,
46
+ snapshots: LibrarySnapshotStore,
47
+ *,
48
+ account_id: int,
49
+ access_token: str,
50
+ clock: Callable[[], float] = time.time,
51
+ ) -> None:
52
+ self.api = api
53
+ self.snapshots = snapshots
54
+ self.account_id = account_id
55
+ self.token = access_token
56
+ self.clock = clock
57
+
58
+ def _get(self, path: str, params: dict[str, str | int] | None = None) -> dict[str, Any]:
59
+ payload = self.api.get_json(path, access_token=self.token, params=params)
60
+ if not isinstance(payload, dict):
61
+ raise SimklResponseError("Malformed Library response; freshness cannot be established.")
62
+ return payload
63
+
64
+ def activities(self) -> dict[str, Any]:
65
+ payload = self._get("/sync/activities")
66
+ if "all" not in payload or not isinstance(payload["all"], str | type(None)):
67
+ raise SimklResponseError(
68
+ "Malformed activity positions; freshness cannot be established."
69
+ )
70
+ for kind in MediaKind:
71
+ block = payload.get(kind.activity, {})
72
+ if not isinstance(block, dict) or any(
73
+ not isinstance(value, str | type(None)) for value in block.values()
74
+ ):
75
+ raise SimklResponseError(
76
+ "Malformed activity positions; freshness cannot be established."
77
+ )
78
+ return payload
79
+
80
+ def _entries(
81
+ self, path: str, params: dict[str, str | int] | None = None
82
+ ) -> dict[int, LibraryEntry]:
83
+ flags: dict[str, str | int] = {
84
+ "extended": "full",
85
+ "include_all_episodes": "yes",
86
+ "episode_watched_at": "yes",
87
+ }
88
+ try:
89
+ return library_entries(self._get(path, {**flags, **(params or {})}))
90
+ except (ValueError, TypeError, KeyError, AttributeError) as exc:
91
+ raise SimklResponseError(
92
+ "Malformed Library state; freshness cannot be established."
93
+ ) from exc
94
+
95
+ def read(self, *, offline: bool = False, repair: bool = False) -> LibrarySnapshot:
96
+ with self.snapshots.lock(self.account_id):
97
+ previous = self.snapshots.load(self.account_id)
98
+ if offline:
99
+ if previous is None or not previous.complete:
100
+ raise ValueError(
101
+ "No complete Library Snapshot exists for this account. Read online first."
102
+ )
103
+ return previous
104
+ current = self.activities() # Always captured before any anchored Library fetch.
105
+ if repair or previous is None or not previous.complete:
106
+ entries: dict[int, LibraryEntry] = {}
107
+ for kind in MediaKind:
108
+ entries.update(self._entries(f"/sync/all-items/{kind.bucket}"))
109
+ else:
110
+ entries = dict(previous.entries)
111
+ if previous.dirty:
112
+ # A failed focused read cannot be repaired from an unchanged activity clock.
113
+ # Explicitly rebuild uncertain state instead of claiming it is current.
114
+ for kind in MediaKind:
115
+ fresh = self._entries(f"/sync/all-items/{kind.bucket}")
116
+ entries = {
117
+ key: value
118
+ for key, value in entries.items()
119
+ if value.item.kind is not kind
120
+ }
121
+ entries.update(fresh)
122
+ elif current != previous.activities:
123
+ since = (
124
+ {"date_from": previous.activities["all"]}
125
+ if previous.activities.get("all")
126
+ else {}
127
+ )
128
+ for kind in MediaKind:
129
+ before = previous.activities.get(kind.activity, {})
130
+ after = current.get(kind.activity, {})
131
+ if any(
132
+ before.get(s.api_value) != after.get(s.api_value) for s in ListStatus
133
+ ):
134
+ entries.update(self._entries(f"/sync/all-items/{kind.bucket}", since))
135
+ if before.get("rated_at") != after.get("rated_at"):
136
+ entries.update(self._entries(f"/sync/ratings/{kind.bucket}", since))
137
+ if any(
138
+ previous.activities.get(k.activity, {}).get("removed_from_list")
139
+ != current.get(k.activity, {}).get("removed_from_list")
140
+ for k in MediaKind
141
+ ):
142
+ ids_payload = self._get("/sync/all-items", {"extended": "simkl_ids_only"})
143
+ try:
144
+ ids = {
145
+ simkl_id(
146
+ row.get("movie") or row.get("show") or row.get("anime") or row
147
+ )
148
+ for k in MediaKind
149
+ for row in ids_payload.get(k.bucket, [])
150
+ }
151
+ except (ValueError, TypeError, AttributeError) as exc:
152
+ raise SimklResponseError(
153
+ "Malformed IDs-only Library comparison."
154
+ ) from exc
155
+ entries = {key: value for key, value in entries.items() if key in ids}
156
+ snapshot = LibrarySnapshot(self.account_id, entries, current, True, False, self.clock())
157
+ self.snapshots.save(snapshot)
158
+ return snapshot
159
+
160
+ def focused(self, item: Media) -> LibraryEntry | None:
161
+ # A user-requested write read-back is scoped to one media kind, never initializes
162
+ # the complete Library, and always reads the server rather than trusting local state.
163
+ self.activities()
164
+ return self._entries(f"/sync/all-items/{item.kind.bucket}").get(item.simkl_id)
165
+
166
+ def _dirty(self) -> bool:
167
+ try:
168
+ self.snapshots.mark_dirty(self.account_id)
169
+ snapshot = self.snapshots.load(self.account_id) or LibrarySnapshot(self.account_id)
170
+ snapshot.dirty = True
171
+ # The separate marker still forces reconciliation if the snapshot cannot be saved.
172
+ with suppress(OSError):
173
+ self.snapshots.save(snapshot)
174
+ return True
175
+ except OSError:
176
+ return False
177
+
178
+ def _save_focused(self, item: Media, entry: LibraryEntry | None) -> None:
179
+ snapshot = self.snapshots.load(self.account_id) or LibrarySnapshot(self.account_id)
180
+ if entry is None:
181
+ snapshot.entries.pop(item.simkl_id, None)
182
+ else:
183
+ snapshot.entries[item.simkl_id] = entry
184
+ # Keep the complete Library activity positions/age: one entry is not a full sync.
185
+ self.snapshots.save(snapshot)
186
+
187
+ def _send(
188
+ self,
189
+ path: str,
190
+ body: dict[str, Any],
191
+ item: Media,
192
+ intended: Callable[[LibraryEntry | None], bool | None],
193
+ *,
194
+ params: dict[str, str | int] | None = None,
195
+ rewatch: bool = False,
196
+ ) -> bool:
197
+ try:
198
+ result = self.api.write_state(path, body, access_token=self.token, params=params)
199
+ if rewatch:
200
+ statuses = result.get("added", {}).get("statuses", [])
201
+ if not any(
202
+ s.get("response", {}).get("rewatch_status") in {"active", "completed", "closed"}
203
+ for s in statuses
204
+ ):
205
+ raise SimklResponseError("Simkl did not confirm a recorded Rewatch.")
206
+ return False
207
+ except (InvalidAccessTokenError, PostBlockedError):
208
+ raise
209
+ except (httpx.HTTPError, SimklResponseError, ValueError, TypeError) as exc:
210
+ try:
211
+ state = self.focused(item)
212
+ verified = intended(state)
213
+ except InvalidAccessTokenError:
214
+ raise
215
+ except (httpx.HTTPError, SimklResponseError, ValueError, OSError):
216
+ verified = None
217
+ if verified is True:
218
+ return True
219
+ if verified is False:
220
+ raise WriteOutcomeError(
221
+ "Read-back confirmed the intended change is absent; rerunning is safe.",
222
+ outcome="not_applied",
223
+ ) from exc
224
+ raise WriteOutcomeError(
225
+ "Outcome unknown. Verify with simkl library repair --json "
226
+ "and the item's Simkl page; "
227
+ "do not blindly retry."
228
+ ) from exc
229
+
230
+ def _write(
231
+ self,
232
+ item: Media,
233
+ path: str,
234
+ body: dict[str, Any],
235
+ intended: Callable[[LibraryEntry | None], bool | None],
236
+ *,
237
+ params: dict[str, str | int] | None = None,
238
+ rewatch: bool = False,
239
+ verify: Callable[[LibraryEntry | None], None] | None = None,
240
+ ) -> dict[str, Any]:
241
+ with self.snapshots.lock(self.account_id):
242
+ try:
243
+ recovered = self._send(path, body, item, intended, params=params, rewatch=rewatch)
244
+ try:
245
+ state = self.focused(item)
246
+ if verify is not None:
247
+ verify(state)
248
+ if (
249
+ not rewatch
250
+ and intended(state) is False
251
+ and (path != "/sync/add-to-list" or state is None)
252
+ ):
253
+ raise WriteOutcomeError(
254
+ "Read-back confirmed an incomplete change; rerunning is safe.",
255
+ outcome="not_applied",
256
+ )
257
+ self._save_focused(item, state)
258
+ return self._result(item, state, recovered=recovered)
259
+ except InvalidAccessTokenError as exc:
260
+ exc.remote_success = True
261
+ raise
262
+ except WriteOutcomeError:
263
+ raise
264
+ except (httpx.HTTPError, SimklResponseError, ValueError, OSError):
265
+ saved = self._dirty()
266
+ return self._result(
267
+ item, None, recovered=recovered, dirty=True, dirty_saved=saved
268
+ )
269
+ except KeyboardInterrupt as exc:
270
+ self._dirty()
271
+ raise WriteOutcomeError(
272
+ "Outcome unknown after cancellation; verify before retrying.", exit_code=130
273
+ ) from exc
274
+ except WriteOutcomeError:
275
+ self._dirty()
276
+ raise
277
+
278
+ @staticmethod
279
+ def _result(
280
+ item: Media,
281
+ state: LibraryEntry | None,
282
+ *,
283
+ recovered: bool = False,
284
+ dirty: bool = False,
285
+ dirty_saved: bool = True,
286
+ ) -> dict[str, Any]:
287
+ message = "Remote write succeeded."
288
+ if recovered:
289
+ message += " Read-back recovered the result."
290
+ if dirty:
291
+ message += (
292
+ " Local state is dirty; the next online read will reconcile. "
293
+ "Do not repeat the write."
294
+ )
295
+ if not dirty_saved:
296
+ message += (
297
+ " The dirty marker could not be saved; run simkl library repair explicitly."
298
+ )
299
+ return {
300
+ "item": item.identity(),
301
+ "remote_success": True,
302
+ "state": state.payload() if state else None,
303
+ "local_state": ("dirty" if dirty_saved else "unavailable") if dirty else "reconciled",
304
+ "recovered_by_readback": recovered,
305
+ "message": message,
306
+ }
307
+
308
+ def set_status(self, item: Media, status: ListStatus) -> dict[str, Any]:
309
+ validate_status(item.kind, status)
310
+ return self._write(
311
+ item,
312
+ "/sync/add-to-list",
313
+ {item.kind.bucket: [{"ids": {"simkl": item.simkl_id}, "to": status.api_value}]},
314
+ lambda state: state is not None and state.list_status is status,
315
+ )
316
+
317
+ def remove(self, item: Media) -> dict[str, Any]:
318
+ bucket = "movies" if item.kind is MediaKind.MOVIE else "shows"
319
+ return self._write(
320
+ item,
321
+ "/sync/history/remove",
322
+ {bucket: [{"ids": {"simkl": item.simkl_id}}]},
323
+ lambda state: state is None,
324
+ )
325
+
326
+ def rating(self, item: Media, score: int | None) -> dict[str, Any]:
327
+ if score is not None and (isinstance(score, bool) or not 1 <= score <= 10):
328
+ raise ValueError("User Rating must be from 1 to 10.")
329
+ payload: dict[str, Any] = {"ids": {"simkl": item.simkl_id}}
330
+ if score is not None:
331
+ payload["rating"] = score
332
+ return self._write(
333
+ item,
334
+ "/sync/ratings" + ("/remove" if score is None else ""),
335
+ {item.kind.bucket: [payload]},
336
+ lambda state: (state.user_rating if state is not None else None) == score,
337
+ )
338
+
339
+ def mark(
340
+ self,
341
+ item: Media,
342
+ target: WatchedTarget,
343
+ *,
344
+ watched_at: str,
345
+ rewatch: bool = False,
346
+ rewatch_id: int | None = None,
347
+ ) -> dict[str, Any]:
348
+ payload = target.item_payload(item, watched_at=watched_at)
349
+ if rewatch:
350
+ settings = self.api.checked_json(
351
+ self.api._request("POST", "/users/settings", access_token=self.token)
352
+ )
353
+ if settings.get("account", {}).get("type") not in {"pro", "vip"}:
354
+ raise ValueError("Rewatch tracking requires Simkl PRO or VIP.")
355
+ payload["is_rewatch"] = True
356
+ if rewatch_id is None:
357
+ self.activities()
358
+ sessions = self._get(
359
+ f"/sync/all-items/{item.kind.bucket}", {"allow_rewatch": "yes"}
360
+ )
361
+ for row in sessions.get(item.kind.bucket, []):
362
+ identity = row.get("movie") or row.get("show") or row.get("anime")
363
+ if (
364
+ row.get("is_rewatch")
365
+ and row.get("rewatch_status") == "active"
366
+ and isinstance(identity, dict)
367
+ and simkl_id(identity) == item.simkl_id
368
+ ):
369
+ rewatch_id = positive_int(row.get("rewatch_id"))
370
+ break
371
+ if rewatch_id is not None:
372
+ payload["rewatch_id"] = rewatch_id
373
+ elif rewatch_id is not None:
374
+ raise ValueError("--rewatch-id requires --rewatch.")
375
+ return self._write(
376
+ item,
377
+ "/sync/history",
378
+ {item.kind.bucket: [payload]},
379
+ lambda state: None if rewatch else target.matches(state, watched=True),
380
+ params={"allow_rewatch": "yes"} if rewatch else None,
381
+ rewatch=rewatch,
382
+ )
383
+
384
+ def unmark(self, item: Media, target: WatchedTarget) -> dict[str, Any]:
385
+ bucket = "movies" if item.kind is MediaKind.MOVIE else "shows"
386
+ if target.coordinates or target.external_id is not None:
387
+ before = self.focused(item)
388
+
389
+ def preserved(state: LibraryEntry | None) -> bool | None:
390
+ matched = target.matches(state, watched=False)
391
+ if matched is None:
392
+ return None
393
+ same = (before is None and state is None) or (
394
+ before is not None
395
+ and state is not None
396
+ and before.list_status is state.list_status
397
+ and before.user_rating == state.user_rating
398
+ )
399
+ return matched if same else None
400
+
401
+ def verify_preservation(state: LibraryEntry | None) -> None:
402
+ if (before is None and state is not None) or (
403
+ before is not None
404
+ and (
405
+ state is None
406
+ or state.list_status is not before.list_status
407
+ or state.user_rating != before.user_rating
408
+ )
409
+ ):
410
+ raise WriteOutcomeError(
411
+ "Partial or unknown outcome: Episode unmark did not preserve Library state."
412
+ " "
413
+ "Verify with simkl library repair --json before another change.",
414
+ outcome="partial_or_unknown",
415
+ details={"captured_state": before.payload() if before else None},
416
+ )
417
+
418
+ return self._write(
419
+ item,
420
+ "/sync/history/remove",
421
+ {bucket: [target.item_payload(item)]},
422
+ preserved,
423
+ verify=verify_preservation,
424
+ )
425
+ with self.snapshots.lock(self.account_id):
426
+ before = self.focused(item)
427
+ if before is None or not before.watched:
428
+ self._save_focused(item, before)
429
+ return {**self._result(item, before), "message": "Watched State was already clear."}
430
+ if item.kind is MediaKind.MOVIE and before.list_status is ListStatus.COMPLETED:
431
+ raise ValueError(
432
+ "Simkl cannot keep a Movie Completed without marking it watched. "
433
+ "Move it explicitly to Plan to Watch or Dropped with simkl library "
434
+ "set-status to clear Watched State and preserve its User Rating. "
435
+ "Nothing was changed."
436
+ )
437
+ try:
438
+ recovered = self._send(
439
+ "/sync/history/remove",
440
+ {bucket: [target.item_payload(item)]},
441
+ item,
442
+ lambda state: state is None,
443
+ )
444
+ restore = {"ids": {"simkl": item.simkl_id}, "to": before.list_status.api_value}
445
+ recovered |= self._send(
446
+ "/sync/add-to-list",
447
+ {item.kind.bucket: [restore]},
448
+ item,
449
+ lambda state: state is not None and state.list_status is before.list_status,
450
+ )
451
+ if before.user_rating is not None:
452
+ recovered |= self._send(
453
+ "/sync/ratings",
454
+ {
455
+ item.kind.bucket: [
456
+ {"ids": {"simkl": item.simkl_id}, "rating": before.user_rating}
457
+ ]
458
+ },
459
+ item,
460
+ lambda state: state is not None and state.user_rating == before.user_rating,
461
+ )
462
+ after = self.focused(item)
463
+ if (
464
+ after is None
465
+ or after.watched
466
+ or after.list_status is not before.list_status
467
+ or after.user_rating != before.user_rating
468
+ ):
469
+ raise WriteOutcomeError(
470
+ "Final read-back could not confirm preserved Library state."
471
+ )
472
+ self._save_focused(item, after)
473
+ return self._result(item, after, recovered=recovered)
474
+ except (httpx.HTTPError, SimklResponseError, OSError, KeyboardInterrupt) as exc:
475
+ self._dirty()
476
+ if isinstance(exc, InvalidAccessTokenError):
477
+ raise
478
+ raise WriteOutcomeError(
479
+ "Partial or unknown outcome while preserving Library state. "
480
+ "Verify with simkl library repair --json before making another change.",
481
+ outcome="partial_or_unknown",
482
+ details={"captured_state": before.payload()},
483
+ exit_code=130 if isinstance(exc, KeyboardInterrupt) else 1,
484
+ ) from exc