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,2 @@
1
+ # anibridge-provider-base
2
+ 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."""