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/output.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Keep argument-validation failures machine readable when --json is requested."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from typer import Abort
|
|
11
|
+
from typer import _click as click
|
|
12
|
+
from typer.core import TyperGroup
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JsonGroup(TyperGroup):
|
|
16
|
+
def main(
|
|
17
|
+
self,
|
|
18
|
+
args: Sequence[str] | None = None,
|
|
19
|
+
prog_name: str | None = None,
|
|
20
|
+
complete_var: str | None = None,
|
|
21
|
+
standalone_mode: bool = True,
|
|
22
|
+
windows_expand_args: bool = True,
|
|
23
|
+
**extra: Any,
|
|
24
|
+
) -> Any:
|
|
25
|
+
arguments = list(sys.argv[1:] if args is None else args)
|
|
26
|
+
try:
|
|
27
|
+
result = super().main(
|
|
28
|
+
args=arguments,
|
|
29
|
+
prog_name=prog_name,
|
|
30
|
+
complete_var=complete_var,
|
|
31
|
+
standalone_mode=False,
|
|
32
|
+
windows_expand_args=windows_expand_args,
|
|
33
|
+
**extra,
|
|
34
|
+
)
|
|
35
|
+
except click.ClickException as exc:
|
|
36
|
+
if "--json" in arguments:
|
|
37
|
+
click.echo(
|
|
38
|
+
json.dumps({"error": "invalid_arguments", "message": exc.format_message()})
|
|
39
|
+
)
|
|
40
|
+
exc.show()
|
|
41
|
+
if standalone_mode:
|
|
42
|
+
raise SystemExit(exc.exit_code) from None
|
|
43
|
+
raise
|
|
44
|
+
except Abort as exc:
|
|
45
|
+
if "--json" in arguments:
|
|
46
|
+
click.echo(json.dumps({"error": "cancelled", "message": "Aborted."}))
|
|
47
|
+
click.echo("Aborted!", err=True)
|
|
48
|
+
if standalone_mode:
|
|
49
|
+
raise SystemExit(
|
|
50
|
+
130 if isinstance(exc.__cause__, KeyboardInterrupt) else 1
|
|
51
|
+
) from None
|
|
52
|
+
raise
|
|
53
|
+
if standalone_mode:
|
|
54
|
+
raise SystemExit(result if isinstance(result, int) else 0)
|
|
55
|
+
return result
|
simklcli/rate_control.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from filelock import FileLock
|
|
11
|
+
|
|
12
|
+
from simklcli.filesystem import atomic_write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CrossProcessRateGate:
|
|
16
|
+
"""Space Simkl requests across processes by application or account scope."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
root: Path,
|
|
21
|
+
*,
|
|
22
|
+
client_id: str,
|
|
23
|
+
clock: Callable[[], float] = time.time,
|
|
24
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._root = root
|
|
27
|
+
self._client_id = client_id
|
|
28
|
+
self._clock = clock
|
|
29
|
+
self._sleep = sleep
|
|
30
|
+
self._accounts: dict[str, int] = {}
|
|
31
|
+
|
|
32
|
+
def bind_account(self, access_token: str, account_id: int) -> None:
|
|
33
|
+
# Carry the initial token-validation cadence into the validated account gate.
|
|
34
|
+
self._root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
35
|
+
token_key = hashlib.sha256(f"account:{access_token}".encode()).hexdigest()
|
|
36
|
+
account_key = hashlib.sha256(f"account:{account_id}".encode()).hexdigest()
|
|
37
|
+
path = self._root / f"{account_key}.json"
|
|
38
|
+
with FileLock(self._root / f"{account_key}.lock", mode=0o600):
|
|
39
|
+
state = _read_state(path)
|
|
40
|
+
token_state = _read_state(self._root / f"{token_key}.json")
|
|
41
|
+
for field, value in token_state.items():
|
|
42
|
+
state[field] = max(value, state.get(field, value))
|
|
43
|
+
atomic_write_json(path, state)
|
|
44
|
+
atomic_write_json(self._root / f"{token_key}.account.json", {"account_id": account_id})
|
|
45
|
+
self._accounts[access_token] = account_id
|
|
46
|
+
|
|
47
|
+
def remembered_account_id(self, access_token: str) -> int | None:
|
|
48
|
+
key = hashlib.sha256(f"account:{access_token}".encode()).hexdigest()
|
|
49
|
+
try:
|
|
50
|
+
payload = json.loads((self._root / f"{key}.account.json").read_text(encoding="utf-8"))
|
|
51
|
+
account_id = payload["account_id"]
|
|
52
|
+
if isinstance(account_id, int) and not isinstance(account_id, bool) and account_id > 0:
|
|
53
|
+
return account_id
|
|
54
|
+
except (OSError, KeyError, TypeError, ValueError):
|
|
55
|
+
pass
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def limit(self, method: str, access_token: str | None) -> Iterator[None]:
|
|
60
|
+
interval = 1.0 if method == "POST" else 0.1
|
|
61
|
+
account_id = (
|
|
62
|
+
None
|
|
63
|
+
if access_token is None
|
|
64
|
+
else (self._accounts.get(access_token) or self.remembered_account_id(access_token))
|
|
65
|
+
)
|
|
66
|
+
scope = (
|
|
67
|
+
f"account:{account_id if account_id is not None else access_token}"
|
|
68
|
+
if access_token is not None
|
|
69
|
+
else f"application:{self._client_id}"
|
|
70
|
+
)
|
|
71
|
+
key = hashlib.sha256(scope.encode()).hexdigest()
|
|
72
|
+
self._root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
73
|
+
state_path = self._root / f"{key}.json"
|
|
74
|
+
lock = FileLock(self._root / f"{key}.lock")
|
|
75
|
+
with lock:
|
|
76
|
+
state = _read_state(state_path)
|
|
77
|
+
field = "last_post" if method == "POST" else "last_get"
|
|
78
|
+
previous = state.get(field)
|
|
79
|
+
if previous is not None:
|
|
80
|
+
delay = previous + interval - self._clock()
|
|
81
|
+
if delay > 0:
|
|
82
|
+
self._sleep(delay)
|
|
83
|
+
state[field] = self._clock()
|
|
84
|
+
atomic_write_json(state_path, state)
|
|
85
|
+
yield
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_state(path: Path) -> dict[str, float]:
|
|
89
|
+
try:
|
|
90
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
91
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
92
|
+
return {}
|
|
93
|
+
if not isinstance(payload, dict):
|
|
94
|
+
return {}
|
|
95
|
+
return {
|
|
96
|
+
key: float(value)
|
|
97
|
+
for key, value in payload.items()
|
|
98
|
+
if key in {"last_get", "last_post"} and isinstance(value, int | float)
|
|
99
|
+
}
|
simklcli/runtime.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import webbrowser
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import keyring
|
|
13
|
+
from platformdirs import user_config_path, user_data_path
|
|
14
|
+
|
|
15
|
+
from simklcli.rate_control import CrossProcessRateGate
|
|
16
|
+
from simklcli.snapshots import LibrarySnapshotStore
|
|
17
|
+
from simklcli.storage import (
|
|
18
|
+
Credential,
|
|
19
|
+
CredentialRepository,
|
|
20
|
+
CredentialStorageError,
|
|
21
|
+
EmptyKeyring,
|
|
22
|
+
Keyring,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from simklcli.api import SimklClient
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Public AUTH V2 "TV, devices & command line" app ID for simklcli. It carries no
|
|
30
|
+
# secret: Simkl refuses user-scoped requests without a user Access Token.
|
|
31
|
+
DEFAULT_CLIENT_ID = "6d50d4e07aa47b6409a4cd0c2b16729fab7ddc7f5c111fcba7d0001ac054c164"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Runtime:
|
|
36
|
+
credentials: CredentialRepository
|
|
37
|
+
snapshots: LibrarySnapshotStore
|
|
38
|
+
environ: Mapping[str, str]
|
|
39
|
+
data_dir: Path
|
|
40
|
+
transport: httpx.BaseTransport | None
|
|
41
|
+
rate_gate: CrossProcessRateGate | None
|
|
42
|
+
sleep: Callable[[float], None]
|
|
43
|
+
monotonic: Callable[[], float]
|
|
44
|
+
open_browser: Callable[[str], bool]
|
|
45
|
+
interactive: bool
|
|
46
|
+
clock: Callable[[], float] = time.time
|
|
47
|
+
|
|
48
|
+
def client_id(self) -> str:
|
|
49
|
+
configured = self.environ.get("SIMKL_CLIENT_ID", "").strip()
|
|
50
|
+
if configured:
|
|
51
|
+
return configured
|
|
52
|
+
if not self.environ.get("SIMKL_ACCESS_TOKEN", "").strip():
|
|
53
|
+
try:
|
|
54
|
+
stored = self.credentials.read()
|
|
55
|
+
except CredentialStorageError:
|
|
56
|
+
stored = None
|
|
57
|
+
if stored is not None and stored.client_id:
|
|
58
|
+
return stored.client_id
|
|
59
|
+
return DEFAULT_CLIENT_ID
|
|
60
|
+
|
|
61
|
+
def identity_gate(self) -> CrossProcessRateGate:
|
|
62
|
+
return self.rate_gate or CrossProcessRateGate(
|
|
63
|
+
self.data_dir / "rate-gates",
|
|
64
|
+
client_id=self.client_id(),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def api_client(self) -> SimklClient:
|
|
68
|
+
from simklcli.api import SimklClient
|
|
69
|
+
|
|
70
|
+
if self.rate_gate is not None and not self.environ.get("SIMKL_ACCESS_TOKEN", "").strip():
|
|
71
|
+
try:
|
|
72
|
+
stored = self.credentials.read()
|
|
73
|
+
except CredentialStorageError:
|
|
74
|
+
stored = None
|
|
75
|
+
if stored is not None and stored.account is not None:
|
|
76
|
+
self.rate_gate.bind_account(stored.access_token, stored.account.id)
|
|
77
|
+
return SimklClient(
|
|
78
|
+
client_id=self.client_id(),
|
|
79
|
+
transport=self.transport,
|
|
80
|
+
sleep=self.sleep,
|
|
81
|
+
rate_gate=self.rate_gate,
|
|
82
|
+
clock=self.clock,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def authenticated_client(self) -> tuple[Credential, SimklClient]:
|
|
86
|
+
from simklcli.api import SimklResponseError
|
|
87
|
+
from simklcli.storage import CredentialSource
|
|
88
|
+
|
|
89
|
+
credential = self.active_credential()
|
|
90
|
+
if credential is None:
|
|
91
|
+
raise SimklResponseError(
|
|
92
|
+
"No Access Token is available. Run simkl auth login explicitly."
|
|
93
|
+
)
|
|
94
|
+
if credential.source is not CredentialSource.ENVIRONMENT and credential.account is None:
|
|
95
|
+
raise SimklResponseError(
|
|
96
|
+
"The active credential has no validated Authenticated Account."
|
|
97
|
+
)
|
|
98
|
+
if credential.legacy or credential.invalidated:
|
|
99
|
+
raise SimklResponseError(
|
|
100
|
+
"This account requires AUTH V2 authorization. Run simkl auth login; "
|
|
101
|
+
"the Library Snapshot will be retained."
|
|
102
|
+
)
|
|
103
|
+
if credential.source is CredentialSource.ENVIRONMENT:
|
|
104
|
+
# Recognize genuine V1 credentials without accepting them on a V2 registration.
|
|
105
|
+
token = credential.access_token
|
|
106
|
+
if len(token) == 64 and all(c in "0123456789abcdef" for c in token):
|
|
107
|
+
raise SimklResponseError("SIMKL_ACCESS_TOKEN is a V1 token; authorize your V2 app.")
|
|
108
|
+
elif credential.client_id != self.client_id():
|
|
109
|
+
raise SimklResponseError(
|
|
110
|
+
"SIMKL_CLIENT_ID differs from the stored token's app; restore it or log out "
|
|
111
|
+
"before authorizing a different app."
|
|
112
|
+
)
|
|
113
|
+
api = self.api_client()
|
|
114
|
+
refresh = None
|
|
115
|
+
if credential.source is not CredentialSource.ENVIRONMENT:
|
|
116
|
+
refresh = self._refresh_credential
|
|
117
|
+
if credential.expires_at is not None and credential.expires_at <= self.clock() + 60:
|
|
118
|
+
self._refresh_credential(credential.access_token)
|
|
119
|
+
credential = self.credentials.read() or credential
|
|
120
|
+
api.configure_auth(credential.access_token, refresh)
|
|
121
|
+
if self.rate_gate is not None and credential.account is not None:
|
|
122
|
+
self.rate_gate.bind_account(credential.access_token, credential.account.id)
|
|
123
|
+
return credential, api
|
|
124
|
+
|
|
125
|
+
def _refresh_credential(self, failed_token: str) -> str:
|
|
126
|
+
from simklcli.api import InvalidAccessTokenError, OAuthGrantError, SimklResponseError
|
|
127
|
+
|
|
128
|
+
with self.credentials.account_transaction():
|
|
129
|
+
credential = self.credentials.read()
|
|
130
|
+
if credential is None or credential.invalidated or credential.refresh_token is None:
|
|
131
|
+
raise InvalidAccessTokenError(
|
|
132
|
+
"Refresh credentials are unavailable; authorize again."
|
|
133
|
+
)
|
|
134
|
+
if credential.client_id != self.client_id():
|
|
135
|
+
raise SimklResponseError(
|
|
136
|
+
"The stored token belongs to a different client registration."
|
|
137
|
+
)
|
|
138
|
+
# Another invocation may already have refreshed while this one waited for the lock.
|
|
139
|
+
if credential.access_token != failed_token:
|
|
140
|
+
return credential.access_token
|
|
141
|
+
if (
|
|
142
|
+
credential.refresh_expires_at is None
|
|
143
|
+
or credential.refresh_expires_at <= self.clock()
|
|
144
|
+
):
|
|
145
|
+
raise InvalidAccessTokenError("The Refresh Token expired; authorize again.")
|
|
146
|
+
api = self.api_client()
|
|
147
|
+
try:
|
|
148
|
+
pair = api.refresh_tokens(credential.refresh_token)
|
|
149
|
+
except OAuthGrantError as exc:
|
|
150
|
+
if exc.error == "invalid_grant":
|
|
151
|
+
raise InvalidAccessTokenError(
|
|
152
|
+
"The Refresh Token is invalid or revoked."
|
|
153
|
+
) from exc
|
|
154
|
+
raise
|
|
155
|
+
account = api.get_authenticated_account(pair.access_token)
|
|
156
|
+
if credential.account is None or account.id != credential.account.id:
|
|
157
|
+
raise SimklResponseError(
|
|
158
|
+
"Refreshed credentials did not match the Authenticated Account."
|
|
159
|
+
)
|
|
160
|
+
updated = replace(
|
|
161
|
+
credential,
|
|
162
|
+
access_token=pair.access_token,
|
|
163
|
+
refresh_token=pair.refresh_token,
|
|
164
|
+
expires_at=pair.expires_at,
|
|
165
|
+
refresh_expires_at=pair.refresh_expires_at,
|
|
166
|
+
scope=pair.scope,
|
|
167
|
+
account=account,
|
|
168
|
+
)
|
|
169
|
+
self.credentials.save(updated)
|
|
170
|
+
if self.rate_gate is not None:
|
|
171
|
+
self.rate_gate.bind_account(pair.access_token, account.id)
|
|
172
|
+
return pair.access_token
|
|
173
|
+
|
|
174
|
+
def active_credential(self) -> Credential | None:
|
|
175
|
+
token = self.environ.get("SIMKL_ACCESS_TOKEN", "").strip()
|
|
176
|
+
if token:
|
|
177
|
+
from simklcli.storage import CredentialSource
|
|
178
|
+
|
|
179
|
+
return Credential(
|
|
180
|
+
access_token=token,
|
|
181
|
+
source=CredentialSource.ENVIRONMENT,
|
|
182
|
+
account=None,
|
|
183
|
+
)
|
|
184
|
+
return self.credentials.read()
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def for_testing(
|
|
188
|
+
cls,
|
|
189
|
+
*,
|
|
190
|
+
config_dir: Path,
|
|
191
|
+
data_dir: Path,
|
|
192
|
+
environ: Mapping[str, str] | None = None,
|
|
193
|
+
transport: httpx.BaseTransport | None = None,
|
|
194
|
+
rate_gate: CrossProcessRateGate | None = None,
|
|
195
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
196
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
197
|
+
open_browser: Callable[[str], bool] = webbrowser.open,
|
|
198
|
+
interactive: bool = False,
|
|
199
|
+
clock: Callable[[], float] = time.time,
|
|
200
|
+
) -> Runtime:
|
|
201
|
+
return cls(
|
|
202
|
+
credentials=CredentialRepository(config_dir, EmptyKeyring()),
|
|
203
|
+
snapshots=LibrarySnapshotStore(data_dir),
|
|
204
|
+
environ={"SIMKL_CLIENT_ID": "test-v2-client", **(environ or {})},
|
|
205
|
+
data_dir=data_dir,
|
|
206
|
+
transport=transport or httpx.MockTransport(_unexpected_test_request),
|
|
207
|
+
rate_gate=rate_gate,
|
|
208
|
+
sleep=sleep,
|
|
209
|
+
monotonic=monotonic,
|
|
210
|
+
open_browser=open_browser,
|
|
211
|
+
interactive=interactive,
|
|
212
|
+
clock=clock,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def default_runtime() -> Runtime:
|
|
217
|
+
config_dir = Path(user_config_path("simklcli", appauthor=False))
|
|
218
|
+
data_dir = Path(user_data_path("simklcli", appauthor=False))
|
|
219
|
+
backend: Keyring = keyring.get_keyring()
|
|
220
|
+
client_id = os.environ.get("SIMKL_CLIENT_ID", "").strip() or DEFAULT_CLIENT_ID
|
|
221
|
+
return Runtime(
|
|
222
|
+
credentials=CredentialRepository(config_dir, backend),
|
|
223
|
+
snapshots=LibrarySnapshotStore(data_dir),
|
|
224
|
+
environ=os.environ,
|
|
225
|
+
data_dir=data_dir,
|
|
226
|
+
transport=None,
|
|
227
|
+
rate_gate=CrossProcessRateGate(data_dir / "rate-gates", client_id=client_id),
|
|
228
|
+
sleep=time.sleep,
|
|
229
|
+
monotonic=time.monotonic,
|
|
230
|
+
open_browser=webbrowser.open,
|
|
231
|
+
interactive=_is_interactive(),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _unexpected_test_request(request: httpx.Request) -> httpx.Response:
|
|
236
|
+
raise AssertionError(f"No authored response exists for {request.method} {request.url.path}.")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _is_interactive() -> bool:
|
|
240
|
+
import sys
|
|
241
|
+
|
|
242
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
simklcli/snapshots.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from filelock import FileLock
|
|
9
|
+
|
|
10
|
+
from simklcli.filesystem import atomic_write_bytes, atomic_write_json
|
|
11
|
+
from simklcli.media import LibraryEntry, ListStatus, Media, MediaKind, WatchedEpisode, positive_int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LibrarySnapshot:
|
|
16
|
+
account_id: int
|
|
17
|
+
entries: dict[int, LibraryEntry] = field(default_factory=dict)
|
|
18
|
+
activities: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
complete: bool = False
|
|
20
|
+
dirty: bool = False
|
|
21
|
+
reconciled_at: float = 0.0
|
|
22
|
+
|
|
23
|
+
def payload(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"version": 1,
|
|
26
|
+
"account_id": self.account_id,
|
|
27
|
+
"entries": [entry.payload() for entry in self.entries.values()],
|
|
28
|
+
"activities": self.activities,
|
|
29
|
+
"complete": self.complete,
|
|
30
|
+
"dirty": self.dirty,
|
|
31
|
+
"reconciled_at": self.reconciled_at,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LibrarySnapshotStore:
|
|
36
|
+
"""Own per-account Library Snapshot paths and locking."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, data_dir: Path) -> None:
|
|
39
|
+
self._data_dir = data_dir
|
|
40
|
+
self._locks: dict[int, FileLock] = {}
|
|
41
|
+
|
|
42
|
+
def lock(self, account_id: int) -> FileLock:
|
|
43
|
+
lock_dir = self._data_dir / "snapshot-locks"
|
|
44
|
+
lock_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
45
|
+
if account_id not in self._locks:
|
|
46
|
+
self._locks[account_id] = FileLock(lock_dir / f"{account_id}.lock", mode=0o600)
|
|
47
|
+
return self._locks[account_id]
|
|
48
|
+
|
|
49
|
+
def load(self, account_id: int) -> LibrarySnapshot | None:
|
|
50
|
+
with self.lock(account_id):
|
|
51
|
+
try:
|
|
52
|
+
payload = json.loads(self.path(account_id).read_text(encoding="utf-8"))
|
|
53
|
+
if payload["version"] != 1 or payload["account_id"] != account_id:
|
|
54
|
+
return None
|
|
55
|
+
if (
|
|
56
|
+
not isinstance(payload["complete"], bool)
|
|
57
|
+
or not isinstance(payload["dirty"], bool)
|
|
58
|
+
or not isinstance(payload["activities"], dict)
|
|
59
|
+
or not isinstance(payload["reconciled_at"], int | float)
|
|
60
|
+
):
|
|
61
|
+
return None
|
|
62
|
+
entries = {}
|
|
63
|
+
for row in payload["entries"]:
|
|
64
|
+
kind = MediaKind(row["kind"])
|
|
65
|
+
item = Media.from_catalog(
|
|
66
|
+
{
|
|
67
|
+
"ids": {"simkl": row["simkl_id"]},
|
|
68
|
+
"title": row["title"],
|
|
69
|
+
"year": row["year"],
|
|
70
|
+
},
|
|
71
|
+
kind,
|
|
72
|
+
)
|
|
73
|
+
# Reuse the API validators and strip any unexpected catalog fields.
|
|
74
|
+
parsed = LibraryEntry.from_server(
|
|
75
|
+
{
|
|
76
|
+
"movie" if kind is MediaKind.MOVIE else "show": {
|
|
77
|
+
"ids": {"simkl": item.simkl_id},
|
|
78
|
+
"title": item.title,
|
|
79
|
+
"year": item.year,
|
|
80
|
+
},
|
|
81
|
+
"status": ListStatus(row["list_status"]).api_value,
|
|
82
|
+
"user_rating": row["user_rating"],
|
|
83
|
+
"last_watched_at": row["last_watched_at"],
|
|
84
|
+
"watched_episodes_count": row["watched_episodes_count"],
|
|
85
|
+
"seasons": [
|
|
86
|
+
{
|
|
87
|
+
"number": e["season"] if e["season"] is not None else 1,
|
|
88
|
+
"episodes": [
|
|
89
|
+
{"number": e["number"], "watched_at": e["watched_at"]}
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
for e in row["episodes"]
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
kind,
|
|
96
|
+
)
|
|
97
|
+
if not isinstance(row["watched"], bool):
|
|
98
|
+
return None
|
|
99
|
+
entries[item.simkl_id] = LibraryEntry(
|
|
100
|
+
item,
|
|
101
|
+
parsed.list_status,
|
|
102
|
+
row["watched"],
|
|
103
|
+
parsed.last_watched_at,
|
|
104
|
+
parsed.user_rating,
|
|
105
|
+
tuple(
|
|
106
|
+
WatchedEpisode(positive_int(e["number"]), e["season"], e["watched_at"])
|
|
107
|
+
for e in row["episodes"]
|
|
108
|
+
),
|
|
109
|
+
parsed.watched_episodes_count,
|
|
110
|
+
)
|
|
111
|
+
if payload["complete"] and "all" not in payload["activities"]:
|
|
112
|
+
return None
|
|
113
|
+
return LibrarySnapshot(
|
|
114
|
+
account_id,
|
|
115
|
+
entries,
|
|
116
|
+
payload["activities"],
|
|
117
|
+
payload["complete"],
|
|
118
|
+
payload["dirty"] or self.dirty_path(account_id).exists(),
|
|
119
|
+
payload["reconciled_at"],
|
|
120
|
+
)
|
|
121
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def save(self, snapshot: LibrarySnapshot) -> None:
|
|
125
|
+
with self.lock(snapshot.account_id):
|
|
126
|
+
atomic_write_json(self.path(snapshot.account_id), snapshot.payload())
|
|
127
|
+
if not snapshot.dirty:
|
|
128
|
+
self.dirty_path(snapshot.account_id).unlink(missing_ok=True)
|
|
129
|
+
|
|
130
|
+
def path(self, account_id: int) -> Path:
|
|
131
|
+
return self._data_dir / "accounts" / str(account_id) / "library.json"
|
|
132
|
+
|
|
133
|
+
def dirty_path(self, account_id: int) -> Path:
|
|
134
|
+
return self._data_dir / "snapshot-locks" / f"{account_id}.dirty"
|
|
135
|
+
|
|
136
|
+
def mark_dirty(self, account_id: int) -> None:
|
|
137
|
+
with self.lock(account_id):
|
|
138
|
+
atomic_write_bytes(self.dirty_path(account_id), b"1")
|
|
139
|
+
|
|
140
|
+
def delete(self, account_id: int) -> None:
|
|
141
|
+
with self.lock(account_id):
|
|
142
|
+
self.path(account_id).unlink(missing_ok=True)
|
|
143
|
+
self.dirty_path(account_id).unlink(missing_ok=True)
|
|
144
|
+
|
|
145
|
+
@contextmanager
|
|
146
|
+
def reversible_delete(self, account_id: int) -> Iterator[None]:
|
|
147
|
+
"""Delete a snapshot, restoring it if the surrounding update fails."""
|
|
148
|
+
with self.lock(account_id):
|
|
149
|
+
path = self.path(account_id)
|
|
150
|
+
previous_content = path.read_bytes() if path.exists() else None
|
|
151
|
+
dirty_path = self.dirty_path(account_id)
|
|
152
|
+
was_dirty = dirty_path.exists()
|
|
153
|
+
try:
|
|
154
|
+
path.unlink(missing_ok=True)
|
|
155
|
+
dirty_path.unlink(missing_ok=True)
|
|
156
|
+
yield
|
|
157
|
+
except BaseException:
|
|
158
|
+
if previous_content is not None:
|
|
159
|
+
atomic_write_bytes(path, previous_content)
|
|
160
|
+
if was_dirty:
|
|
161
|
+
atomic_write_bytes(dirty_path, b"1")
|
|
162
|
+
raise
|