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