anibridge-provider-base 0.1.0a1__tar.gz
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.
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AniBridge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anibridge-provider-base
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Provider contracts for the AniBridge project.
|
|
5
|
+
Keywords: anibridge,api
|
|
6
|
+
Author: Elias Benbourenane
|
|
7
|
+
Author-email: Elias Benbourenane <eliasbenbourenane@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Maintainer: Elias Benbourenane
|
|
14
|
+
Maintainer-email: Elias Benbourenane <eliasbenbourenane@gmail.com>
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# anibridge-provider-base
|
|
19
|
+
Provider contracts for the AniBridge project
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "anibridge-provider-base"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Provider contracts for the AniBridge project."
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Elias Benbourenane", email = "eliasbenbourenane@gmail.com" },
|
|
12
|
+
]
|
|
13
|
+
maintainers = [
|
|
14
|
+
{ name = "Elias Benbourenane", email = "eliasbenbourenane@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
keywords = ["anibridge", "api"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
indent-width = 4
|
|
27
|
+
line-length = 88
|
|
28
|
+
|
|
29
|
+
[tool.ruff.format]
|
|
30
|
+
docstring-code-format = true
|
|
31
|
+
indent-style = "space"
|
|
32
|
+
quote-style = "double"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["B", "D", "DOC", "E", "F", "I", "RUF", "SIM", "UP", "W"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.per-file-ignores]
|
|
38
|
+
"tests/**/*.py" = ["D"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint.pydocstyle]
|
|
41
|
+
convention = "google"
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
pythonpath = ["src"]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
addopts = "--cov=src"
|
|
47
|
+
|
|
48
|
+
[tool.uv.build-backend]
|
|
49
|
+
module-name = "anibridge"
|
|
50
|
+
namespace = true
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
54
|
+
build-backend = "uv_build"
|
|
55
|
+
|
|
56
|
+
[dependency-groups]
|
|
57
|
+
dev = [
|
|
58
|
+
"pytest>=9.0.2",
|
|
59
|
+
"pytest-asyncio>=1.3.0",
|
|
60
|
+
"pytest-cov>=7.0.0",
|
|
61
|
+
"ruff>=0.15.5",
|
|
62
|
+
"ty>=0.0.22",
|
|
63
|
+
]
|
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"""Provider contracts for AniBridge provider authors.
|
|
2
|
+
|
|
3
|
+
This module defines the interface between AniBridge and a concrete media provider (e.g.
|
|
4
|
+
Plex, AniList, Trakt, ...).
|
|
5
|
+
|
|
6
|
+
When implementing a provider, your job is to translate the provider's native objects,
|
|
7
|
+
identifiers, lists, activity, and constraints into the normalized objects in this
|
|
8
|
+
module. The contract is intentionally flexible so providers can keep their native shape,
|
|
9
|
+
but the values you return must have precise semantics. AniBridge relies on those
|
|
10
|
+
semantics to match media, compare state, plan writes, avoid duplicate work, and preserve
|
|
11
|
+
provider-specific details where appropriate.
|
|
12
|
+
|
|
13
|
+
----------------------------------------------------------------------------------------
|
|
14
|
+
Implementation guide
|
|
15
|
+
----------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
1. Start with identity: anchors, refs, and paths
|
|
18
|
+
|
|
19
|
+
The mappable unit is an anchor (a show, movie, manga, game, or similar media). A
|
|
20
|
+
`Ref` addresses that anchor by `key`. A `Ref` may also include a `path` of `Step`s
|
|
21
|
+
into the anchor's part space, such as season/episode/chapter.
|
|
22
|
+
|
|
23
|
+
Use the same anchor key for every coordinate inside the same work. Add path steps
|
|
24
|
+
when the provider exposes or accepts state below the anchor level.
|
|
25
|
+
|
|
26
|
+
This lets AniBridge align providers that model parts differently. For example, one
|
|
27
|
+
provider may expose distinct episode objects, while another only exposes an
|
|
28
|
+
aggregate "12 episodes watched". Both still describe the same anchor plus coordinate
|
|
29
|
+
space.
|
|
30
|
+
|
|
31
|
+
2. Advertise native vocabularies through capabilities
|
|
32
|
+
|
|
33
|
+
Provider-native names are open strings. Use them in `Node.kind`, `Record.kind`,
|
|
34
|
+
`Event.kind`, `Step.axis`, `Progress.unit`, and artwork roles.
|
|
35
|
+
|
|
36
|
+
Closed enums are the values AniBridge reasons over: `Status`, `RecordField`,
|
|
37
|
+
`NodeFlag`, `FacetName`, `ChangeKind`, `WriteOp`, `TemporalPrecision`, `WriteError`
|
|
38
|
+
and the semantic kind enums `NodeKind`, `RecordKind`, and `EventKind`.
|
|
39
|
+
|
|
40
|
+
In `capabilities()`, map each native kind to a closed semantic with a `Descriptor`.
|
|
41
|
+
AniBridge never compares native strings across providers. It maps source-native
|
|
42
|
+
values to semantics, chooses compatible target-native values for those semantics,
|
|
43
|
+
and translates from there.
|
|
44
|
+
|
|
45
|
+
Use `semantic=None` for native values that should be kept for display or round-trip
|
|
46
|
+
fidelity, but never used for cross-provider sync.
|
|
47
|
+
|
|
48
|
+
3. Put operational node meaning in flags
|
|
49
|
+
|
|
50
|
+
`NodeKind` describes what a node is for display, grouping, and coordinate
|
|
51
|
+
interpretation. `NodeFlag` describes how AniBridge may operate on that node.
|
|
52
|
+
|
|
53
|
+
A provider should attach flags based on the behavior the node supports:
|
|
54
|
+
|
|
55
|
+
`ANCHOR`: the root work-level node AniBridge maps across providers, such as a show,
|
|
56
|
+
movie, manga, book series, or game.
|
|
57
|
+
|
|
58
|
+
`CONTAINER`: a node that can expand into addressable children or parts, such as a
|
|
59
|
+
show with seasons, a season with episodes, or a book series with volumes.
|
|
60
|
+
|
|
61
|
+
`CONSUMABLE`: a leaf a user can complete, such as an episode, movie, chapter, track,
|
|
62
|
+
or other playable/readable unit.
|
|
63
|
+
|
|
64
|
+
`TRACKABLE`: a ref that can hold user state, such as progress, status, rating,
|
|
65
|
+
dates, repeat count, notes, or activity.
|
|
66
|
+
|
|
67
|
+
`ORDERED_PARTS`: a node whose part coordinates have meaningful order, such as
|
|
68
|
+
episode number, chapter number, disc/track order, or similar progress positions.
|
|
69
|
+
|
|
70
|
+
`SCAN_ROOT`: a node that is valid as a starting point for bulk enumeration through
|
|
71
|
+
`scan`.
|
|
72
|
+
|
|
73
|
+
A node may have multiple flags. For example, a TV series is often both an `ANCHOR`
|
|
74
|
+
and a `CONTAINER`, while an episode is often both `CONSUMABLE` and `TRACKABLE`.
|
|
75
|
+
|
|
76
|
+
4. Choose the user-state shape your provider actually owns
|
|
77
|
+
|
|
78
|
+
Return `Record` for current aggregate state about a ref, such as status, progress,
|
|
79
|
+
rating, dates, repeat count, or notes.
|
|
80
|
+
|
|
81
|
+
Return `Event` for immutable timestamped activity, such as a play, scrobble, read,
|
|
82
|
+
or check-in.
|
|
83
|
+
|
|
84
|
+
Return `EventSummary` when the provider can expose aggregate event counts without
|
|
85
|
+
paging the full event stream.
|
|
86
|
+
|
|
87
|
+
Do not emit both per-part `Record`s and `Event`s for the identical fact unless the
|
|
88
|
+
provider materially exposes both views.
|
|
89
|
+
|
|
90
|
+
5. Hydrate only the requested projection
|
|
91
|
+
|
|
92
|
+
`Node.title`, `Node.url`, and `Part.title` should be cheap labels that are always
|
|
93
|
+
safe to return.
|
|
94
|
+
|
|
95
|
+
Facets such as `TITLES`, `ARTWORK`, `IDS`, `STRUCTURE`, and `METADATA` are hydrated
|
|
96
|
+
only when requested. Record fields follow the same rule through the requested
|
|
97
|
+
`RecordField` set.
|
|
98
|
+
|
|
99
|
+
Returned objects are frozen value objects, not lazy proxies. Attribute access must
|
|
100
|
+
never perform I/O. To load more data, query the same `Ref` again with a wider facet
|
|
101
|
+
or field projection, preferably batched across many refs.
|
|
102
|
+
|
|
103
|
+
Create a separate facet only when the data requires a separate fetch, a large
|
|
104
|
+
payload, or a distinct provider endpoint.
|
|
105
|
+
|
|
106
|
+
6. Keep metadata opaque
|
|
107
|
+
|
|
108
|
+
Every `metadata: Mapping[str, MetaValue]` is provider passthrough. AniBridge does
|
|
109
|
+
not plan from it, compare it, or translate it. Metadata is only for display/logging.
|
|
110
|
+
|
|
111
|
+
If the bridge must understand a value, model it as a normalized field, facet, flag,
|
|
112
|
+
descriptor, or constraint instead of putting it in metadata.
|
|
113
|
+
|
|
114
|
+
7. Normalize datetimes and constraints
|
|
115
|
+
|
|
116
|
+
Every datetime crossing this contract must be timezone-aware UTC. A naive datetime
|
|
117
|
+
is a contract violation.
|
|
118
|
+
|
|
119
|
+
If a provider only supports date-level precision, advertise that with
|
|
120
|
+
`TemporalConstraint(precision=TemporalPrecision.DATE)` in the relevant
|
|
121
|
+
`FieldSpec.constraints`.
|
|
122
|
+
|
|
123
|
+
Use field constraints to describe what the provider can represent or accept:
|
|
124
|
+
date-vs-datetime precision, numeric range and step, text length, progress shape,
|
|
125
|
+
and similar limits. Constraints describe provider limits, not alternate normalized
|
|
126
|
+
value types.
|
|
127
|
+
|
|
128
|
+
For `RecordField.PROGRESS`, `Progress.current` is the synced user-state value.
|
|
129
|
+
Providers with bounded or discrete progress counts should describe that current
|
|
130
|
+
value with `ProgressConstraint(current=NumericConstraint(...))`.
|
|
131
|
+
`Progress.total` and `Progress.unit` describe the shape of that progress channel.
|
|
132
|
+
Providers that derive total/unit from media metadata instead of user state should
|
|
133
|
+
advertise that with `ProgressConstraint(total=False, unit=False)`.
|
|
134
|
+
|
|
135
|
+
8. Separate method presence from method granularity
|
|
136
|
+
|
|
137
|
+
Add `SupportsX` mixins for the methods your provider implements. For example, use
|
|
138
|
+
`SupportsScan` for bulk enumeration, `SupportsRecordReads` for record reads, and
|
|
139
|
+
`SupportsRecordWrites` for record writes.
|
|
140
|
+
|
|
141
|
+
Use `capabilities()` to describe what those methods can actually do: roles, facets,
|
|
142
|
+
native kind mappings, coordinate axes, record fields, event kinds, write operations,
|
|
143
|
+
change kinds, and external authorities. `isinstance(provider, SupportsX)` says a
|
|
144
|
+
method exists, but `capabilities()` says what the method supports.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
from abc import ABC, abstractmethod
|
|
148
|
+
from collections.abc import Mapping, Sequence
|
|
149
|
+
from dataclasses import dataclass, field
|
|
150
|
+
from datetime import datetime
|
|
151
|
+
from enum import StrEnum
|
|
152
|
+
from logging import Logger
|
|
153
|
+
from typing import ClassVar
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"Account",
|
|
157
|
+
"AppendEvent",
|
|
158
|
+
"Artwork",
|
|
159
|
+
"BackupArtifact",
|
|
160
|
+
"Capabilities",
|
|
161
|
+
"Change",
|
|
162
|
+
"ChangeKind",
|
|
163
|
+
"ChangeQuery",
|
|
164
|
+
"DeleteEvent",
|
|
165
|
+
"DeleteRecord",
|
|
166
|
+
"Descriptor",
|
|
167
|
+
"Event",
|
|
168
|
+
"EventChange",
|
|
169
|
+
"EventKind",
|
|
170
|
+
"EventQuery",
|
|
171
|
+
"EventSummary",
|
|
172
|
+
"EventSummaryQuery",
|
|
173
|
+
"EventWrite",
|
|
174
|
+
"ExternalId",
|
|
175
|
+
"Facet",
|
|
176
|
+
"FacetName",
|
|
177
|
+
"FieldConstraint",
|
|
178
|
+
"FieldSpec",
|
|
179
|
+
"Identifiers",
|
|
180
|
+
"InboundRequest",
|
|
181
|
+
"InboundResult",
|
|
182
|
+
"Match",
|
|
183
|
+
"MetaValue",
|
|
184
|
+
"Metadata",
|
|
185
|
+
"Node",
|
|
186
|
+
"NodeChange",
|
|
187
|
+
"NodeFlag",
|
|
188
|
+
"NodeKind",
|
|
189
|
+
"NodeQuery",
|
|
190
|
+
"NumericConstraint",
|
|
191
|
+
"Page",
|
|
192
|
+
"Part",
|
|
193
|
+
"Progress",
|
|
194
|
+
"ProgressConstraint",
|
|
195
|
+
"Provider",
|
|
196
|
+
"Rating",
|
|
197
|
+
"Record",
|
|
198
|
+
"RecordChange",
|
|
199
|
+
"RecordField",
|
|
200
|
+
"RecordKind",
|
|
201
|
+
"RecordQuery",
|
|
202
|
+
"RecordWrite",
|
|
203
|
+
"Ref",
|
|
204
|
+
"Role",
|
|
205
|
+
"Scalar",
|
|
206
|
+
"ScalarValue",
|
|
207
|
+
"ScanItem",
|
|
208
|
+
"ScanQuery",
|
|
209
|
+
"Semantic",
|
|
210
|
+
"State",
|
|
211
|
+
"Status",
|
|
212
|
+
"Step",
|
|
213
|
+
"Structure",
|
|
214
|
+
"SupportsBackupExports",
|
|
215
|
+
"SupportsBackupImports",
|
|
216
|
+
"SupportsChangeFeed",
|
|
217
|
+
"SupportsEventReads",
|
|
218
|
+
"SupportsEventSummaries",
|
|
219
|
+
"SupportsEventWrites",
|
|
220
|
+
"SupportsInboundChanges",
|
|
221
|
+
"SupportsMapping",
|
|
222
|
+
"SupportsNodeReads",
|
|
223
|
+
"SupportsNodeSearch",
|
|
224
|
+
"SupportsRecordReads",
|
|
225
|
+
"SupportsRecordWrites",
|
|
226
|
+
"SupportsScan",
|
|
227
|
+
"TemporalConstraint",
|
|
228
|
+
"TemporalPrecision",
|
|
229
|
+
"TextConstraint",
|
|
230
|
+
"Titles",
|
|
231
|
+
"UpsertRecord",
|
|
232
|
+
"Value",
|
|
233
|
+
"WriteError",
|
|
234
|
+
"WriteOp",
|
|
235
|
+
"WriteResult",
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# Record values may contain only non-null scalars. A missing `RecordField` represents
|
|
239
|
+
# "unset", so `None` must not appear as a record value.
|
|
240
|
+
type Scalar = str | int | float | bool
|
|
241
|
+
type ScalarValue = Scalar | None
|
|
242
|
+
type MetaValue = ScalarValue | tuple[ScalarValue, ...]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class Role(StrEnum):
|
|
246
|
+
"""Direction a provider may serve in a sync."""
|
|
247
|
+
|
|
248
|
+
SOURCE = "source"
|
|
249
|
+
TARGET = "target"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class NodeFlag(StrEnum):
|
|
253
|
+
"""Operational semantics the sync engine needs, attached to a catalog node."""
|
|
254
|
+
|
|
255
|
+
ANCHOR = "anchor" # mappable unit; carries cross-provider identity
|
|
256
|
+
CONTAINER = "container" # has addressable children/parts
|
|
257
|
+
CONSUMABLE = "consumable" # a leaf a user can complete (episode, chapter)
|
|
258
|
+
TRACKABLE = "trackable" # user state can be attached here
|
|
259
|
+
ORDERED_PARTS = "ordered_parts" # part coordinates carry meaningful order
|
|
260
|
+
SCAN_ROOT = "scan_root" # a valid starting point for `scan`
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Status(StrEnum):
|
|
264
|
+
"""Provider-neutral status for user state."""
|
|
265
|
+
|
|
266
|
+
PLANNED = "planned"
|
|
267
|
+
ACTIVE = "active"
|
|
268
|
+
PAUSED = "paused"
|
|
269
|
+
COMPLETED = "completed"
|
|
270
|
+
DROPPED = "dropped"
|
|
271
|
+
REPEATING = "repeating"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class RecordField(StrEnum):
|
|
275
|
+
"""Fields on a record. CLOSED vocabulary.
|
|
276
|
+
|
|
277
|
+
Each field's `Value` type is fixed and self-describing, so the bridge never needs a
|
|
278
|
+
separate storage-kind tag:
|
|
279
|
+
STATUS -> State
|
|
280
|
+
PROGRESS -> Progress
|
|
281
|
+
RATING -> Rating
|
|
282
|
+
STARTED_AT -> datetime (UTC)
|
|
283
|
+
FINISHED_AT -> datetime (UTC)
|
|
284
|
+
LAST_ACTIVITY_AT -> datetime (UTC)
|
|
285
|
+
REPEAT_COUNT -> int
|
|
286
|
+
NOTES -> str
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
STATUS = "status"
|
|
290
|
+
PROGRESS = "progress"
|
|
291
|
+
RATING = "rating"
|
|
292
|
+
STARTED_AT = "started_at"
|
|
293
|
+
FINISHED_AT = "finished_at"
|
|
294
|
+
LAST_ACTIVITY_AT = "last_activity_at"
|
|
295
|
+
REPEAT_COUNT = "repeat_count"
|
|
296
|
+
NOTES = "notes"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class TemporalPrecision(StrEnum):
|
|
300
|
+
"""Temporal precision advertised for a datetime field constraint.
|
|
301
|
+
|
|
302
|
+
If the advertised precision is `DATE`, the provider will reduce precision during
|
|
303
|
+
date/datetime translation between providers and treat datetimes as dates.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
DATE = "date"
|
|
307
|
+
DATETIME = "datetime"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True, slots=True)
|
|
311
|
+
class TemporalConstraint:
|
|
312
|
+
"""Accepted temporal granularity for a datetime field."""
|
|
313
|
+
|
|
314
|
+
precision: TemporalPrecision
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True, slots=True)
|
|
318
|
+
class NumericConstraint:
|
|
319
|
+
"""Accepted numeric range and quantization for an int/float field.
|
|
320
|
+
|
|
321
|
+
`step` is measured from `minimum` when present, else from 0.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
minimum: float | None = None
|
|
325
|
+
maximum: float | None = None
|
|
326
|
+
step: float | None = None
|
|
327
|
+
|
|
328
|
+
def __post_init__(self) -> None:
|
|
329
|
+
"""Validate numeric constraint invariants."""
|
|
330
|
+
if self.step is not None and self.step <= 0:
|
|
331
|
+
raise ValueError("NumericConstraint.step must be > 0")
|
|
332
|
+
if (
|
|
333
|
+
self.minimum is not None
|
|
334
|
+
and self.maximum is not None
|
|
335
|
+
and self.minimum > self.maximum
|
|
336
|
+
):
|
|
337
|
+
raise ValueError(
|
|
338
|
+
"NumericConstraint.minimum must be <= NumericConstraint.maximum"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True, slots=True)
|
|
343
|
+
class TextConstraint:
|
|
344
|
+
"""Accepted textual limits for a string field."""
|
|
345
|
+
|
|
346
|
+
max_length: int | None = None
|
|
347
|
+
|
|
348
|
+
def __post_init__(self) -> None:
|
|
349
|
+
"""Validate text constraint invariants."""
|
|
350
|
+
if self.max_length is not None and self.max_length < 0:
|
|
351
|
+
raise ValueError("TextConstraint.max_length must be >= 0")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True, slots=True)
|
|
355
|
+
class ProgressConstraint:
|
|
356
|
+
"""Which `Progress` dimensions are writable/comparable user state.
|
|
357
|
+
|
|
358
|
+
`Progress.current` is always the synced value for `RecordField.PROGRESS`. Set
|
|
359
|
+
`current` when the provider only accepts a bounded or quantized progress value.
|
|
360
|
+
Progress quantization floors partial units because progress counts completed units.
|
|
361
|
+
Set `total` or `unit` to false when the provider owns those dimensions as media
|
|
362
|
+
metadata rather than writable progress state.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
current: NumericConstraint | None = None
|
|
366
|
+
total: bool = True
|
|
367
|
+
unit: bool = True
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
type FieldConstraint = (
|
|
371
|
+
TemporalConstraint | NumericConstraint | TextConstraint | ProgressConstraint
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class FacetName(StrEnum):
|
|
376
|
+
"""Selectively hydratable facets on a node."""
|
|
377
|
+
|
|
378
|
+
TITLES = "titles"
|
|
379
|
+
ARTWORK = "artwork"
|
|
380
|
+
IDS = "ids"
|
|
381
|
+
STRUCTURE = "structure"
|
|
382
|
+
METADATA = "metadata"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class ChangeKind(StrEnum):
|
|
386
|
+
"""What an incremental change touched.
|
|
387
|
+
|
|
388
|
+
Advertises change granularity in `Capabilities.change_kinds`. The payloads are the
|
|
389
|
+
`Change` union.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
NODE = "node"
|
|
393
|
+
RECORD = "record"
|
|
394
|
+
EVENT = "event"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class WriteOp(StrEnum):
|
|
398
|
+
"""Write operations a provider may advertise."""
|
|
399
|
+
|
|
400
|
+
UPSERT_RECORD = "upsert_record"
|
|
401
|
+
DELETE_RECORD = "delete_record"
|
|
402
|
+
APPEND_EVENT = "append_event"
|
|
403
|
+
DELETE_EVENT = "delete_event"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class WriteError(StrEnum):
|
|
407
|
+
"""Machine-readable reason a write failed, so the planner can pick a policy.
|
|
408
|
+
|
|
409
|
+
`WriteResult.error` carries the human detail.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
UNSUPPORTED = "unsupported" # op/field not supported here (don't retry)
|
|
413
|
+
NOT_FOUND = "not_found" # target ref/record/event doesn't exist
|
|
414
|
+
CONFLICT = "conflict" # concurrency / state conflict
|
|
415
|
+
INVALID = "invalid" # malformed or rejected input (don't retry as-is)
|
|
416
|
+
AUTH = "auth" # auth/permission failure
|
|
417
|
+
RATE_LIMITED = "rate_limited" # throttled; retry after backoff
|
|
418
|
+
TRANSIENT = "transient" # transient upstream failure; retryable
|
|
419
|
+
INTERNAL = "internal" # provider-internal error
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class NodeKind(StrEnum):
|
|
423
|
+
"""Semantic node kind used for cross-provider classification.
|
|
424
|
+
|
|
425
|
+
For display, like-with-like grouping, and choosing the coordinate interpretation.
|
|
426
|
+
Operational sync semantics live in `NodeFlag`. A provider maps its natives onto
|
|
427
|
+
these in `Capabilities.node_kinds`.
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
SERIES = "series"
|
|
431
|
+
SEASON = "season"
|
|
432
|
+
EPISODE = "episode"
|
|
433
|
+
FILM = "film"
|
|
434
|
+
BOOK_SERIES = "book_series"
|
|
435
|
+
BOOK = "book"
|
|
436
|
+
CHAPTER = "chapter"
|
|
437
|
+
GAME = "game"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class RecordKind(StrEnum):
|
|
441
|
+
"""Semantic record kind: which channel of user state a record belongs to.
|
|
442
|
+
|
|
443
|
+
Lets the bridge sync like with like and never mix channels. Single-list providers
|
|
444
|
+
(e.g. AniList, MAL) declare just `PROGRESS`. Multi-list providers (e.g. Trakt) split
|
|
445
|
+
across several. Note `PLANNED` here is a separate list (a watchlist), distinct from
|
|
446
|
+
`Status.PLANNED` (a planning status inside the progress list). A provider maps its
|
|
447
|
+
natives in `Capabilities.record_kinds`.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
PROGRESS = "progress" # primary consumption state (status/progress/rating)
|
|
451
|
+
COLLECTION = "collection" # owned / in library
|
|
452
|
+
PLANNED = "planned" # intent to consume, modeled as its own list
|
|
453
|
+
RATINGS = "ratings" # ratings kept as a separate list
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class EventKind(StrEnum):
|
|
457
|
+
"""Semantic event kind. Mapped in `Capabilities.event_kinds`."""
|
|
458
|
+
|
|
459
|
+
PLAY = "play" # a consumption event / scrobble
|
|
460
|
+
CHECKIN = "checkin" # a user-initiated check-in
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# Closed vocabularies a `Descriptor` can map a native string onto.
|
|
464
|
+
type Semantic = NodeKind | RecordKind | EventKind | Status
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@dataclass(frozen=True, slots=True)
|
|
468
|
+
class Descriptor[S: Semantic]:
|
|
469
|
+
"""A native vocabulary value mapped onto a closed shared semantic.
|
|
470
|
+
|
|
471
|
+
`native` is the provider's exact term (what it puts in `Node.kind` / `Record.kind` /
|
|
472
|
+
`Event.kind`, or a native status label). `semantic` is the closed cross-provider
|
|
473
|
+
meaning the bridge matches on (e.g. native "show" -> `NodeKind.SERIES`).
|
|
474
|
+
`semantic is None` marks the native as provider-private: used only for display.
|
|
475
|
+
"""
|
|
476
|
+
|
|
477
|
+
native: str
|
|
478
|
+
semantic: S | None = None
|
|
479
|
+
description: str | None = None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass(frozen=True, slots=True)
|
|
483
|
+
class ExternalId:
|
|
484
|
+
"""A stable external identity used for cross-provider matching.
|
|
485
|
+
|
|
486
|
+
In-memory form of an anibridge-mappings *descriptor*
|
|
487
|
+
(https://github.com/anibridge/anibridge-mappings), whose wire format is
|
|
488
|
+
`authority:value[:scope]`.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
authority: str # mappings descriptor "provider"
|
|
492
|
+
value: str # mappings descriptor "id"
|
|
493
|
+
scope: str | None = None # optional mappings descriptor "scope" for subsetting
|
|
494
|
+
|
|
495
|
+
@classmethod
|
|
496
|
+
def parse(cls, descriptor: str) -> ExternalId:
|
|
497
|
+
"""Parse an `authority:value[:scope]` descriptor.
|
|
498
|
+
|
|
499
|
+
Relies on the dataset invariant that authority/value tokens contain no
|
|
500
|
+
colons, so a 3-field split yields a scope and a 2-field split does not.
|
|
501
|
+
"""
|
|
502
|
+
parts = descriptor.split(":")
|
|
503
|
+
if len(parts) == 2:
|
|
504
|
+
authority, value = parts
|
|
505
|
+
return cls(authority, value)
|
|
506
|
+
if len(parts) == 3:
|
|
507
|
+
authority, value, scope = parts
|
|
508
|
+
return cls(authority, value, scope)
|
|
509
|
+
raise ValueError(f"not a valid descriptor: {descriptor!r}")
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def descriptor(self) -> str:
|
|
513
|
+
"""Render back to the `authority:value[:scope]` wire format."""
|
|
514
|
+
if self.scope is None:
|
|
515
|
+
return f"{self.authority}:{self.value}"
|
|
516
|
+
return f"{self.authority}:{self.value}:{self.scope}"
|
|
517
|
+
|
|
518
|
+
def __repr__(self) -> str:
|
|
519
|
+
"""Debug-friendly descriptor form."""
|
|
520
|
+
return self.descriptor
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@dataclass(frozen=True, slots=True)
|
|
524
|
+
class Step:
|
|
525
|
+
"""One coordinate on the path into an anchor's part space."""
|
|
526
|
+
|
|
527
|
+
axis: str # e.g. "season", "episode", "chapter", "disc", "track"
|
|
528
|
+
value: int | str
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@dataclass(frozen=True, slots=True)
|
|
532
|
+
class Ref:
|
|
533
|
+
"""Addresses an anchor, or a part within it via `path`.
|
|
534
|
+
|
|
535
|
+
`key` is the provider's identifier for the anchor (the mappable unit). An empty
|
|
536
|
+
`path` points at the anchor; a non-empty path points at a coordinate inside it. The
|
|
537
|
+
same (anchor, path) aligns across providers even when one materializes the part as
|
|
538
|
+
its own object and another does not.
|
|
539
|
+
"""
|
|
540
|
+
|
|
541
|
+
key: str
|
|
542
|
+
path: tuple[Step, ...] = ()
|
|
543
|
+
|
|
544
|
+
@classmethod
|
|
545
|
+
def anchor(cls, key: str) -> Ref:
|
|
546
|
+
"""Reference the anchor itself."""
|
|
547
|
+
return cls(key)
|
|
548
|
+
|
|
549
|
+
@classmethod
|
|
550
|
+
def at(cls, key: str, *steps: tuple[str, int | str]) -> Ref:
|
|
551
|
+
"""Reference a part: `Ref.at(show, ("season", 1), ("episode", 3))`."""
|
|
552
|
+
return cls(key, tuple(Step(axis, value) for axis, value in steps))
|
|
553
|
+
|
|
554
|
+
@property
|
|
555
|
+
def is_anchor(self) -> bool:
|
|
556
|
+
"""Whether this ref points at the anchor rather than a part."""
|
|
557
|
+
return not self.path
|
|
558
|
+
|
|
559
|
+
def child(self, axis: str, value: int | str) -> Ref:
|
|
560
|
+
"""Extend this ref one coordinate deeper."""
|
|
561
|
+
return Ref(self.key, (*self.path, Step(axis, value)))
|
|
562
|
+
|
|
563
|
+
def __repr__(self) -> str:
|
|
564
|
+
"""Debug-friendly string form showing the key and path."""
|
|
565
|
+
path_str = ",".join(f"{step.axis}={step.value}" for step in self.path)
|
|
566
|
+
return f"{self.key}:{path_str}" if path_str else str(self.key)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@dataclass(frozen=True, slots=True)
|
|
570
|
+
class Match:
|
|
571
|
+
"""Resolution of one external id onto a provider ref.
|
|
572
|
+
|
|
573
|
+
Resolution is not positional: `resolve` may return zero matches for an unknown id or
|
|
574
|
+
several for an ambiguous one, so every `Match` echoes the `external_id` it resolves.
|
|
575
|
+
`confidence`, when set, ranks ambiguous matches (0.0-1.0); `None` means the provider
|
|
576
|
+
does not score matches.
|
|
577
|
+
"""
|
|
578
|
+
|
|
579
|
+
external_id: ExternalId
|
|
580
|
+
ref: Ref
|
|
581
|
+
confidence: float | None = None
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@dataclass(frozen=True, slots=True)
|
|
585
|
+
class Titles:
|
|
586
|
+
"""TITLES facet."""
|
|
587
|
+
|
|
588
|
+
primary: str
|
|
589
|
+
alternates: dict[str, str] = field(default_factory=dict) # {lang_code: title}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@dataclass(frozen=True, slots=True)
|
|
593
|
+
class Artwork:
|
|
594
|
+
"""ARTWORK facet."""
|
|
595
|
+
|
|
596
|
+
images: Mapping[str, str] = field(default_factory=dict)
|
|
597
|
+
|
|
598
|
+
@property
|
|
599
|
+
def poster(self) -> str | None:
|
|
600
|
+
"""Convenience accessor for the conventional 'poster' role."""
|
|
601
|
+
return self.images.get("poster")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@dataclass(frozen=True, slots=True)
|
|
605
|
+
class Identifiers:
|
|
606
|
+
"""IDS facet: the external identities of this node."""
|
|
607
|
+
|
|
608
|
+
ids: tuple[ExternalId, ...] = ()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@dataclass(frozen=True, slots=True)
|
|
612
|
+
class Part:
|
|
613
|
+
"""One addressable position inside an anchor's coordinate space.
|
|
614
|
+
|
|
615
|
+
`key` is present only when the provider materializes the part as its own object
|
|
616
|
+
(e.g. a Plex episode). It is absent for synthetic positions (e.g. an AniList episode
|
|
617
|
+
index that exists only as a progress coordinate).
|
|
618
|
+
|
|
619
|
+
`title` is a lightweight inline label only.
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
position: tuple[Step, ...]
|
|
623
|
+
title: str | None = None
|
|
624
|
+
key: str | None = None
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@dataclass(frozen=True, slots=True)
|
|
628
|
+
class Structure:
|
|
629
|
+
"""STRUCTURE facet: the coordinate space of an anchor.
|
|
630
|
+
|
|
631
|
+
`axes` is the ordered axis vocabulary (e.g. `("season", "episode")` or
|
|
632
|
+
`("chapter",)`). `parts` enumerates valid positions; it is how the bridge learns to
|
|
633
|
+
expand/collapse aggregate state against granular activity.
|
|
634
|
+
"""
|
|
635
|
+
|
|
636
|
+
axes: tuple[str, ...] = ()
|
|
637
|
+
parts: tuple[Part, ...] = ()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@dataclass(frozen=True, slots=True)
|
|
641
|
+
class Metadata:
|
|
642
|
+
"""METADATA facet: opaque provider-neutral key/value pairs.
|
|
643
|
+
|
|
644
|
+
Presentation-only, never synchronized.
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
values: Mapping[str, MetaValue] = field(default_factory=dict)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
type Facet = Titles | Artwork | Identifiers | Structure | Metadata
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@dataclass(frozen=True, slots=True)
|
|
654
|
+
class Node:
|
|
655
|
+
"""A catalog entity: an anchor or materialized part.
|
|
656
|
+
|
|
657
|
+
`title` and `url` are inexpensive labels that may always be returned. `facets`
|
|
658
|
+
contains only the requested hydrated facets. An absent facet means "not hydrated",
|
|
659
|
+
not "empty".
|
|
660
|
+
"""
|
|
661
|
+
|
|
662
|
+
ref: Ref
|
|
663
|
+
kind: str # open string, advertised in `Capabilities.node_kinds`
|
|
664
|
+
title: str | None = None
|
|
665
|
+
url: str | None = None
|
|
666
|
+
labels: tuple[str, ...] = () # presentation labels for the web UI
|
|
667
|
+
flags: frozenset[NodeFlag] = field(default_factory=frozenset)
|
|
668
|
+
facets: Mapping[FacetName, Facet] = field(default_factory=dict)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@dataclass(frozen=True, slots=True)
|
|
672
|
+
class State:
|
|
673
|
+
"""A lifecycle state: native label plus normalized status."""
|
|
674
|
+
|
|
675
|
+
native: str | None = None
|
|
676
|
+
status: Status | None = None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@dataclass(frozen=True, slots=True)
|
|
680
|
+
class Progress:
|
|
681
|
+
"""A progress channel.
|
|
682
|
+
|
|
683
|
+
`unit` is provider-native and open-ended, such as "episode", "page", or
|
|
684
|
+
"minute".
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
current: int | float | None
|
|
688
|
+
total: int | float | None = None
|
|
689
|
+
unit: str | None = None
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@dataclass(frozen=True, slots=True)
|
|
693
|
+
class Rating:
|
|
694
|
+
"""A rating with its native scale (e.g. 4.5 on a scale of 5)."""
|
|
695
|
+
|
|
696
|
+
value: float
|
|
697
|
+
scale: tuple[float, float, float] # (min, max, step)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# The value union for record fields. A field is unset by being absent from
|
|
701
|
+
# `Record.values`, never by storing `None`.
|
|
702
|
+
type Value = State | Progress | Rating | Scalar | datetime
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@dataclass(frozen=True, slots=True)
|
|
706
|
+
class Record:
|
|
707
|
+
"""Aggregate user state about a ref.
|
|
708
|
+
|
|
709
|
+
`kind` distinguishes coexisting state channels for the same ref, such as Trakt's
|
|
710
|
+
"watched", "collection", and "watchlist" records. Single-list providers should
|
|
711
|
+
use the default kind.
|
|
712
|
+
|
|
713
|
+
`revision` is an optional optimistic-concurrency token echoed back on writes. In
|
|
714
|
+
`values`, an absent `RecordField` means "unknown" or "unset". Never store an
|
|
715
|
+
explicit `None`; use `UpsertRecord.clear` to remove fields.
|
|
716
|
+
"""
|
|
717
|
+
|
|
718
|
+
ref: Ref
|
|
719
|
+
kind: str = "" # advertised in Capabilities.record_kinds
|
|
720
|
+
key: str | None = None # provider's record id, if any
|
|
721
|
+
url: str | None = None
|
|
722
|
+
updated_at: datetime | None = None # last mutation time (UTC); drives LWW
|
|
723
|
+
revision: str | None = None
|
|
724
|
+
ids: tuple[ExternalId, ...] = ()
|
|
725
|
+
values: Mapping[RecordField, Value] = field(default_factory=dict)
|
|
726
|
+
metadata: Mapping[str, MetaValue] = field(default_factory=dict)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@dataclass(frozen=True, slots=True)
|
|
730
|
+
class Event:
|
|
731
|
+
"""A timestamped user activity on a ref.
|
|
732
|
+
|
|
733
|
+
`at` must be timezone-aware UTC.
|
|
734
|
+
"""
|
|
735
|
+
|
|
736
|
+
ref: Ref
|
|
737
|
+
kind: str # advertised in Capabilities.event_kinds
|
|
738
|
+
at: datetime
|
|
739
|
+
key: str | None = None
|
|
740
|
+
metadata: Mapping[str, MetaValue] = field(default_factory=dict)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@dataclass(frozen=True, slots=True)
|
|
744
|
+
class EventSummary:
|
|
745
|
+
"""Aggregated event counts for efficient planning.
|
|
746
|
+
|
|
747
|
+
`first_at` and `last_at`, when present, must be timezone-aware UTC.
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
ref: Ref
|
|
751
|
+
kind: str
|
|
752
|
+
count: int | None = None
|
|
753
|
+
first_at: datetime | None = None
|
|
754
|
+
last_at: datetime | None = None
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@dataclass(frozen=True, slots=True)
|
|
758
|
+
class BackupArtifact:
|
|
759
|
+
"""Provider-managed backup payload for AniBridge to persist.
|
|
760
|
+
|
|
761
|
+
`content` is the exact provider payload to write to disk. `file_extension` must
|
|
762
|
+
include the leading dot, such as `.json` or `.zip`.
|
|
763
|
+
"""
|
|
764
|
+
|
|
765
|
+
content: bytes
|
|
766
|
+
file_extension: str = ".json"
|
|
767
|
+
media_type: str | None = None
|
|
768
|
+
|
|
769
|
+
def __post_init__(self) -> None:
|
|
770
|
+
"""Validate backup artifact invariants."""
|
|
771
|
+
if not self.file_extension.startswith("."):
|
|
772
|
+
raise ValueError("BackupArtifact.file_extension must start with '.'")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@dataclass(frozen=True, slots=True)
|
|
776
|
+
class Page[ItemT]:
|
|
777
|
+
"""One page of results with an opaque continuation cursor.
|
|
778
|
+
|
|
779
|
+
`total`, when known, is the total number of items matching the query across
|
|
780
|
+
all pages. Providers may leave it unset when calculating a total would require
|
|
781
|
+
extra work or the remote API does not expose one.
|
|
782
|
+
"""
|
|
783
|
+
|
|
784
|
+
items: tuple[ItemT, ...]
|
|
785
|
+
cursor: str | None = None
|
|
786
|
+
total: int | None = None
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@dataclass(frozen=True, slots=True)
|
|
790
|
+
class ScanItem:
|
|
791
|
+
"""A node paired with its user records during source enumeration."""
|
|
792
|
+
|
|
793
|
+
node: Node
|
|
794
|
+
records: tuple[Record, ...] = ()
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@dataclass(frozen=True, slots=True)
|
|
798
|
+
class NodeQuery:
|
|
799
|
+
"""Targeted node lookup.
|
|
800
|
+
|
|
801
|
+
Only requested `facets` are hydrated. `native_node_kinds` filters on the
|
|
802
|
+
provider's own open-string kinds, not the closed semantic `NodeKind`.
|
|
803
|
+
"""
|
|
804
|
+
|
|
805
|
+
refs: tuple[Ref, ...] = ()
|
|
806
|
+
native_node_kinds: tuple[str, ...] = ()
|
|
807
|
+
flags: frozenset[NodeFlag] = field(default_factory=frozenset)
|
|
808
|
+
facets: frozenset[FacetName] = field(default_factory=frozenset)
|
|
809
|
+
cursor: str | None = None
|
|
810
|
+
limit: int | None = None
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@dataclass(frozen=True, slots=True)
|
|
814
|
+
class RecordQuery:
|
|
815
|
+
"""Selective record lookup.
|
|
816
|
+
|
|
817
|
+
Only requested `fields` are hydrated. `native_record_kinds` filters on the
|
|
818
|
+
provider's own open-string kinds.
|
|
819
|
+
"""
|
|
820
|
+
|
|
821
|
+
refs: tuple[Ref, ...] = ()
|
|
822
|
+
keys: tuple[str, ...] = ()
|
|
823
|
+
native_record_kinds: tuple[str, ...] = ()
|
|
824
|
+
fields: frozenset[RecordField] = field(default_factory=frozenset)
|
|
825
|
+
cursor: str | None = None
|
|
826
|
+
limit: int | None = None
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@dataclass(frozen=True, slots=True)
|
|
830
|
+
class ScanQuery:
|
|
831
|
+
"""Source enumeration.
|
|
832
|
+
|
|
833
|
+
`ScanQuery` is separate from `NodeQuery`, which is optimized for targeted ref
|
|
834
|
+
lookups. The `native_*_kinds` filters use the provider's own open-string kinds.
|
|
835
|
+
When `with_records` is true, returned scan items may include user records.
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
sources: tuple[Ref, ...] = () # scan roots; empty means the full catalog
|
|
839
|
+
native_node_kinds: tuple[str, ...] = ()
|
|
840
|
+
flags: frozenset[NodeFlag] = field(default_factory=frozenset)
|
|
841
|
+
facets: frozenset[FacetName] = field(default_factory=frozenset)
|
|
842
|
+
native_record_kinds: frozenset[str] = field(default_factory=frozenset)
|
|
843
|
+
fields: frozenset[RecordField] = field(default_factory=frozenset)
|
|
844
|
+
with_records: bool = True
|
|
845
|
+
require_activity: bool = False # include only items with user state
|
|
846
|
+
cursor: str | None = None
|
|
847
|
+
limit: int | None = None
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@dataclass(frozen=True, slots=True)
|
|
851
|
+
class EventQuery:
|
|
852
|
+
"""Detailed event lookup.
|
|
853
|
+
|
|
854
|
+
`native_event_kinds` filters on the provider's own open-string event kinds.
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
refs: tuple[Ref, ...] = ()
|
|
858
|
+
native_event_kinds: tuple[str, ...] = ()
|
|
859
|
+
cursor: str | None = None
|
|
860
|
+
limit: int | None = None
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@dataclass(frozen=True, slots=True)
|
|
864
|
+
class EventSummaryQuery:
|
|
865
|
+
"""Aggregate event lookup.
|
|
866
|
+
|
|
867
|
+
`native_event_kinds` filters on the provider's own open-string event kinds.
|
|
868
|
+
"""
|
|
869
|
+
|
|
870
|
+
refs: tuple[Ref, ...] = ()
|
|
871
|
+
native_event_kinds: tuple[str, ...] = ()
|
|
872
|
+
cursor: str | None = None
|
|
873
|
+
limit: int | None = None
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@dataclass(frozen=True, slots=True)
|
|
877
|
+
class ChangeQuery:
|
|
878
|
+
"""Incremental change-feed poll."""
|
|
879
|
+
|
|
880
|
+
cursor: str | None = None
|
|
881
|
+
limit: int | None = None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@dataclass(frozen=True, slots=True)
|
|
885
|
+
class UpsertRecord:
|
|
886
|
+
"""Create or patch a record.
|
|
887
|
+
|
|
888
|
+
`set` applies field values, and `clear` removes fields. `token` is an opaque
|
|
889
|
+
client-side correlation tag echoed on the matching `WriteResult`.
|
|
890
|
+
`expected_revision`, when given, requests optimistic-concurrency checking.
|
|
891
|
+
"""
|
|
892
|
+
|
|
893
|
+
ref: Ref
|
|
894
|
+
kind: str = ""
|
|
895
|
+
key: str | None = None
|
|
896
|
+
token: str | None = None
|
|
897
|
+
expected_revision: str | None = None
|
|
898
|
+
set: Mapping[RecordField, Value] = field(default_factory=dict)
|
|
899
|
+
clear: frozenset[RecordField] = field(default_factory=frozenset)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@dataclass(frozen=True, slots=True)
|
|
903
|
+
class DeleteRecord:
|
|
904
|
+
"""Delete a record by key, or by (ref, kind)."""
|
|
905
|
+
|
|
906
|
+
ref: Ref | None = None
|
|
907
|
+
kind: str = ""
|
|
908
|
+
key: str | None = None
|
|
909
|
+
token: str | None = None
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
type RecordWrite = UpsertRecord | DeleteRecord
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@dataclass(frozen=True, slots=True)
|
|
916
|
+
class AppendEvent:
|
|
917
|
+
"""Append one activity event.
|
|
918
|
+
|
|
919
|
+
`at` must be timezone-aware UTC. Event appends are at-least-once from AniBridge's
|
|
920
|
+
side: `token` is a correlation tag, not a deduplication key. A provider that cannot
|
|
921
|
+
deduplicate naturally should treat a repeated `(ref, kind, at)` as the same event
|
|
922
|
+
to keep retries idempotent.
|
|
923
|
+
"""
|
|
924
|
+
|
|
925
|
+
ref: Ref
|
|
926
|
+
kind: str
|
|
927
|
+
at: datetime
|
|
928
|
+
token: str | None = None
|
|
929
|
+
metadata: Mapping[str, MetaValue] = field(default_factory=dict)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
@dataclass(frozen=True, slots=True)
|
|
933
|
+
class DeleteEvent:
|
|
934
|
+
"""Delete an event by key, or by a specific `(ref, kind, at)` signature."""
|
|
935
|
+
|
|
936
|
+
key: str | None = None
|
|
937
|
+
ref: Ref | None = None
|
|
938
|
+
kind: str | None = None
|
|
939
|
+
at: datetime | None = None
|
|
940
|
+
token: str | None = None
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
type EventWrite = AppendEvent | DeleteEvent
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@dataclass(frozen=True, slots=True)
|
|
947
|
+
class WriteResult:
|
|
948
|
+
"""Outcome of one write, returned positionally with its input.
|
|
949
|
+
|
|
950
|
+
`token` echoes the request's correlation tag for extra safety. On failure, `code`
|
|
951
|
+
is the machine-readable reason that drives retry, skip, or abort behavior, and
|
|
952
|
+
`error` is optional human-readable detail.
|
|
953
|
+
"""
|
|
954
|
+
|
|
955
|
+
ok: bool
|
|
956
|
+
op: WriteOp
|
|
957
|
+
token: str | None = None
|
|
958
|
+
key: str | None = None
|
|
959
|
+
ref: Ref | None = None
|
|
960
|
+
revision: str | None = None
|
|
961
|
+
code: WriteError | None = None
|
|
962
|
+
error: str | None = None
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
@dataclass(frozen=True, slots=True)
|
|
966
|
+
class NodeChange:
|
|
967
|
+
"""A catalog change.
|
|
968
|
+
|
|
969
|
+
`facets` names what changed for targeted rehydration. Empty `facets` means the
|
|
970
|
+
changed facets are unknown and the node should be reread fully.
|
|
971
|
+
"""
|
|
972
|
+
|
|
973
|
+
ref: Ref | None = None
|
|
974
|
+
key: str | None = None
|
|
975
|
+
at: datetime | None = None # UTC
|
|
976
|
+
facets: frozenset[FacetName] = field(default_factory=frozenset)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
@dataclass(frozen=True, slots=True)
|
|
980
|
+
class RecordChange:
|
|
981
|
+
"""A record change.
|
|
982
|
+
|
|
983
|
+
`kind` is the affected record kind. `fields` names what changed for targeted
|
|
984
|
+
rehydration. Empty `fields` means the changed fields are unknown and the record
|
|
985
|
+
should be reread fully.
|
|
986
|
+
"""
|
|
987
|
+
|
|
988
|
+
ref: Ref | None = None
|
|
989
|
+
key: str | None = None
|
|
990
|
+
kind: str = ""
|
|
991
|
+
at: datetime | None = None # UTC
|
|
992
|
+
fields: frozenset[RecordField] = field(default_factory=frozenset)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@dataclass(frozen=True, slots=True)
|
|
996
|
+
class EventChange:
|
|
997
|
+
"""An event change. `kind` is the event kind affected."""
|
|
998
|
+
|
|
999
|
+
ref: Ref | None = None
|
|
1000
|
+
key: str | None = None
|
|
1001
|
+
kind: str = ""
|
|
1002
|
+
at: datetime | None = None # UTC
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
type Change = NodeChange | RecordChange | EventChange
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@dataclass(frozen=True, slots=True)
|
|
1009
|
+
class InboundRequest:
|
|
1010
|
+
"""A framework-agnostic push payload (webhook or similar)."""
|
|
1011
|
+
|
|
1012
|
+
method: str
|
|
1013
|
+
path: str
|
|
1014
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
1015
|
+
query: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
1016
|
+
body: bytes = b""
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@dataclass(frozen=True, slots=True)
|
|
1020
|
+
class InboundResult:
|
|
1021
|
+
"""Parsed inbound changes.
|
|
1022
|
+
|
|
1023
|
+
`matched` is False when the payload does not target this provider.
|
|
1024
|
+
"""
|
|
1025
|
+
|
|
1026
|
+
matched: bool
|
|
1027
|
+
changes: tuple[Change, ...] = ()
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
@dataclass(frozen=True, slots=True)
|
|
1031
|
+
class FieldSpec:
|
|
1032
|
+
"""The contract for one planner-visible record field.
|
|
1033
|
+
|
|
1034
|
+
The value shape is fixed by `RecordField`, so it is not restated here. `constraints`
|
|
1035
|
+
declares the provider's native limits for this field, such as date-only datetimes,
|
|
1036
|
+
whole-number rating scales, or maximum note length. `values` declares native status
|
|
1037
|
+
labels and their semantics for the `STATUS` field.
|
|
1038
|
+
"""
|
|
1039
|
+
|
|
1040
|
+
field: RecordField
|
|
1041
|
+
readable: bool = True
|
|
1042
|
+
writable: bool = False
|
|
1043
|
+
constraints: tuple[FieldConstraint, ...] = ()
|
|
1044
|
+
values: tuple[Descriptor[Status], ...] = () # native status labels
|
|
1045
|
+
description: str | None = None
|
|
1046
|
+
|
|
1047
|
+
def __post_init__(self) -> None:
|
|
1048
|
+
"""Reject ambiguous field capability declarations."""
|
|
1049
|
+
if self.field != RecordField.STATUS and self.values:
|
|
1050
|
+
raise ValueError("FieldSpec.values is only valid for RecordField.STATUS")
|
|
1051
|
+
|
|
1052
|
+
if self.field == RecordField.STATUS and (self.readable or self.writable):
|
|
1053
|
+
if not self.values:
|
|
1054
|
+
raise ValueError("STATUS fields must declare supported native values")
|
|
1055
|
+
|
|
1056
|
+
seen_native: set[str] = set()
|
|
1057
|
+
seen_writable_semantics: set[Status] = set()
|
|
1058
|
+
for descriptor in self.values:
|
|
1059
|
+
if descriptor.semantic is None:
|
|
1060
|
+
raise ValueError(
|
|
1061
|
+
"STATUS field values must map to a normalized Status"
|
|
1062
|
+
)
|
|
1063
|
+
if descriptor.native in seen_native:
|
|
1064
|
+
raise ValueError(
|
|
1065
|
+
f"duplicate STATUS native value: {descriptor.native!r}"
|
|
1066
|
+
)
|
|
1067
|
+
seen_native.add(descriptor.native)
|
|
1068
|
+
if not self.writable:
|
|
1069
|
+
continue
|
|
1070
|
+
if descriptor.semantic in seen_writable_semantics:
|
|
1071
|
+
raise ValueError(
|
|
1072
|
+
f"duplicate writable STATUS semantic: {descriptor.semantic!r}"
|
|
1073
|
+
)
|
|
1074
|
+
seen_writable_semantics.add(descriptor.semantic)
|
|
1075
|
+
|
|
1076
|
+
seen: set[type[FieldConstraint]] = set()
|
|
1077
|
+
for constraint in self.constraints:
|
|
1078
|
+
constraint_type = type(constraint)
|
|
1079
|
+
if constraint_type in seen:
|
|
1080
|
+
raise ValueError(
|
|
1081
|
+
f"duplicate field constraint type: {constraint_type.__name__}"
|
|
1082
|
+
)
|
|
1083
|
+
seen.add(constraint_type)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
@dataclass(frozen=True, slots=True)
|
|
1087
|
+
class Capabilities:
|
|
1088
|
+
"""Provider vocabularies and sub-method granularity.
|
|
1089
|
+
|
|
1090
|
+
Method presence is answered by `isinstance(provider, SupportsX)`. `Capabilities`
|
|
1091
|
+
describes what those methods support.
|
|
1092
|
+
|
|
1093
|
+
`node_kinds`, `record_kinds`, and `event_kinds` map native kind strings onto closed
|
|
1094
|
+
semantics so the bridge can translate across providers. `coordinate_axes` maps a
|
|
1095
|
+
native node kind to its ordered axis vocabulary, such as "show" -> ("season",
|
|
1096
|
+
"episode"), so the bridge can know a kind's coordinate space without hydrating
|
|
1097
|
+
`STRUCTURE`.
|
|
1098
|
+
|
|
1099
|
+
`external_authorities` holds AniMap descriptor authority tokens this provider can
|
|
1100
|
+
emit or resolve. These are mapping-database identifiers, not provider namespaces:
|
|
1101
|
+
`Provider.NAMESPACE` identifies the AniBridge plugin/provider implementation, while
|
|
1102
|
+
authorities such as "anilist", "tmdb_show", or "tvdb_movie" identify mapping graph
|
|
1103
|
+
nodes.
|
|
1104
|
+
"""
|
|
1105
|
+
|
|
1106
|
+
roles: frozenset[Role] = field(default_factory=frozenset)
|
|
1107
|
+
facets: frozenset[FacetName] = field(default_factory=frozenset)
|
|
1108
|
+
node_kinds: tuple[Descriptor[NodeKind], ...] = ()
|
|
1109
|
+
coordinate_axes: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
1110
|
+
record_kinds: tuple[Descriptor[RecordKind], ...] = ()
|
|
1111
|
+
record_fields: Mapping[RecordField, FieldSpec] = field(default_factory=dict)
|
|
1112
|
+
event_kinds: tuple[Descriptor[EventKind], ...] = ()
|
|
1113
|
+
write_ops: frozenset[WriteOp] = field(default_factory=frozenset)
|
|
1114
|
+
change_kinds: frozenset[ChangeKind] = field(default_factory=frozenset)
|
|
1115
|
+
external_authorities: frozenset[str] = field(default_factory=frozenset)
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
@dataclass(frozen=True, slots=True)
|
|
1119
|
+
class Account:
|
|
1120
|
+
"""The authenticated account a provider instance is bound to."""
|
|
1121
|
+
|
|
1122
|
+
key: str
|
|
1123
|
+
title: str
|
|
1124
|
+
url: str | None = None
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
class Provider(ABC):
|
|
1128
|
+
"""Base for all AniBridge providers.
|
|
1129
|
+
|
|
1130
|
+
A provider instance is bound to a single authenticated account. Mix in each
|
|
1131
|
+
`SupportsX` interface the provider can fulfill, and advertise roles and
|
|
1132
|
+
vocabularies through `capabilities()`.
|
|
1133
|
+
"""
|
|
1134
|
+
|
|
1135
|
+
DISPLAY_NAME: ClassVar[str]
|
|
1136
|
+
NAMESPACE: ClassVar[str]
|
|
1137
|
+
|
|
1138
|
+
def __init__(
|
|
1139
|
+
self,
|
|
1140
|
+
*,
|
|
1141
|
+
logger: Logger,
|
|
1142
|
+
config: Mapping[str, object] | None = None,
|
|
1143
|
+
) -> None:
|
|
1144
|
+
"""Bind a provider instance to a logger and optional config mapping."""
|
|
1145
|
+
self.log = logger
|
|
1146
|
+
self.config = dict(config or {})
|
|
1147
|
+
|
|
1148
|
+
async def initialize(self) -> None:
|
|
1149
|
+
"""Run async setup after construction."""
|
|
1150
|
+
return
|
|
1151
|
+
|
|
1152
|
+
async def clear_cache(self) -> None:
|
|
1153
|
+
"""Drop provider-managed caches."""
|
|
1154
|
+
return
|
|
1155
|
+
|
|
1156
|
+
async def close(self) -> None:
|
|
1157
|
+
"""Release provider resources."""
|
|
1158
|
+
return
|
|
1159
|
+
|
|
1160
|
+
@abstractmethod
|
|
1161
|
+
def account(self) -> Account | None:
|
|
1162
|
+
"""Return the authenticated account, or None before `initialize`."""
|
|
1163
|
+
|
|
1164
|
+
def capabilities(self) -> Capabilities:
|
|
1165
|
+
"""Advertise vocabularies and sub-method granularity."""
|
|
1166
|
+
return Capabilities()
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class SupportsNodeReads(ABC):
|
|
1170
|
+
"""Targeted node reads by ref or other node-query filters."""
|
|
1171
|
+
|
|
1172
|
+
@abstractmethod
|
|
1173
|
+
async def fetch_nodes(self, query: NodeQuery) -> Page[Node]:
|
|
1174
|
+
"""Fetch nodes matching the requested query projection."""
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
class SupportsNodeSearch(ABC):
|
|
1178
|
+
"""Provider catalog search by user-entered text."""
|
|
1179
|
+
|
|
1180
|
+
@abstractmethod
|
|
1181
|
+
async def search_nodes(
|
|
1182
|
+
self,
|
|
1183
|
+
query: str,
|
|
1184
|
+
*,
|
|
1185
|
+
limit: int = 10,
|
|
1186
|
+
facets: frozenset[FacetName] = frozenset(),
|
|
1187
|
+
) -> Page[Node]:
|
|
1188
|
+
"""Search provider catalog nodes matching the requested text."""
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
class SupportsScan(ABC):
|
|
1192
|
+
"""Source enumeration."""
|
|
1193
|
+
|
|
1194
|
+
@abstractmethod
|
|
1195
|
+
async def scan(self, query: ScanQuery) -> Page[ScanItem]:
|
|
1196
|
+
"""Scan provider content in bulk, optionally including records."""
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
class SupportsMapping(ABC):
|
|
1200
|
+
"""Resolve external ids onto this provider's own refs.
|
|
1201
|
+
|
|
1202
|
+
This is per-provider identity only: `resolve` turns a descriptor identity into a
|
|
1203
|
+
native ref, and the `IDS` facet does the reverse. Cross-provider directional
|
|
1204
|
+
translation between authorities, including ranges and ratios, is performed by
|
|
1205
|
+
AniBridge's mapping layer over the anibridge-mappings dataset, not by providers.
|
|
1206
|
+
"""
|
|
1207
|
+
|
|
1208
|
+
@abstractmethod
|
|
1209
|
+
async def resolve(self, ids: Sequence[ExternalId]) -> Sequence[Match]:
|
|
1210
|
+
"""Resolve external identities onto this provider's refs."""
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
class SupportsRecordReads(ABC):
|
|
1214
|
+
"""Selective record reads."""
|
|
1215
|
+
|
|
1216
|
+
@abstractmethod
|
|
1217
|
+
async def fetch_records(self, query: RecordQuery) -> Page[Record]:
|
|
1218
|
+
"""Fetch records matching the requested field projection."""
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
class SupportsRecordWrites(ABC):
|
|
1222
|
+
"""Record mutations. Granularity is advertised in `write_ops`.
|
|
1223
|
+
|
|
1224
|
+
Results are positional and independent. Providers may optimize multiple writes
|
|
1225
|
+
internally, but should not expose partial batch ambiguity: when one write fails,
|
|
1226
|
+
return a failed `WriteResult` for that write instead of raising after applying an
|
|
1227
|
+
unknown subset.
|
|
1228
|
+
"""
|
|
1229
|
+
|
|
1230
|
+
@abstractmethod
|
|
1231
|
+
async def write_records(
|
|
1232
|
+
self, writes: Sequence[RecordWrite]
|
|
1233
|
+
) -> Sequence[WriteResult]:
|
|
1234
|
+
"""Apply record writes and return one result per input."""
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
class SupportsEventReads(ABC):
|
|
1238
|
+
"""Detailed event reads."""
|
|
1239
|
+
|
|
1240
|
+
@abstractmethod
|
|
1241
|
+
async def fetch_events(self, query: EventQuery) -> Page[Event]:
|
|
1242
|
+
"""Fetch detailed events matching the query."""
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
class SupportsEventSummaries(ABC):
|
|
1246
|
+
"""Aggregated event reads for efficient planning."""
|
|
1247
|
+
|
|
1248
|
+
@abstractmethod
|
|
1249
|
+
async def fetch_event_summaries(
|
|
1250
|
+
self, query: EventSummaryQuery
|
|
1251
|
+
) -> Page[EventSummary]:
|
|
1252
|
+
"""Fetch aggregate event summaries matching the query."""
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
class SupportsEventWrites(ABC):
|
|
1256
|
+
"""Event mutations. Granularity is advertised in `write_ops`."""
|
|
1257
|
+
|
|
1258
|
+
@abstractmethod
|
|
1259
|
+
async def write_events(self, writes: Sequence[EventWrite]) -> Sequence[WriteResult]:
|
|
1260
|
+
"""Apply event writes and return one result per input."""
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
class SupportsChangeFeed(ABC):
|
|
1264
|
+
"""Poll incremental changes."""
|
|
1265
|
+
|
|
1266
|
+
@abstractmethod
|
|
1267
|
+
async def poll_changes(self, query: ChangeQuery) -> Page[Change]:
|
|
1268
|
+
"""Poll the provider's incremental change feed."""
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
class SupportsBackupExports(ABC):
|
|
1272
|
+
"""Export provider-managed backup payloads for AniBridge to persist."""
|
|
1273
|
+
|
|
1274
|
+
@abstractmethod
|
|
1275
|
+
async def export_backup(self) -> BackupArtifact | None:
|
|
1276
|
+
"""Return a provider-managed backup payload, if supported."""
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
class SupportsBackupImports(ABC):
|
|
1280
|
+
"""Import provider-managed backup payloads persisted by AniBridge."""
|
|
1281
|
+
|
|
1282
|
+
@abstractmethod
|
|
1283
|
+
async def import_backup(self, payload: bytes) -> None:
|
|
1284
|
+
"""Restore provider-managed state from a backup payload."""
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
class SupportsInboundChanges(ABC):
|
|
1288
|
+
"""Parse push-style change notifications."""
|
|
1289
|
+
|
|
1290
|
+
@abstractmethod
|
|
1291
|
+
async def parse_inbound(self, request: InboundRequest) -> InboundResult:
|
|
1292
|
+
"""Parse an inbound push payload into normalized changes."""
|