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/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
|