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/storage.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, cast
|
|
12
|
+
|
|
13
|
+
from filelock import FileLock
|
|
14
|
+
|
|
15
|
+
from simklcli.filesystem import atomic_write_bytes, atomic_write_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CredentialSource(StrEnum):
|
|
19
|
+
ENVIRONMENT = "environment"
|
|
20
|
+
KEYRING = "keyring"
|
|
21
|
+
FILE = "file"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Account:
|
|
26
|
+
id: int
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Credential:
|
|
32
|
+
access_token: str = field(repr=False)
|
|
33
|
+
source: CredentialSource
|
|
34
|
+
account: Account | None
|
|
35
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
36
|
+
expires_at: float | None = None
|
|
37
|
+
refresh_expires_at: float | None = None
|
|
38
|
+
client_id: str | None = None
|
|
39
|
+
scope: str | None = None
|
|
40
|
+
invalidated: bool = False
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def legacy(self) -> bool:
|
|
44
|
+
return self.source is not CredentialSource.ENVIRONMENT and self.client_id is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Keyring(Protocol):
|
|
48
|
+
def get_password(self, service: str, username: str) -> str | None: ...
|
|
49
|
+
|
|
50
|
+
def set_password(self, service: str, username: str, password: str) -> None: ...
|
|
51
|
+
|
|
52
|
+
def delete_password(self, service: str, username: str) -> None: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EmptyKeyring:
|
|
56
|
+
def get_password(self, service: str, username: str) -> str | None:
|
|
57
|
+
del service, username
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def set_password(self, service: str, username: str, password: str) -> None:
|
|
61
|
+
del service, username, password
|
|
62
|
+
raise RuntimeError("No keyring is available.")
|
|
63
|
+
|
|
64
|
+
def delete_password(self, service: str, username: str) -> None:
|
|
65
|
+
del service, username
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CredentialStorageError(RuntimeError):
|
|
69
|
+
"""Persistent credential storage could not be read or updated."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CredentialRepository:
|
|
73
|
+
def __init__(self, config_dir: Path, keyring: Keyring) -> None:
|
|
74
|
+
self._path = config_dir / "auth.json"
|
|
75
|
+
self._lock_path = config_dir / "account.lock"
|
|
76
|
+
self._lock = FileLock(self._lock_path, mode=0o600)
|
|
77
|
+
self._keyring = keyring
|
|
78
|
+
|
|
79
|
+
@contextmanager
|
|
80
|
+
def account_transaction(self) -> Iterator[None]:
|
|
81
|
+
self._lock_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
82
|
+
if os.name != "nt":
|
|
83
|
+
self._lock_path.parent.chmod(0o700)
|
|
84
|
+
with self._lock:
|
|
85
|
+
yield
|
|
86
|
+
|
|
87
|
+
def read_account(self) -> Account | None:
|
|
88
|
+
if not self._path.exists():
|
|
89
|
+
return None
|
|
90
|
+
return _account_from_payload(self._read_payload())
|
|
91
|
+
|
|
92
|
+
def read(self) -> Credential | None:
|
|
93
|
+
if not self._path.exists():
|
|
94
|
+
return None
|
|
95
|
+
payload = self._read_payload()
|
|
96
|
+
source = CredentialSource(cast(str, payload["storage"]))
|
|
97
|
+
account = _account_from_payload(payload)
|
|
98
|
+
if source is CredentialSource.FILE:
|
|
99
|
+
token = payload.get("access_token")
|
|
100
|
+
else:
|
|
101
|
+
try:
|
|
102
|
+
token = self._keyring.get_password("simklcli", "access-token")
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE) from exc
|
|
105
|
+
if not isinstance(token, str) or not token:
|
|
106
|
+
return None
|
|
107
|
+
if payload["version"] == 2:
|
|
108
|
+
if source is CredentialSource.KEYRING:
|
|
109
|
+
try:
|
|
110
|
+
secrets = json.loads(token)
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise CredentialStorageError("Stored token pair could not be read.") from exc
|
|
113
|
+
else:
|
|
114
|
+
secrets = payload
|
|
115
|
+
if not isinstance(secrets, dict) or any(
|
|
116
|
+
not isinstance(secrets.get(key), str) or not secrets[key]
|
|
117
|
+
for key in ("access_token", "refresh_token")
|
|
118
|
+
):
|
|
119
|
+
raise CredentialStorageError("Stored token pair could not be read.")
|
|
120
|
+
return Credential(
|
|
121
|
+
access_token=secrets["access_token"],
|
|
122
|
+
refresh_token=secrets["refresh_token"],
|
|
123
|
+
account=account,
|
|
124
|
+
source=source,
|
|
125
|
+
expires_at=cast(float, payload["expires_at"]),
|
|
126
|
+
refresh_expires_at=cast(float, payload["refresh_expires_at"]),
|
|
127
|
+
client_id=cast(str, payload["client_id"]),
|
|
128
|
+
scope=cast(str, payload["scope"]),
|
|
129
|
+
invalidated=bool(payload.get("invalidated", False)),
|
|
130
|
+
)
|
|
131
|
+
return Credential(access_token=token, source=source, account=account)
|
|
132
|
+
|
|
133
|
+
def ensure_available(self, source: CredentialSource) -> None:
|
|
134
|
+
if source is not CredentialSource.KEYRING:
|
|
135
|
+
return
|
|
136
|
+
priority = getattr(self._keyring, "priority", 0)
|
|
137
|
+
try:
|
|
138
|
+
usable = float(priority) > 0
|
|
139
|
+
self._keyring.get_password("simklcli", "access-token")
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE) from exc
|
|
142
|
+
if not usable:
|
|
143
|
+
raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE)
|
|
144
|
+
|
|
145
|
+
def write(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
access_token: str,
|
|
149
|
+
account: Account,
|
|
150
|
+
source: CredentialSource,
|
|
151
|
+
refresh_token: str | None = None,
|
|
152
|
+
expires_at: float | None = None,
|
|
153
|
+
refresh_expires_at: float | None = None,
|
|
154
|
+
client_id: str | None = None,
|
|
155
|
+
scope: str | None = None,
|
|
156
|
+
invalidated: bool = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
if not access_token:
|
|
159
|
+
raise CredentialStorageError("Refusing to persist an empty Access Token.")
|
|
160
|
+
payload: dict[str, object] = {
|
|
161
|
+
"version": 1,
|
|
162
|
+
"storage": source.value,
|
|
163
|
+
"account": {"id": account.id, "name": account.name},
|
|
164
|
+
}
|
|
165
|
+
keyring_value = access_token
|
|
166
|
+
if refresh_token is not None:
|
|
167
|
+
if (
|
|
168
|
+
not refresh_token
|
|
169
|
+
or not client_id
|
|
170
|
+
or not scope
|
|
171
|
+
or any(
|
|
172
|
+
not isinstance(value, (int, float))
|
|
173
|
+
or isinstance(value, bool)
|
|
174
|
+
or value <= 0
|
|
175
|
+
or not math.isfinite(value)
|
|
176
|
+
for value in (expires_at, refresh_expires_at)
|
|
177
|
+
)
|
|
178
|
+
):
|
|
179
|
+
raise CredentialStorageError("Refusing to persist an incomplete V2 credential.")
|
|
180
|
+
payload.update(
|
|
181
|
+
version=2,
|
|
182
|
+
client_id=client_id,
|
|
183
|
+
scope=scope,
|
|
184
|
+
expires_at=expires_at,
|
|
185
|
+
refresh_expires_at=refresh_expires_at,
|
|
186
|
+
invalidated=invalidated,
|
|
187
|
+
)
|
|
188
|
+
keyring_value = json.dumps(
|
|
189
|
+
{"access_token": access_token, "refresh_token": refresh_token}
|
|
190
|
+
)
|
|
191
|
+
previous_metadata = self._path.read_bytes() if self._path.exists() else None
|
|
192
|
+
replacing_keyring = (
|
|
193
|
+
source is CredentialSource.FILE
|
|
194
|
+
and previous_metadata is not None
|
|
195
|
+
and json.loads(previous_metadata).get("storage") == CredentialSource.KEYRING.value
|
|
196
|
+
)
|
|
197
|
+
previous_keyring_token: str | None = None
|
|
198
|
+
keyring_changed = False
|
|
199
|
+
if source is CredentialSource.FILE:
|
|
200
|
+
payload["access_token"] = access_token
|
|
201
|
+
if refresh_token is not None:
|
|
202
|
+
payload["refresh_token"] = refresh_token
|
|
203
|
+
elif source is not CredentialSource.KEYRING:
|
|
204
|
+
raise CredentialStorageError("Environment credentials cannot be persisted.")
|
|
205
|
+
try:
|
|
206
|
+
if replacing_keyring:
|
|
207
|
+
previous_keyring_token = self._keyring.get_password("simklcli", "access-token")
|
|
208
|
+
if source is CredentialSource.KEYRING:
|
|
209
|
+
try:
|
|
210
|
+
previous_keyring_token = self._keyring.get_password("simklcli", "access-token")
|
|
211
|
+
keyring_changed = previous_keyring_token != keyring_value
|
|
212
|
+
if keyring_changed:
|
|
213
|
+
self._keyring.set_password("simklcli", "access-token", keyring_value)
|
|
214
|
+
except Exception as keyring_error:
|
|
215
|
+
raise CredentialStorageError(_KEYRING_UNAVAILABLE_MESSAGE) from keyring_error
|
|
216
|
+
atomic_write_json(self._path, payload)
|
|
217
|
+
if replacing_keyring and previous_keyring_token is not None:
|
|
218
|
+
keyring_changed = True
|
|
219
|
+
self._keyring.delete_password("simklcli", "access-token")
|
|
220
|
+
except BaseException:
|
|
221
|
+
try:
|
|
222
|
+
_restore_file(self._path, previous_metadata)
|
|
223
|
+
if (source is CredentialSource.KEYRING or replacing_keyring) and keyring_changed:
|
|
224
|
+
if previous_keyring_token is None:
|
|
225
|
+
self._keyring.delete_password("simklcli", "access-token")
|
|
226
|
+
else:
|
|
227
|
+
self._keyring.set_password(
|
|
228
|
+
"simklcli", "access-token", previous_keyring_token
|
|
229
|
+
)
|
|
230
|
+
except Exception as rollback_error:
|
|
231
|
+
raise CredentialStorageError(
|
|
232
|
+
"Credential persistence failed and could not be rolled back completely."
|
|
233
|
+
) from rollback_error
|
|
234
|
+
raise
|
|
235
|
+
|
|
236
|
+
def save(self, credential: Credential) -> None:
|
|
237
|
+
if credential.account is None:
|
|
238
|
+
raise CredentialStorageError("The credential needs a validated account.")
|
|
239
|
+
self.write(
|
|
240
|
+
access_token=credential.access_token,
|
|
241
|
+
account=credential.account,
|
|
242
|
+
source=credential.source,
|
|
243
|
+
refresh_token=credential.refresh_token,
|
|
244
|
+
expires_at=credential.expires_at,
|
|
245
|
+
refresh_expires_at=credential.refresh_expires_at,
|
|
246
|
+
client_id=credential.client_id,
|
|
247
|
+
scope=credential.scope,
|
|
248
|
+
invalidated=credential.invalidated,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def delete(self) -> Account | None:
|
|
252
|
+
if not self._path.exists():
|
|
253
|
+
return None
|
|
254
|
+
payload = self._read_payload()
|
|
255
|
+
account_payload = payload.get("account")
|
|
256
|
+
account = (
|
|
257
|
+
Account(id=account_payload["id"], name=account_payload["name"])
|
|
258
|
+
if isinstance(account_payload, dict)
|
|
259
|
+
else None
|
|
260
|
+
)
|
|
261
|
+
keyring_token: str | None = None
|
|
262
|
+
keyring_touched = False
|
|
263
|
+
try:
|
|
264
|
+
if payload.get("storage") == CredentialSource.KEYRING.value:
|
|
265
|
+
try:
|
|
266
|
+
keyring_token = self._keyring.get_password("simklcli", "access-token")
|
|
267
|
+
if keyring_token is not None:
|
|
268
|
+
keyring_touched = True
|
|
269
|
+
self._keyring.delete_password("simklcli", "access-token")
|
|
270
|
+
except Exception as keyring_error:
|
|
271
|
+
raise CredentialStorageError(
|
|
272
|
+
"Could not delete the OS keyring credential."
|
|
273
|
+
) from keyring_error
|
|
274
|
+
self._path.unlink(missing_ok=True)
|
|
275
|
+
except BaseException:
|
|
276
|
+
if keyring_touched and keyring_token is not None:
|
|
277
|
+
try:
|
|
278
|
+
self._keyring.set_password("simklcli", "access-token", keyring_token)
|
|
279
|
+
except Exception as rollback_error:
|
|
280
|
+
raise CredentialStorageError(
|
|
281
|
+
"Credential deletion failed and could not be rolled back completely."
|
|
282
|
+
) from rollback_error
|
|
283
|
+
raise
|
|
284
|
+
return account
|
|
285
|
+
|
|
286
|
+
def _read_payload(self) -> dict[str, object]:
|
|
287
|
+
try:
|
|
288
|
+
payload = json.loads(self._path.read_text(encoding="utf-8"))
|
|
289
|
+
if (
|
|
290
|
+
not isinstance(payload, dict)
|
|
291
|
+
or isinstance(payload.get("version"), bool)
|
|
292
|
+
or payload.get("version") not in {1, 2}
|
|
293
|
+
):
|
|
294
|
+
raise ValueError
|
|
295
|
+
if CredentialSource(payload["storage"]) is CredentialSource.ENVIRONMENT:
|
|
296
|
+
raise ValueError
|
|
297
|
+
account = payload["account"]
|
|
298
|
+
if not isinstance(account, dict):
|
|
299
|
+
raise ValueError
|
|
300
|
+
account_id = account.get("id")
|
|
301
|
+
name = account.get("name")
|
|
302
|
+
if (
|
|
303
|
+
not isinstance(account_id, int)
|
|
304
|
+
or isinstance(account_id, bool)
|
|
305
|
+
or account_id <= 0
|
|
306
|
+
or not isinstance(name, str)
|
|
307
|
+
or not name.strip()
|
|
308
|
+
):
|
|
309
|
+
raise ValueError
|
|
310
|
+
if payload["version"] == 2 and (
|
|
311
|
+
any(
|
|
312
|
+
not isinstance(payload.get(key), str) or not payload[key]
|
|
313
|
+
for key in ("client_id", "scope")
|
|
314
|
+
)
|
|
315
|
+
or any(
|
|
316
|
+
not isinstance(payload.get(key), (int, float))
|
|
317
|
+
or isinstance(payload[key], bool)
|
|
318
|
+
or payload[key] <= 0
|
|
319
|
+
or not math.isfinite(payload[key])
|
|
320
|
+
for key in ("expires_at", "refresh_expires_at")
|
|
321
|
+
)
|
|
322
|
+
or not isinstance(payload.get("invalidated", False), bool)
|
|
323
|
+
):
|
|
324
|
+
raise ValueError
|
|
325
|
+
except (KeyError, OSError, TypeError, ValueError) as exc:
|
|
326
|
+
raise CredentialStorageError("Stored account metadata could not be read.") from exc
|
|
327
|
+
return payload
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _restore_file(path: Path, previous_content: bytes | None) -> None:
|
|
331
|
+
if previous_content is None:
|
|
332
|
+
if path.exists():
|
|
333
|
+
path.unlink()
|
|
334
|
+
else:
|
|
335
|
+
atomic_write_bytes(path, previous_content)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
_KEYRING_UNAVAILABLE_MESSAGE = (
|
|
339
|
+
"No usable OS keyring is available. Re-run login with --storage file "
|
|
340
|
+
"to explicitly select plaintext-file storage."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _account_from_payload(payload: dict[str, object]) -> Account:
|
|
345
|
+
account_data = payload["account"]
|
|
346
|
+
assert isinstance(account_data, dict)
|
|
347
|
+
return Account(id=account_data["id"], name=account_data["name"])
|
simklcli/targets.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Validate selectors and build explicit Episode targets before any write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from simklcli.catalog import Catalog
|
|
10
|
+
from simklcli.media import LibraryEntry, Media, MediaKind, positive_int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def viewing_time(value: str | None) -> str:
|
|
14
|
+
time = (
|
|
15
|
+
datetime.now(UTC) if value is None else datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
16
|
+
)
|
|
17
|
+
if time.tzinfo is None:
|
|
18
|
+
raise ValueError("--at requires an ISO-8601 timestamp with a timezone.")
|
|
19
|
+
return time.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class WatchedTarget:
|
|
24
|
+
coordinates: tuple[tuple[int | None, int], ...] = ()
|
|
25
|
+
bulk: bool = False
|
|
26
|
+
external_id: tuple[str, str] | None = None
|
|
27
|
+
|
|
28
|
+
def item_payload(self, item: Media, *, watched_at: str | None = None) -> dict[str, Any]:
|
|
29
|
+
payload: dict[str, Any] = {"ids": {"simkl": item.simkl_id}}
|
|
30
|
+
if watched_at is not None:
|
|
31
|
+
payload["watched_at"] = watched_at
|
|
32
|
+
if self.external_id is not None:
|
|
33
|
+
provider, value = self.external_id
|
|
34
|
+
payload["episodes"] = [{"ids": {provider: value}}]
|
|
35
|
+
return payload
|
|
36
|
+
if self.coordinates:
|
|
37
|
+
episodes_by_season: dict[int, list[dict[str, Any]]] = {}
|
|
38
|
+
for season, number in self.coordinates:
|
|
39
|
+
episode: dict[str, Any] = {"number": number}
|
|
40
|
+
if watched_at is not None:
|
|
41
|
+
episode["watched_at"] = watched_at
|
|
42
|
+
episodes_by_season.setdefault(season if season is not None else 1, []).append(
|
|
43
|
+
episode
|
|
44
|
+
)
|
|
45
|
+
payload["seasons"] = [
|
|
46
|
+
{"number": season, "episodes": episodes}
|
|
47
|
+
for season, episodes in episodes_by_season.items()
|
|
48
|
+
]
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
def matches(self, entry: LibraryEntry | None, *, watched: bool) -> bool | None:
|
|
52
|
+
if self.external_id is not None:
|
|
53
|
+
# The catalog API does not guarantee external episode IDs in its response.
|
|
54
|
+
# A lost write response cannot be verified from a parent-level boolean.
|
|
55
|
+
return None
|
|
56
|
+
if not self.coordinates:
|
|
57
|
+
return (entry is not None and entry.watched) == watched
|
|
58
|
+
actual = set() if entry is None else {(e.season, e.number) for e in entry.episodes}
|
|
59
|
+
return all((coordinate in actual) == watched for coordinate in self.coordinates)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def select_target(
|
|
63
|
+
catalog: Catalog,
|
|
64
|
+
item: Media,
|
|
65
|
+
*,
|
|
66
|
+
season: int | None,
|
|
67
|
+
episode: int | None,
|
|
68
|
+
all_episodes: bool,
|
|
69
|
+
episode_id: str | None = None,
|
|
70
|
+
marking: bool = True,
|
|
71
|
+
) -> WatchedTarget:
|
|
72
|
+
if episode_id is not None and (season is not None or episode is not None or all_episodes):
|
|
73
|
+
raise ValueError("--episode-id cannot be combined with other target selectors.")
|
|
74
|
+
if episode is not None and all_episodes:
|
|
75
|
+
raise ValueError("--episode and --all cannot be combined.")
|
|
76
|
+
if item.standalone:
|
|
77
|
+
if season is not None or episode is not None or all_episodes or episode_id is not None:
|
|
78
|
+
raise ValueError("Standalone Media Items do not accept Episode selectors.")
|
|
79
|
+
return WatchedTarget()
|
|
80
|
+
if item.kind is MediaKind.ANIME and season is not None:
|
|
81
|
+
raise ValueError("Anime uses canonical Episode Numbers; omit --season.")
|
|
82
|
+
if not all_episodes and episode is None and episode_id is None:
|
|
83
|
+
raise ValueError("Episodic Media Items require --episode, --season … --all, or --all.")
|
|
84
|
+
if item.kind is MediaKind.SHOW and episode is not None and season is None:
|
|
85
|
+
raise ValueError("A Show Episode requires --season and --episode.")
|
|
86
|
+
if episode_id is not None:
|
|
87
|
+
provider, separator, value = episode_id.partition(":")
|
|
88
|
+
if not separator or provider not in {"tvdb", "anidb"} or not value:
|
|
89
|
+
raise ValueError("--episode-id must be a qualified TVDB or AniDB Episode ID.")
|
|
90
|
+
return WatchedTarget(external_id=(provider, value))
|
|
91
|
+
rows = catalog.episodes(item)
|
|
92
|
+
coordinates: list[tuple[int | None, int]] = []
|
|
93
|
+
for row in rows:
|
|
94
|
+
row_season = row.get("season") if item.kind is MediaKind.SHOW else None
|
|
95
|
+
number = positive_int(row.get("episode", row.get("number")))
|
|
96
|
+
if season is not None and season != row_season:
|
|
97
|
+
continue
|
|
98
|
+
if episode is not None and episode != number:
|
|
99
|
+
continue
|
|
100
|
+
if all_episodes and marking and not _has_aired(row):
|
|
101
|
+
continue
|
|
102
|
+
coordinates.append((row_season, number))
|
|
103
|
+
if not coordinates:
|
|
104
|
+
raise ValueError("No applicable Episodes matched this parent and the selected coordinates.")
|
|
105
|
+
return WatchedTarget(tuple(coordinates), all_episodes)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _has_aired(row: dict[str, Any]) -> bool:
|
|
109
|
+
if isinstance(row.get("aired"), bool):
|
|
110
|
+
return bool(row["aired"])
|
|
111
|
+
date = row.get("date", row.get("aired"))
|
|
112
|
+
if not isinstance(date, str):
|
|
113
|
+
return False
|
|
114
|
+
aired = datetime.fromisoformat(date.replace("Z", "+00:00"))
|
|
115
|
+
if aired.tzinfo is None:
|
|
116
|
+
aired = aired.replace(tzinfo=UTC)
|
|
117
|
+
return aired <= datetime.now(UTC)
|