Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,22 @@ The `auth_provider.v1` capability also exposes OAuth-flow RPCs (`InitAuthorize`,

`watch_sync_provider.v1` lets external plugins participate in Prairie's host-owned
watch-provider pipeline. The host owns encrypted per-profile credentials,
OAuth state, durable desired-state events, retries, ordering, and
reconciliation. Plugins are stateless protocol adapters: they receive secrets
only for the duration of an RPC, map rich movie/episode identity to an upstream
service, and return typed apply or retry outcomes.
authorization-code and device-code flow state, durable desired-state events,
retries, ordering, and reconciliation. Plugins are stateless protocol adapters:
they receive secrets only for the duration of an RPC, map rich movie/episode
identity to an upstream service, and return typed apply or retry outcomes.

Watch-sync plugins must not persist or log credentials, authorization codes,
provider flow state, or secret configuration. `ApplyEvents` is an at-least-once
contract; plugins must treat `event_id` as stable across retries and implement
convergent desired-state updates rather than increments.
convergent desired-state updates rather than increments. That rule also applies
to scrobble stops: replaying the same event ID must not create another play.
For playback events, `completed` is the host's authoritative watched decision;
plugins must not infer completion from `watch_history_id` or percentage alone.
Metadata consumers must likewise check optional `season_number` presence: zero
means Specials, while absence means no season scope. See
[compatibility guidance](docs/compatibility.md#presence-sensitive-optional-fields)
for the request and record rules.

Authenticated RPCs receive the same host-owned capability, configuration, and
credential data through `WatchSyncAuthenticatedContext`. The context exists
Expand All @@ -144,6 +151,29 @@ patches. The host validates and persists them before consuming results, pages,
or faults—even when the response contains a fault. If credential persistence
fails, the host commits no other response data.

Device-code plugins register both `WatchSyncProvider` and the separate
`WatchSyncDeviceAuthorizationService`. Keeping device authorization in a
second service preserves source compatibility for v0.12 Go providers that
implemented `WatchSyncProviderServer` directly. Register it without changing
the released `CapabilityServers` shape:

```go
runtime.ServeManifestWithOptions(manifestJSON, version, servers,
runtime.WithWatchSyncDeviceAuthorization(deviceAuthServer))
```

A pending poll may replace its opaque provider state, polling interval, and
expiry; the host encrypts and persists those values before the next poll.
Those updates remain part of the same user challenge, so the original user code
and verification URL must stay valid until expiry. An explicitly empty
`provider_state` clears the prior state; omitting it retains the prior state.

`WatchSyncProviderConfig` is keyed by manifest config key and field, for example
`provider.client_id`. Scalar values are sent as strings and structured values
as JSON. Fields marked secret in the manifest are sent through `secret_values`;
undeclared fields are treated as secret. Plugins must accept configuration from
the RPC context rather than relying on process-global state.

Descriptors and events use the shared `WatchSyncMediaType` enum so advertised
support and delivered media cannot drift between string conventions. Apply
results pair their delivery status with a typed fault: successful results omit
Expand All @@ -153,11 +183,19 @@ Connection-wide faults such as invalid credentials belong on the RPC response.

`ListRemoteState` returns provider-neutral typed subrecords. `watched` carries a
play count and last-watched time; `progress` carries a fractional percentage and
paused time. An item may contain either or both. The host keeps the request
`cursor` fixed while following ephemeral page tokens, commits each successful
page, and only then persists the final `next_cursor`. `complete_snapshot=true`
means the traversal is authoritative; when false, missing items are not
deletions.
paused time; `favorite` and `watchlist` carry list membership. An item may
contain multiple state families. The host requests only the state families a
sync phase needs, keeps that phase's `cursor` fixed while following ephemeral
page tokens, commits each successful page, and only then persists the final
`next_cursor`. `complete_snapshot=true` means the traversal is authoritative;
when false, missing items are not deletions. An incremental favorite or
watchlist removal is an item whose corresponding list state has `removed=true`;
it may omit `media` when `provider_item_key` identifies a record previously
returned to the host. When
`provides_watchlist_order=true`, watchlist traversals must be complete snapshots
and the order of returned watchlist states is the remote list order. Event
`list_position` is presence-aware: an explicit zero means the first position,
while omission means no requested ordering.

## Scan sources

Expand Down
39 changes: 39 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The compatibility boundary includes:
- manifest helpers in `pkg/pluginsdk/manifest`
- config validation helpers in `pkg/pluginsdk/config`
- generic capability metadata conversion helpers in `pkg/pluginsdk/convert`
- canonical image-variant strings in `pkg/pluginsdk/imagevariant`

## Versioning Rules

Expand All @@ -29,6 +30,44 @@ The compatibility boundary includes:
- CI and release pipelines should build with `GOWORK=off` and without checking out this repo as a sibling source dependency.
- Local `go.work` files and temporary `replace` directives are acceptable for development, but they must not be committed as the release path.

## Open Vocabularies

Some contract fields carry an open string vocabulary rather than an enum, so
Silo can add values without a breaking protobuf change. Plugins must tolerate
values they do not recognize.

Image variants (`ResolveImageURLRequest.variant`,
`ResolveImageURLsRequest.variant`, `ResolveCatalogImageURLsRequest.variant`) are
the current example. The canonical values are exported from
`pkg/pluginsdk/imagevariant`: `card`, `featured`, `large`, `full`, `original`,
listed smallest to largest. `large` (~780px posters and stills, ~1280px logos
and backdrops) was added between `featured` and `full` once Silo gained
client-selectable image sizes; adding it is an additive change and does not
require a plugin update.

A plugin receiving an unknown variant MUST degrade gracefully to its nearest
supported size — or its default size — and MUST NOT return an error. Returning
an error turns a slightly-wrong image size into a missing image. Use a `switch`
with a `default` arm rather than an exhaustive match, and do not assume the
constants shipped in any given SDK tag are the complete set. The same rule
applies to any future open vocabulary added to `v1`.

## Presence-Sensitive Optional Fields

Some contract fields use proto3 `optional` because absence and zero have
different meanings. Current examples are `WatchSyncEvent.list_position`,
`GetImagesRequest.season_number`, and `ImageRecord.season_number`.

Consumers must check presence (`nil` pointer in Go or `HasField` in reflective
APIs), never infer it from the numeric value. Calling
`GetSeasonNumber() != 0` silently conflates "no season scope" with "Specials
(season zero) requested."

A season-scoped `GetImagesRequest` is a scope, not a guarantee. Plugins that
can filter by season should do so, and plugins should populate
`ImageRecord.season_number` whenever the season is known. Hosts must bucket and
verify images by the per-image field rather than assume a filtered response.

## Runtime Compatibility

- `prairie_api_version` is the coarse runtime compatibility gate between Prairie and a plugin binary.
Expand Down
26 changes: 25 additions & 1 deletion docs/runtime-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ Catalog image fields are host-owned opaque paths. Pass poster or backdrop paths
back unchanged to resolve them into browser targets:

```go
urls, err := host.ResolveCatalogImageURLs(ctx, []string{item.PosterURL, item.BackdropURL}, "thumbnail")
import "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/imagevariant"

urls, err := host.ResolveCatalogImageURLs(ctx, []string{item.PosterURL, item.BackdropURL}, imagevariant.Card)
if err != nil { return }
posterURL := urls[item.PosterURL]
```
Expand All @@ -118,6 +120,28 @@ Pass an empty variant to use the host default. Unresolved paths are omitted from
the returned map, which is keyed by the original input path. Treat returned URLs
as opaque host-generated browser targets.

### Image variants

The variant is a semantic size hint, not an exact pixel contract. The canonical
vocabulary, smallest to largest, is exported as constants from
`pkg/pluginsdk/imagevariant`:

| Constant | Value | Intended surface |
| --- | --- | --- |
| `imagevariant.Card` | `card` | grid and list thumbnails, search results, cast rows (~300px posters) |
| `imagevariant.Featured` | `featured` | hero artwork, detail pages, section headers (~500px posters/logos, ~1280px backdrops) |
| `imagevariant.Large` | `large` | high-density displays and client-selected larger sizes (~780px posters/stills, ~1280px logos/backdrops) |
| `imagevariant.Full` | `full` | full-screen and near-source rendering |
| `imagevariant.Original` | `original` | unresized source asset |

`large` was added after the initial vocabulary; the set is open and may grow
again. A plugin that resolves variants itself (for example a metadata provider
implementing `ResolveImageURL` / `ResolveImageURLs`) must treat an unknown
variant as a request for its nearest supported size — or its default — and must
never return an error. Implement the mapping as a `switch` with a `default` arm
so new variants fall through to a sensible size instead of failing a resolve,
which users see as a missing poster.

### Peer discovery and calls

Use capability constants and discovery helpers instead of hard-coded strings
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.7.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
google.golang.org/grpc v1.75.1
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
)

Expand All @@ -17,8 +17,8 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/oklog/run v1.1.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
)
50 changes: 26 additions & 24 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -41,36 +43,36 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCw
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
Loading