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/commands.py ADDED
@@ -0,0 +1,437 @@
1
+ """Public tracking command surface and shared terminal/JSON behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from collections.abc import Callable
8
+ from contextlib import nullcontext
9
+ from dataclasses import replace
10
+ from typing import Annotated, Any
11
+
12
+ import httpx
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from simklcli.api import InvalidAccessTokenError, SimklResponseError
18
+ from simklcli.catalog import AmbiguousReferenceError, Catalog
19
+ from simklcli.media import ListStatus, Media, MediaKind
20
+ from simklcli.runtime import Runtime
21
+ from simklcli.storage import CredentialSource, CredentialStorageError
22
+ from simklcli.targets import select_target, viewing_time
23
+ from simklcli.tracking import Tracking, WriteOutcomeError
24
+
25
+ Reference = Annotated[str, typer.Argument(help="Media Reference: qualified ID or title text.")]
26
+ KindOption = Annotated[MediaKind | None, typer.Option(help="Media kind or ID namespace.")]
27
+ YearOption = Annotated[int | None, typer.Option(min=1, help="Disambiguate by release year.")]
28
+ JsonOption = Annotated[bool, typer.Option("--json", help="Emit one JSON payload; never prompt.")]
29
+ YesOption = Annotated[
30
+ bool, typer.Option("--yes", "-y", help="Confirm noninteractive or bulk operations.")
31
+ ]
32
+ SeasonOption = Annotated[int | None, typer.Option(min=0, help="Show Season; omit for Anime.")]
33
+ EpisodeOption = Annotated[int | None, typer.Option(min=1, help="Canonical Episode Number.")]
34
+ AllOption = Annotated[bool, typer.Option("--all", help="Target every applicable Episode.")]
35
+ EpisodeIdOption = Annotated[
36
+ str | None, typer.Option(help="Qualified TVDB/AniDB Episode ID within this parent.")
37
+ ]
38
+
39
+
40
+ def _media_table(rows: list[dict[str, Any]]) -> Table:
41
+ table = Table()
42
+ for name in ["Simkl ID", "Title", "Year", "Kind", "List Status", "User Rating"]:
43
+ table.add_column(name)
44
+ for row in rows:
45
+ table.add_row(
46
+ str(row["simkl_id"]),
47
+ row["title"],
48
+ str(row.get("year") or ""),
49
+ row["kind"],
50
+ str(row.get("list_status", "")),
51
+ str(row.get("user_rating") or ""),
52
+ )
53
+ return table
54
+
55
+
56
+ def _confirm(runtime: Runtime, *, yes: bool, json_output: bool, message: str) -> None:
57
+ if yes:
58
+ return
59
+ if json_output or not runtime.interactive:
60
+ raise ValueError("This operation requires --yes in JSON or noninteractive use.")
61
+ if not typer.confirm(message):
62
+ raise typer.Abort()
63
+
64
+
65
+ def _resolve(
66
+ runtime: Runtime,
67
+ catalog: Catalog,
68
+ reference: str,
69
+ kind: MediaKind | None,
70
+ year: int | None,
71
+ json_output: bool,
72
+ ) -> Media:
73
+ try:
74
+ return catalog.resolve(reference, kind=kind, year=year)
75
+ except AmbiguousReferenceError as exc:
76
+ if json_output or not runtime.interactive:
77
+ raise
78
+ Console().print(_media_table([item.identity() for item in exc.candidates]))
79
+ choice = typer.prompt("Choose candidate number (starting at 1)", type=int)
80
+ if not 1 <= choice <= len(exc.candidates):
81
+ raise ValueError("Invalid candidate selection.") from exc
82
+ chosen = exc.candidates[choice - 1]
83
+ return catalog.details(chosen.simkl_id, chosen.kind)
84
+
85
+
86
+ def _tracking(runtime: Runtime, *, offline: bool = False) -> Tracking:
87
+ credential = runtime.active_credential()
88
+ if credential is None:
89
+ raise ValueError("No Access Token is available. Run simkl auth login explicitly.")
90
+ account = credential.account
91
+ account_id = account.id if account is not None else None
92
+ if offline:
93
+ api = runtime.api_client()
94
+ else:
95
+ credential, api = runtime.authenticated_client()
96
+ if credential.source is CredentialSource.ENVIRONMENT:
97
+ if offline:
98
+ account_id = runtime.identity_gate().remembered_account_id(credential.access_token)
99
+ if account_id is None:
100
+ stored = runtime.credentials.read()
101
+ if stored is None or stored.access_token != credential.access_token:
102
+ raise ValueError(
103
+ "An environment override needs online account validation "
104
+ "before Library reads."
105
+ )
106
+ account_id = stored.account.id if stored.account is not None else None
107
+ else:
108
+ account = api.get_authenticated_account(credential.access_token)
109
+ account_id = account.id
110
+ runtime.identity_gate().bind_account(credential.access_token, account.id)
111
+ if account_id is None:
112
+ raise ValueError("The active credential has no validated Authenticated Account.")
113
+ if runtime.rate_gate is not None:
114
+ runtime.rate_gate.bind_account(credential.access_token, account_id)
115
+ return Tracking(
116
+ api,
117
+ runtime.snapshots,
118
+ account_id=account_id,
119
+ access_token=api.access_token or credential.access_token,
120
+ )
121
+
122
+
123
+ def _invalidate(runtime: Runtime) -> None:
124
+ credential = runtime.active_credential()
125
+ if credential is not None and credential.source is not CredentialSource.ENVIRONMENT:
126
+ runtime.credentials.save(replace(credential, invalidated=True))
127
+
128
+
129
+ def _catalog(runtime: Runtime) -> Catalog:
130
+ if runtime.active_credential() is None:
131
+ return Catalog(runtime.api_client())
132
+ _, api = runtime.authenticated_client()
133
+ return Catalog(api)
134
+
135
+
136
+ def _run(
137
+ runtime_factory: Callable[[], Runtime],
138
+ json_output: bool,
139
+ action: Callable[[Runtime], dict[str, Any]],
140
+ *,
141
+ authenticated: bool = False,
142
+ ) -> None:
143
+ runtime = runtime_factory()
144
+ try:
145
+ with runtime.credentials.account_transaction() if authenticated else nullcontext():
146
+ try:
147
+ payload = action(runtime)
148
+ except InvalidAccessTokenError:
149
+ _invalidate(runtime)
150
+ raise
151
+ except (
152
+ ValueError,
153
+ SimklResponseError,
154
+ CredentialStorageError,
155
+ httpx.HTTPError,
156
+ OSError,
157
+ KeyboardInterrupt,
158
+ typer.Abort,
159
+ ) as exc:
160
+ error: dict[str, Any] = {"error": "command_failed", "message": str(exc) or "Cancelled."}
161
+ if isinstance(exc, AmbiguousReferenceError):
162
+ error.update(
163
+ error="ambiguous_reference", candidates=[m.identity() for m in exc.candidates]
164
+ )
165
+ if isinstance(exc, WriteOutcomeError):
166
+ error.update(outcome=exc.outcome, **exc.details)
167
+ if isinstance(exc, InvalidAccessTokenError):
168
+ error["error"] = "invalid_or_revoked"
169
+ error["message"] += (
170
+ " Require explicit login; environment tokens cannot be removed by this CLI."
171
+ )
172
+ if exc.remote_success:
173
+ error["remote_success"] = True
174
+ error["message"] += (
175
+ " The write succeeded before reconciliation failed; do not repeat it."
176
+ )
177
+ if json_output:
178
+ typer.echo(json.dumps(error, ensure_ascii=False, separators=(",", ":")))
179
+ Console(stderr=True).print(error["message"], markup=False)
180
+ if "captured_state" in error and not json_output:
181
+ Console(stderr=True).print(
182
+ json.dumps(error["captured_state"], ensure_ascii=False), markup=False
183
+ )
184
+ exit_code = (
185
+ exc.exit_code
186
+ if isinstance(exc, WriteOutcomeError)
187
+ else (130 if isinstance(exc, KeyboardInterrupt) else 1)
188
+ )
189
+ raise typer.Exit(exit_code) from None
190
+ if json_output:
191
+ typer.echo(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
192
+ elif "items" in payload or "results" in payload:
193
+ Console().print(_media_table(payload.get("items", payload.get("results", []))))
194
+ if "snapshot_age_seconds" in payload:
195
+ Console().print(
196
+ f"{payload['source']} · snapshot age {payload['snapshot_age_seconds']}s",
197
+ markup=False,
198
+ )
199
+ elif "metadata" in payload:
200
+ Console().print(_media_table([payload["item"]]))
201
+ Console().print_json(data=payload["metadata"])
202
+ else:
203
+ Console().print(payload["message"], markup=False)
204
+ if payload.get("state"):
205
+ Console().print(_media_table([payload["state"]]))
206
+
207
+
208
+ def register_commands(app: typer.Typer, runtime_factory: Callable[[], Runtime]) -> None:
209
+ library = typer.Typer(help="Read and change Library state.", no_args_is_help=True)
210
+ watched = typer.Typer(help="Mark or unmark Watched State.", no_args_is_help=True)
211
+ rating = typer.Typer(help="Set or remove User Ratings.", no_args_is_help=True)
212
+ app.add_typer(library, name="library")
213
+ app.add_typer(watched, name="watched")
214
+ app.add_typer(rating, name="rating")
215
+
216
+ @app.command()
217
+ def search(
218
+ query: Reference,
219
+ kind: KindOption = None,
220
+ year: YearOption = None,
221
+ limit: Annotated[int, typer.Option(min=1, max=50, help="Results per media kind.")] = 10,
222
+ page: Annotated[int, typer.Option(min=1, max=20)] = 1,
223
+ json_output: JsonOption = False,
224
+ ) -> None:
225
+ """Search the catalog for candidate Media Items."""
226
+
227
+ def action(runtime: Runtime) -> dict[str, Any]:
228
+ results = _catalog(runtime).search(query, kind=kind, year=year, limit=limit, page=page)
229
+ return {
230
+ "query": query,
231
+ "results": [i.identity() for i in results.items],
232
+ "next_page": results.next_page,
233
+ }
234
+
235
+ _run(runtime_factory, json_output, action, authenticated=True)
236
+
237
+ @app.command()
238
+ def lookup(
239
+ reference: Reference,
240
+ kind: KindOption = None,
241
+ year: YearOption = None,
242
+ json_output: JsonOption = False,
243
+ ) -> None:
244
+ """Resolve a Media Reference and fetch current Catalog Metadata."""
245
+
246
+ def action(runtime: Runtime) -> dict[str, Any]:
247
+ item = _resolve(runtime, _catalog(runtime), reference, kind, year, json_output)
248
+ return {"item": item.identity(), "metadata": item.metadata}
249
+
250
+ _run(runtime_factory, json_output, action, authenticated=True)
251
+
252
+ @library.command("list")
253
+ def library_list(
254
+ status: Annotated[ListStatus | None, typer.Option()] = None,
255
+ kind: KindOption = None,
256
+ offline: Annotated[bool, typer.Option()] = False,
257
+ json_output: JsonOption = False,
258
+ ) -> None:
259
+ """Reconcile the Library online, or read a dated complete snapshot."""
260
+
261
+ def action(runtime: Runtime) -> dict[str, Any]:
262
+ snapshot = _tracking(runtime, offline=offline).read(offline=offline)
263
+ return {
264
+ "items": [
265
+ e.payload()
266
+ for e in snapshot.entries.values()
267
+ if (kind is None or e.item.kind is kind)
268
+ and (status is None or e.list_status is status)
269
+ ],
270
+ "source": "library-snapshot" if offline else "simkl-reconciled",
271
+ "snapshot_age_seconds": max(0, int(time.time() - snapshot.reconciled_at)),
272
+ "dirty": snapshot.dirty,
273
+ }
274
+
275
+ _run(runtime_factory, json_output, action, authenticated=True)
276
+
277
+ @library.command("repair")
278
+ def library_repair(json_output: JsonOption = False) -> None:
279
+ """Rebuild the complete Library Snapshot from Simkl."""
280
+
281
+ def action(runtime: Runtime) -> dict[str, Any]:
282
+ snapshot = _tracking(runtime).read(repair=True)
283
+ return {"message": "Library Snapshot rebuilt.", "count": len(snapshot.entries)}
284
+
285
+ _run(runtime_factory, json_output, action, authenticated=True)
286
+
287
+ @library.command("set-status")
288
+ def library_set_status(
289
+ reference: Reference,
290
+ status: ListStatus,
291
+ kind: KindOption = None,
292
+ year: YearOption = None,
293
+ json_output: JsonOption = False,
294
+ ) -> None:
295
+ """Add a Media Item or change its List Status."""
296
+
297
+ def action(runtime: Runtime) -> dict[str, Any]:
298
+ service = _tracking(runtime)
299
+ item = _resolve(runtime, Catalog(service.api), reference, kind, year, json_output)
300
+ return service.set_status(item, status)
301
+
302
+ _run(runtime_factory, json_output, action, authenticated=True)
303
+
304
+ @library.command("remove")
305
+ def library_remove(
306
+ reference: Reference,
307
+ yes: YesOption = False,
308
+ kind: KindOption = None,
309
+ year: YearOption = None,
310
+ json_output: JsonOption = False,
311
+ ) -> None:
312
+ """Remove an item together with its Watched State and User Rating."""
313
+
314
+ def action(runtime: Runtime) -> dict[str, Any]:
315
+ service = _tracking(runtime)
316
+ item = _resolve(runtime, Catalog(service.api), reference, kind, year, json_output)
317
+ _confirm(
318
+ runtime,
319
+ yes=yes,
320
+ json_output=json_output,
321
+ message=f"Remove {item.title}, its Watched State, and its User Rating?",
322
+ )
323
+ return service.remove(item)
324
+
325
+ _run(runtime_factory, json_output, action, authenticated=True)
326
+
327
+ @watched.command("mark")
328
+ def watched_mark(
329
+ reference: Reference,
330
+ season: SeasonOption = None,
331
+ episode: EpisodeOption = None,
332
+ all_episodes: AllOption = False,
333
+ episode_id: EpisodeIdOption = None,
334
+ yes: YesOption = False,
335
+ watched_at: Annotated[str | None, typer.Option("--at")] = None,
336
+ rewatch: Annotated[bool, typer.Option()] = False,
337
+ rewatch_id: Annotated[int | None, typer.Option(min=1)] = None,
338
+ kind: KindOption = None,
339
+ year: YearOption = None,
340
+ json_output: JsonOption = False,
341
+ ) -> None:
342
+ """Record a Viewing or an explicit Bulk Watched Update."""
343
+
344
+ def action(runtime: Runtime) -> dict[str, Any]:
345
+ at = viewing_time(watched_at)
346
+ service = _tracking(runtime)
347
+ catalog = Catalog(service.api)
348
+ item = _resolve(runtime, catalog, reference, kind, year, json_output)
349
+ target = select_target(
350
+ catalog,
351
+ item,
352
+ season=season,
353
+ episode=episode,
354
+ all_episodes=all_episodes,
355
+ episode_id=episode_id,
356
+ )
357
+ if target.bulk:
358
+ _confirm(
359
+ runtime,
360
+ yes=yes,
361
+ json_output=json_output,
362
+ message=f"Mark selected Episodes of {item.title} watched?",
363
+ )
364
+ return service.mark(item, target, watched_at=at, rewatch=rewatch, rewatch_id=rewatch_id)
365
+
366
+ _run(runtime_factory, json_output, action, authenticated=True)
367
+
368
+ @watched.command("unmark")
369
+ def watched_unmark(
370
+ reference: Reference,
371
+ season: SeasonOption = None,
372
+ episode: EpisodeOption = None,
373
+ all_episodes: AllOption = False,
374
+ episode_id: EpisodeIdOption = None,
375
+ yes: YesOption = False,
376
+ kind: KindOption = None,
377
+ year: YearOption = None,
378
+ json_output: JsonOption = False,
379
+ ) -> None:
380
+ """Clear Watched State and preserve Library membership, status, and rating."""
381
+
382
+ def action(runtime: Runtime) -> dict[str, Any]:
383
+ service = _tracking(runtime)
384
+ catalog = Catalog(service.api)
385
+ item = _resolve(runtime, catalog, reference, kind, year, json_output)
386
+ target = select_target(
387
+ catalog,
388
+ item,
389
+ season=season,
390
+ episode=episode,
391
+ all_episodes=all_episodes,
392
+ episode_id=episode_id,
393
+ marking=False,
394
+ )
395
+ if target.bulk:
396
+ _confirm(
397
+ runtime,
398
+ yes=yes,
399
+ json_output=json_output,
400
+ message=f"Clear selected Episodes of {item.title}?",
401
+ )
402
+ return service.unmark(item, target)
403
+
404
+ _run(runtime_factory, json_output, action, authenticated=True)
405
+
406
+ @rating.command("set")
407
+ def rating_set(
408
+ reference: Reference,
409
+ score: Annotated[int, typer.Argument(min=1, max=10)],
410
+ kind: KindOption = None,
411
+ year: YearOption = None,
412
+ json_output: JsonOption = False,
413
+ ) -> None:
414
+ """Set a User Rating from 1 to 10."""
415
+
416
+ def action(runtime: Runtime) -> dict[str, Any]:
417
+ service = _tracking(runtime)
418
+ item = _resolve(runtime, Catalog(service.api), reference, kind, year, json_output)
419
+ return service.rating(item, score)
420
+
421
+ _run(runtime_factory, json_output, action, authenticated=True)
422
+
423
+ @rating.command("remove")
424
+ def rating_remove(
425
+ reference: Reference,
426
+ kind: KindOption = None,
427
+ year: YearOption = None,
428
+ json_output: JsonOption = False,
429
+ ) -> None:
430
+ """Remove a User Rating while retaining the Library item."""
431
+
432
+ def action(runtime: Runtime) -> dict[str, Any]:
433
+ service = _tracking(runtime)
434
+ item = _resolve(runtime, Catalog(service.api), reference, kind, year, json_output)
435
+ return service.rating(item, None)
436
+
437
+ _run(runtime_factory, json_output, action, authenticated=True)
simklcli/filesystem.py ADDED
@@ -0,0 +1,31 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+
7
+ def atomic_write_json(path: Path, payload: object) -> None:
8
+ content = json.dumps(
9
+ payload,
10
+ ensure_ascii=False,
11
+ separators=(",", ":"),
12
+ ).encode()
13
+ atomic_write_bytes(path, content)
14
+
15
+
16
+ def atomic_write_bytes(path: Path, content: bytes) -> None:
17
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
18
+ if os.name != "nt":
19
+ path.parent.chmod(0o700)
20
+ temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
21
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
22
+ try:
23
+ with os.fdopen(descriptor, "wb") as stream:
24
+ stream.write(content)
25
+ stream.flush()
26
+ os.fsync(stream.fileno())
27
+ os.replace(temporary, path)
28
+ if os.name != "nt":
29
+ path.chmod(0o600)
30
+ finally:
31
+ temporary.unlink(missing_ok=True)
simklcli/media.py ADDED
@@ -0,0 +1,190 @@
1
+ """Canonical media identity and validated Library state, independent of API vocabulary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+
10
+ class MediaKind(StrEnum):
11
+ MOVIE = "movie"
12
+ SHOW = "show"
13
+ ANIME = "anime"
14
+
15
+ @property
16
+ def endpoint(self) -> str:
17
+ return {self.MOVIE: "movies", self.SHOW: "tv", self.ANIME: "anime"}[self]
18
+
19
+ @property
20
+ def bucket(self) -> str:
21
+ return {self.MOVIE: "movies", self.SHOW: "shows", self.ANIME: "anime"}[self]
22
+
23
+ @property
24
+ def activity(self) -> str:
25
+ return "tv_shows" if self is self.SHOW else self.bucket
26
+
27
+
28
+ class ListStatus(StrEnum):
29
+ WATCHING = "watching"
30
+ PLAN_TO_WATCH = "plan-to-watch"
31
+ ON_HOLD = "on-hold"
32
+ DROPPED = "dropped"
33
+ COMPLETED = "completed"
34
+
35
+ @property
36
+ def api_value(self) -> str:
37
+ return {self.PLAN_TO_WATCH: "plantowatch", self.ON_HOLD: "hold"}.get(self, self.value)
38
+
39
+ @classmethod
40
+ def from_api(cls, value: object) -> ListStatus:
41
+ return cls({"plantowatch": "plan-to-watch", "hold": "on-hold"}.get(str(value), str(value)))
42
+
43
+
44
+ def validate_status(kind: MediaKind, status: ListStatus) -> None:
45
+ if kind is MediaKind.MOVIE and status in {ListStatus.WATCHING, ListStatus.ON_HOLD}:
46
+ raise ValueError(f"Movies cannot have List Status {status.value}.")
47
+
48
+
49
+ def positive_int(value: object) -> int:
50
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
51
+ raise ValueError("Expected a positive integer.")
52
+ return value
53
+
54
+
55
+ def simkl_id(payload: dict[str, Any]) -> int:
56
+ ids = payload.get("ids", {})
57
+ return positive_int(ids.get("simkl", ids.get("simkl_id")))
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Media:
62
+ simkl_id: int
63
+ title: str
64
+ year: int | None
65
+ kind: MediaKind
66
+ metadata: dict[str, Any] = field(default_factory=dict, compare=False)
67
+
68
+ @classmethod
69
+ def from_catalog(cls, payload: dict[str, Any], kind: MediaKind) -> Media:
70
+ title = payload.get("title")
71
+ year = payload.get("year")
72
+ if not isinstance(title, str) or not title.strip():
73
+ raise ValueError("Catalog response is missing a title.")
74
+ if year is not None:
75
+ positive_int(year)
76
+ return cls(simkl_id(payload), title, year, kind, payload)
77
+
78
+ def identity(self) -> dict[str, Any]:
79
+ return {
80
+ "simkl_id": self.simkl_id,
81
+ "title": self.title,
82
+ "year": self.year,
83
+ "kind": self.kind.value,
84
+ }
85
+
86
+ @property
87
+ def standalone(self) -> bool:
88
+ return self.kind is MediaKind.MOVIE or (
89
+ self.kind is MediaKind.ANIME
90
+ and (
91
+ self.metadata.get("anime_type") == "movie"
92
+ or self.metadata.get("ep_count") == 1
93
+ or self.metadata.get("total_episodes") == 1
94
+ )
95
+ )
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class WatchedEpisode:
100
+ number: int
101
+ season: int | None
102
+ watched_at: str | None = None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class LibraryEntry:
107
+ item: Media
108
+ list_status: ListStatus
109
+ watched: bool
110
+ last_watched_at: str | None
111
+ user_rating: int | None
112
+ episodes: tuple[WatchedEpisode, ...] = ()
113
+ watched_episodes_count: int = 0
114
+
115
+ def payload(self) -> dict[str, Any]:
116
+ return {
117
+ **self.item.identity(),
118
+ "list_status": self.list_status.value,
119
+ "watched": self.watched,
120
+ "last_watched_at": self.last_watched_at,
121
+ "user_rating": self.user_rating,
122
+ "episodes": [asdict(e) for e in self.episodes],
123
+ "watched_episodes_count": self.watched_episodes_count,
124
+ }
125
+
126
+ @classmethod
127
+ def from_server(cls, row: dict[str, Any], kind: MediaKind) -> LibraryEntry:
128
+ item_data = row.get("movie") or row.get("show") or row.get("anime")
129
+ if not isinstance(item_data, dict):
130
+ raise ValueError("Library entry is missing media identity.")
131
+ if kind is MediaKind.SHOW and ("anime_type" in row or item_data.get("type") == "anime"):
132
+ kind = MediaKind.ANIME
133
+ item = Media.from_catalog(item_data, kind)
134
+ status = ListStatus.from_api(row.get("status"))
135
+ validate_status(kind, status)
136
+ rating = row.get("user_rating")
137
+ if rating is not None and not 1 <= positive_int(rating) <= 10:
138
+ raise ValueError("Invalid User Rating in Library response.")
139
+ last_watched = row.get("last_watched_at")
140
+ if last_watched is not None and not isinstance(last_watched, str):
141
+ raise ValueError("Invalid Last Watched At.")
142
+ episodes: list[WatchedEpisode] = []
143
+ seasons = row.get("seasons", [])
144
+ if "episodes" in row:
145
+ seasons = [*seasons, {"number": 1, "episodes": row["episodes"]}]
146
+ for season in seasons:
147
+ season_number = season["number"]
148
+ if (
149
+ not isinstance(season_number, int)
150
+ or isinstance(season_number, bool)
151
+ or season_number < 0
152
+ ):
153
+ raise ValueError("Invalid Season in Library response.")
154
+ for episode in season.get("episodes", []):
155
+ if episode.get("watched", True):
156
+ episodes.append(
157
+ WatchedEpisode(
158
+ positive_int(episode["number"]),
159
+ season_number if kind is MediaKind.SHOW else None,
160
+ episode.get("watched_at", episode.get("last_watched_at")),
161
+ )
162
+ )
163
+ count = row.get("watched_episodes_count", len(episodes))
164
+ if not isinstance(count, int) or isinstance(count, bool) or count < 0:
165
+ raise ValueError("Invalid Watched State count.")
166
+ return cls(
167
+ item,
168
+ status,
169
+ bool(last_watched or count or episodes),
170
+ last_watched,
171
+ rating,
172
+ tuple(episodes),
173
+ count,
174
+ )
175
+
176
+
177
+ def library_entries(payload: dict[str, Any]) -> dict[int, LibraryEntry]:
178
+ if set(payload) - {kind.bucket for kind in MediaKind}:
179
+ raise ValueError("Unrecognized Library response shape.")
180
+ entries = {}
181
+ for kind in MediaKind:
182
+ rows = payload.get(kind.bucket, [])
183
+ if not isinstance(rows, list):
184
+ raise ValueError("Invalid Library response bucket.")
185
+ for row in rows:
186
+ if not isinstance(row, dict):
187
+ raise ValueError("Invalid Library entry.")
188
+ entry = LibraryEntry.from_server(row, kind)
189
+ entries[entry.item.simkl_id] = entry
190
+ return entries