diff --git a/README.md b/README.md index 1842e92..f1c5c48 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/docs/compatibility.md b/docs/compatibility.md index 0251a09..86d8432 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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 @@ -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. diff --git a/docs/runtime-host.md b/docs/runtime-host.md index d447f10..68a570b 100644 --- a/docs/runtime-host.md +++ b/docs/runtime-host.md @@ -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] ``` @@ -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 diff --git a/go.mod b/go.mod index 8ea5d9d..1ce7be4 100644 --- a/go.mod +++ b/go.mod @@ -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 ) @@ -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 ) diff --git a/go.sum b/go.sum index f0c0a85..6681331 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= diff --git a/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go b/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go index 4173b4f..7e4d19b 100644 --- a/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go +++ b/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go @@ -1680,14 +1680,18 @@ func (x *GetEpisodesResponse) GetEpisodes() []*EpisodeRecord { } type ImageRecord struct { - state protoimpl.MessageState `protogen:"open.v1"` - Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` - Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` - Language string `protobuf:"bytes,3,opt,name=language,proto3" json:"language,omitempty"` - Width int32 `protobuf:"varint,4,opt,name=width,proto3" json:"width,omitempty"` - Height int32 `protobuf:"varint,5,opt,name=height,proto3" json:"height,omitempty"` - Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` - SeasonNumber *int32 `protobuf:"varint,7,opt,name=season_number,json=seasonNumber,proto3,oneof" json:"season_number,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` + Language string `protobuf:"bytes,3,opt,name=language,proto3" json:"language,omitempty"` + Width int32 `protobuf:"varint,4,opt,name=width,proto3" json:"width,omitempty"` + Height int32 `protobuf:"varint,5,opt,name=height,proto3" json:"height,omitempty"` + Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` + // Present only when the image is confirmed to belong to one exact season. + // Season zero represents Specials, so presence must not be inferred from the + // numeric value. Plugins should populate this field whenever the season is + // known, even when the request was already season-scoped. + SeasonNumber *int32 `protobuf:"varint,7,opt,name=season_number,json=seasonNumber,proto3,oneof" json:"season_number,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1777,7 +1781,10 @@ type GetImagesRequest struct { ItemType string `protobuf:"bytes,2,opt,name=item_type,json=itemType,proto3" json:"item_type,omitempty"` ProviderIds *structpb.Struct `protobuf:"bytes,3,opt,name=provider_ids,json=providerIds,proto3" json:"provider_ids,omitempty"` Language string `protobuf:"bytes,4,opt,name=language,proto3" json:"language,omitempty"` - // When set for series items, providers return the exact season artwork gallery + // Present only when requesting artwork for a specific TV season. + // Season zero represents Specials, so presence must not be inferred from + // the numeric value. This scope is a request, not a guarantee: plugins that + // can filter by season should do so, but hosts must verify season attribution // via ImageRecord.season_number rather than assume a filtered response. SeasonNumber *int32 `protobuf:"varint,5,opt,name=season_number,json=seasonNumber,proto3,oneof" json:"season_number,omitempty"` unknownFields protoimpl.UnknownFields @@ -1894,9 +1901,15 @@ func (x *GetImagesResponse) GetImages() []*ImageRecord { } type ResolveImageURLRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Variant string `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Semantic size hint, not an exact pixel contract. Canonical values, smallest + // to largest: "card", "featured", "large", "full", "original". Empty means + // the plugin default. The vocabulary is open and grows additively, so a + // plugin receiving an unknown variant must degrade gracefully to its nearest + // supported size and must not return an error. See + // pkg/pluginsdk/imagevariant for constants and the full rules. + Variant string `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1990,9 +2003,15 @@ func (x *ResolveImageURLResponse) GetUrl() string { } type ResolveImageURLsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Paths []string `protobuf:"bytes,1,rep,name=paths,proto3" json:"paths,omitempty"` - Variant string `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Paths []string `protobuf:"bytes,1,rep,name=paths,proto3" json:"paths,omitempty"` + // Semantic size hint applied to every path in this request. Canonical values, + // smallest to largest: "card", "featured", "large", "full", "original". Empty + // means the plugin default. The vocabulary is open and grows additively, so a + // plugin receiving an unknown variant must degrade gracefully to its nearest + // supported size and must not return an error. See + // pkg/pluginsdk/imagevariant for constants and the full rules. + Variant string `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } diff --git a/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go b/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go index 155a655..7ec89dc 100644 --- a/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go +++ b/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go @@ -7,6 +7,14 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" ) +func assertFieldNumber(t *testing.T, message protoreflect.ProtoMessage, field string, want protoreflect.FieldNumber) { + t.Helper() + got := message.ProtoReflect().Descriptor().Fields().ByName(protoreflect.Name(field)) + if got == nil || got.Number() != want { + t.Fatalf("%T.%s field number = %v, want %d", message, field, got, want) + } +} + func TestMetadataItemDescriptor_IncludesReleaseDate(t *testing.T) { field := (&MetadataItem{}).ProtoReflect().Descriptor().Fields().ByName("release_date") if field == nil { @@ -38,21 +46,14 @@ func TestMetadataTitleDescriptors_IncludeAliasAndLanguageFields(t *testing.T) { } } - assertFieldNumber := func(message protoreflect.ProtoMessage, field string, want protoreflect.FieldNumber) { - t.Helper() - got := message.ProtoReflect().Descriptor().Fields().ByName(protoreflect.Name(field)) - if got == nil || got.Number() != want { - t.Fatalf("%T.%s field number = %v, want %d", message, field, got, want) - } - } - assertFieldNumber(&ProviderSearchResult{}, "title_aliases", 9) - assertFieldNumber(&ProviderSearchResult{}, "title_language", 10) - assertFieldNumber(&ProviderSearchResult{}, "title_is_fallback", 11) - assertFieldNumber(&ProviderSearchResult{}, "original_language", 12) - assertFieldNumber(&MetadataItem{}, "title_aliases", 33) - assertFieldNumber(&MetadataItem{}, "title_language", 34) - assertFieldNumber(&MetadataItem{}, "title_is_fallback", 35) - assertFieldNumber(&MetadataItem{}, "title_aliases_complete", 36) + assertFieldNumber(t, &ProviderSearchResult{}, "title_aliases", 9) + assertFieldNumber(t, &ProviderSearchResult{}, "title_language", 10) + assertFieldNumber(t, &ProviderSearchResult{}, "title_is_fallback", 11) + assertFieldNumber(t, &ProviderSearchResult{}, "original_language", 12) + assertFieldNumber(t, &MetadataItem{}, "title_aliases", 33) + assertFieldNumber(t, &MetadataItem{}, "title_language", 34) + assertFieldNumber(t, &MetadataItem{}, "title_is_fallback", 35) + assertFieldNumber(t, &MetadataItem{}, "title_aliases_complete", 36) } func TestMetadataItem_OldPayloadDecodesWithOptionalTitleFields(t *testing.T) { @@ -123,6 +124,11 @@ func TestMetadataProviderRequestDescriptors_IncludeProviderContext(t *testing.T) fieldName: "language", message: &GetImagesRequest{}, }, + { + name: "GetImagesRequest season_number", + fieldName: "season_number", + message: &GetImagesRequest{}, + }, { name: "SearchMetadataRequest language", fieldName: "language", @@ -149,6 +155,32 @@ func TestMetadataProviderRequestDescriptors_IncludeProviderContext(t *testing.T) } } +func TestGetImagesRequest_SeasonNumberPreservesPresenceAndSpecials(t *testing.T) { + seasonZero := int32(0) + assertOptionalInt32Presence( + t, + &GetImagesRequest{SeasonNumber: &seasonZero}, + &GetImagesRequest{}, + func(message proto.Message) *int32 { + return message.(*GetImagesRequest).SeasonNumber + }, + ) + assertFieldNumber(t, &GetImagesRequest{}, "season_number", 5) +} + +func TestImageRecord_SeasonNumberPreservesPresenceAndSpecials(t *testing.T) { + seasonZero := int32(0) + assertOptionalInt32Presence( + t, + &ImageRecord{SeasonNumber: &seasonZero}, + &ImageRecord{}, + func(message proto.Message) *int32 { + return message.(*ImageRecord).SeasonNumber + }, + ) + assertFieldNumber(t, &ImageRecord{}, "season_number", 7) +} + func TestMetadataProviderServiceDescriptor_IncludesPersonDetailRPC(t *testing.T) { method := File_prairie_plugin_v1_metadata_provider_proto.Services(). ByName("MetadataProvider"). diff --git a/pkg/pluginproto/prairie/plugin/v1/optional_presence_test.go b/pkg/pluginproto/prairie/plugin/v1/optional_presence_test.go new file mode 100644 index 0000000..38c089a --- /dev/null +++ b/pkg/pluginproto/prairie/plugin/v1/optional_presence_test.go @@ -0,0 +1,36 @@ +package pluginv1 + +import ( + "testing" + + "google.golang.org/protobuf/proto" +) + +func assertOptionalInt32Presence(t *testing.T, withZero proto.Message, empty proto.Message, get func(proto.Message) *int32) { + t.Helper() + + data, err := proto.Marshal(withZero) + if err != nil { + t.Fatalf("marshal %T with explicit zero: %v", withZero, err) + } + decoded := withZero.ProtoReflect().New().Interface() + if err := proto.Unmarshal(data, decoded); err != nil { + t.Fatalf("unmarshal %T with explicit zero: %v", withZero, err) + } + value := get(decoded) + if value == nil || *value != 0 { + t.Fatalf("%T explicit zero = %v, want present value 0", decoded, value) + } + + data, err = proto.Marshal(empty) + if err != nil { + t.Fatalf("marshal empty %T: %v", empty, err) + } + decoded = empty.ProtoReflect().New().Interface() + if err := proto.Unmarshal(data, decoded); err != nil { + t.Fatalf("unmarshal empty %T: %v", empty, err) + } + if value := get(decoded); value != nil { + t.Fatalf("%T absent value = %v, want nil", decoded, value) + } +} diff --git a/pkg/pluginproto/prairie/plugin/v1/runtime_host.pb.go b/pkg/pluginproto/prairie/plugin/v1/runtime_host.pb.go index 8533931..5326eaa 100644 --- a/pkg/pluginproto/prairie/plugin/v1/runtime_host.pb.go +++ b/pkg/pluginproto/prairie/plugin/v1/runtime_host.pb.go @@ -1543,9 +1543,13 @@ type ResolveCatalogImageURLsRequest struct { // backdrop_url values returned by ListLibraryMedia. Paths are host-owned // opaque strings; plugins should pass them back unchanged. Paths []string `protobuf:"bytes,1,rep,name=paths,proto3" json:"paths,omitempty"` - // Optional image variant name understood by the host resolver. Empty uses the - // host default. Common values include "thumbnail" and "original"; unsupported - // variants are handled host-side. + // Optional semantic size hint understood by the host resolver, not an exact + // pixel contract. Canonical values, smallest to largest: "card", "featured", + // "large", "full", "original". Empty uses the host default. The vocabulary is + // open and grows additively; unsupported variants are degraded host-side to + // the nearest available size rather than erroring, and the same graceful + // degradation is required of plugins that resolve variants themselves. See + // pkg/pluginsdk/imagevariant for constants and the full rules. Variant string `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_compat_test.go b/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_compat_test.go new file mode 100644 index 0000000..e415a1e --- /dev/null +++ b/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_compat_test.go @@ -0,0 +1,39 @@ +package pluginv1 + +import "context" + +// legacyDirectWatchSyncProvider is deliberately implemented without embedding +// UnimplementedWatchSyncProviderServer. Keep this compile-time assertion so a +// future additive RPC cannot silently break source compatibility for v0.12 +// plugins generated with require_unimplemented_servers=false. +type legacyDirectWatchSyncProvider struct{} + +func (legacyDirectWatchSyncProvider) InitAuthorize(context.Context, *WatchSyncInitAuthorizeRequest) (*WatchSyncInitAuthorizeResponse, error) { + return nil, nil +} + +func (legacyDirectWatchSyncProvider) ExchangeCode(context.Context, *WatchSyncExchangeCodeRequest) (*WatchSyncCredentialResponse, error) { + return nil, nil +} + +func (legacyDirectWatchSyncProvider) ExchangeAPIKey(context.Context, *WatchSyncExchangeAPIKeyRequest) (*WatchSyncCredentialResponse, error) { + return nil, nil +} + +func (legacyDirectWatchSyncProvider) RefreshCredentials(context.Context, *WatchSyncRefreshCredentialsRequest) (*WatchSyncCredentialResponse, error) { + return nil, nil +} + +func (legacyDirectWatchSyncProvider) GetAccount(context.Context, *WatchSyncGetAccountRequest) (*WatchSyncGetAccountResponse, error) { + return nil, nil +} + +func (legacyDirectWatchSyncProvider) ApplyEvents(context.Context, *WatchSyncApplyEventsRequest) (*WatchSyncApplyEventsResponse, error) { + return nil, nil +} + +func (legacyDirectWatchSyncProvider) ListRemoteState(context.Context, *WatchSyncListRemoteStateRequest) (*WatchSyncListRemoteStateResponse, error) { + return nil, nil +} + +var _ WatchSyncProviderServer = legacyDirectWatchSyncProvider{} diff --git a/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_test.go b/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_test.go index dff73a3..2c1d4f1 100644 --- a/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_test.go +++ b/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider_test.go @@ -26,6 +26,7 @@ func TestWatchSyncTypedRemoteStateRoundTrip(t *testing.T) { ProgressPercent: 42.5, PausedAt: timestamppb.New(pausedAt), }, + Watchlist: &WatchSyncRemoteListState{ListedAt: timestamppb.New(pausedAt.Add(-2 * time.Hour)), Removed: true}, }}, NextCursor: "checkpoint-2", CompleteSnapshot: true, @@ -44,11 +45,120 @@ func TestWatchSyncTypedRemoteStateRoundTrip(t *testing.T) { if item.GetMedia().GetMediaType() != WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE || item.GetWatched().GetPlayCount() != 2 || item.GetProgress().GetProgressPercent() != 42.5 || - !item.GetProgress().GetPausedAt().AsTime().Equal(pausedAt) { + !item.GetProgress().GetPausedAt().AsTime().Equal(pausedAt) || + !item.GetWatchlist().GetListedAt().AsTime().Equal(pausedAt.Add(-2*time.Hour)) || + !item.GetWatchlist().GetRemoved() { t.Fatalf("remote state = %#v", item) } } +func TestWatchSyncDeviceAuthorizationRoundTrip(t *testing.T) { + expiresAt := time.Unix(1_800_000_000, 0).UTC() + input := &WatchSyncDeviceAuthorizationServiceStartResponse{ + UserCode: "ABCD-1234", + VerificationUrl: "https://provider.example/activate", + VerificationUrlComplete: "https://provider.example/activate?code=ABCD-1234", + ProviderState: []byte(`{"device_code":"secret"}`), + PollingInterval: durationpb.New(5 * time.Second), + ExpiresAt: timestamppb.New(expiresAt), + } + data, err := proto.Marshal(input) + if err != nil { + t.Fatal(err) + } + var output WatchSyncDeviceAuthorizationServiceStartResponse + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if output.GetUserCode() != input.GetUserCode() || + output.GetVerificationUrl() != input.GetVerificationUrl() || + output.GetVerificationUrlComplete() != input.GetVerificationUrlComplete() || + !proto.Equal(&output, input) || + string(output.GetProviderState()) != string(input.GetProviderState()) || + output.GetPollingInterval().AsDuration() != 5*time.Second || + !output.GetExpiresAt().AsTime().Equal(expiresAt) { + t.Fatalf("device authorization = %#v", &output) + } +} + +func TestWatchSyncDeviceAuthorizationPendingStateRoundTrip(t *testing.T) { + expiresAt := time.Unix(1_800_000_100, 0).UTC() + input := &WatchSyncDeviceAuthorizationServicePollResponse{ + Status: WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_PENDING, + ProviderState: []byte("rotated-state"), + PollingInterval: durationpb.New(10 * time.Second), + ExpiresAt: timestamppb.New(expiresAt), + } + data, err := proto.Marshal(input) + if err != nil { + t.Fatal(err) + } + var output WatchSyncDeviceAuthorizationServicePollResponse + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if !proto.Equal(input, &output) { + t.Fatalf("pending device authorization = %#v", &output) + } +} + +func TestWatchSyncDeviceAuthorizationPendingStatePreservesPresence(t *testing.T) { + explicitEmpty := &WatchSyncDeviceAuthorizationServicePollResponse{ProviderState: []byte{}} + data, err := proto.Marshal(explicitEmpty) + if err != nil { + t.Fatal(err) + } + var output WatchSyncDeviceAuthorizationServicePollResponse + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if output.ProviderState == nil || len(output.GetProviderState()) != 0 { + t.Fatalf("explicit empty provider state = %#v", output.ProviderState) + } + + data, err = proto.Marshal(&WatchSyncDeviceAuthorizationServicePollResponse{}) + if err != nil { + t.Fatal(err) + } + output.Reset() + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if output.ProviderState != nil { + t.Fatalf("omitted provider state = %#v, want nil", output.ProviderState) + } +} + +func TestWatchSyncListPositionPreservesPresence(t *testing.T) { + zero := int32(0) + assertOptionalInt32Presence( + t, + &WatchSyncEvent{ListPosition: &zero}, + &WatchSyncEvent{}, + func(message proto.Message) *int32 { + return message.(*WatchSyncEvent).ListPosition + }, + ) +} + +func TestWatchSyncListTombstoneDoesNotRequireMedia(t *testing.T) { + input := &WatchSyncRemoteState{ + ProviderItemKey: "remote-1", + Favorite: &WatchSyncRemoteListState{Removed: true}, + } + data, err := proto.Marshal(input) + if err != nil { + t.Fatal(err) + } + var output WatchSyncRemoteState + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if !proto.Equal(input, &output) || output.GetMedia() != nil || !output.GetFavorite().GetRemoved() { + t.Fatalf("list tombstone = %#v", &output) + } +} + func TestWatchSyncApplyResultCarriesTypedRateLimit(t *testing.T) { retryAfter := 45 * time.Second request := &WatchSyncApplyEventsRequest{ @@ -87,3 +197,35 @@ func TestWatchSyncApplyResultCarriesTypedRateLimit(t *testing.T) { t.Fatalf("request=%#v result=%#v", request, &output) } } + +func TestWatchSyncEventCarriesAuthoritativeCompletionState(t *testing.T) { + input := &WatchSyncEvent{ + EventId: "playback-stop-1", + WatchHistoryId: "incomplete-history-row", + CompletionPercent: 5.5, + Completed: false, + } + data, err := proto.Marshal(input) + if err != nil { + t.Fatal(err) + } + var output WatchSyncEvent + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if output.GetCompleted() { + t.Fatal("completed = true, want false") + } + + input.Completed = true + data, err = proto.Marshal(input) + if err != nil { + t.Fatal(err) + } + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if !output.GetCompleted() { + t.Fatal("completed = false, want true") + } +} diff --git a/pkg/pluginsdk/convert/convert.go b/pkg/pluginsdk/convert/convert.go index fdc8981..6cee44d 100644 --- a/pkg/pluginsdk/convert/convert.go +++ b/pkg/pluginsdk/convert/convert.go @@ -125,13 +125,28 @@ func capabilityMetadata(descriptor *pluginv1.CapabilityDescriptor) (map[string]a if len(descriptor.GetConfigSchema()) > 0 { schemas := make([]map[string]any, 0, len(descriptor.GetConfigSchema())) for _, schema := range descriptor.GetConfigSchema() { - schemas = append(schemas, map[string]any{ - "key": schema.GetKey(), - "title": schema.GetTitle(), - "description": schema.GetDescription(), - "json_schema": schema.GetJsonSchema(), - "required": schema.GetRequired(), - }) + if schema == nil { + schemas = append(schemas, map[string]any{}) + continue + } + data, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("encode capability config schema: %w", err) + } + var value map[string]any + if err := json.Unmarshal(data, &value); err != nil { + return nil, fmt.Errorf("decode capability config schema JSON: %w", err) + } + // CapabilityRecord.Metadata is a public compatibility boundary. The + // original scalar-only encoding always emitted these keys, including + // required=false; protojson omits default-valued fields unless they are + // restored explicitly. + value["key"] = schema.GetKey() + value["title"] = schema.GetTitle() + value["description"] = schema.GetDescription() + value["json_schema"] = schema.GetJsonSchema() + value["required"] = schema.GetRequired() + schemas = append(schemas, value) } metadata["config_schema"] = schemas } @@ -157,19 +172,13 @@ func decodeConfigSchemas(value any) ([]*pluginv1.ConfigSchema, error) { schemas := make([]*pluginv1.ConfigSchema, 0, len(entries)) for _, entry := range entries { rawSchema := toStringAnyMap(entry) - jsonData, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(&pluginv1.ConfigSchema{ - Key: stringValue(rawSchema["key"]), - Title: stringValue(rawSchema["title"]), - Description: stringValue(rawSchema["description"]), - JsonSchema: stringValue(rawSchema["json_schema"]), - Required: boolValue(rawSchema["required"]), - }) + jsonData, err := json.Marshal(rawSchema) if err != nil { - return nil, fmt.Errorf("encode config schema: %w", err) + return nil, fmt.Errorf("encode capability config schema: %w", err) } var schema pluginv1.ConfigSchema - if err := protojson.Unmarshal(jsonData, &schema); err != nil { - return nil, fmt.Errorf("decode config schema: %w", err) + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(jsonData, &schema); err != nil { + return nil, fmt.Errorf("decode capability config schema: %w", err) } schemas = append(schemas, &schema) } @@ -206,13 +215,3 @@ func toStringAnyMap(value any) map[string]any { return map[string]any{} } } - -func stringValue(value any) string { - text, _ := value.(string) - return text -} - -func boolValue(value any) bool { - flag, _ := value.(bool) - return flag -} diff --git a/pkg/pluginsdk/convert/convert_test.go b/pkg/pluginsdk/convert/convert_test.go index ee1ca85..a38db17 100644 --- a/pkg/pluginsdk/convert/convert_test.go +++ b/pkg/pluginsdk/convert/convert_test.go @@ -3,6 +3,7 @@ package convert_test import ( "testing" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" pluginv1 "github.com/prairie-server/prairie-plugin-sdk/pkg/pluginproto/prairie/plugin/v1" @@ -34,7 +35,15 @@ func TestCapabilityRecordsFromManifestRoundTrips(t *testing.T) { Title: "Connection", Description: "API key", JsonSchema: `{"type":"object"}`, - Required: true, + Required: false, + AdminForm: &pluginv1.AdminFormDescriptor{ + SubmitLabel: "Connect", + Fields: []*pluginv1.AdminFormField{{ + Key: "base_url", Label: "Server URL", + Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT, + Required: true, + }}, + }, }, }, Metadata: metadata, @@ -49,6 +58,19 @@ func TestCapabilityRecordsFromManifestRoundTrips(t *testing.T) { if len(records) != 1 { t.Fatalf("record count = %d, want 1", len(records)) } + recordedSchema := records[0].Metadata["config_schema"].([]map[string]any)[0] + for key, want := range map[string]any{ + "key": "connection", + "title": "Connection", + "description": "API key", + "json_schema": `{"type":"object"}`, + "required": false, + } { + if got, present := recordedSchema[key]; !present || got != want { + t.Fatalf("config_schema[%q] = %#v, present=%v, want %#v", key, got, present, want) + } + } + recordedSchema["future_field"] = true decoded, err := convert.DecodeCapability(records[0]) if err != nil { @@ -60,6 +82,9 @@ func TestCapabilityRecordsFromManifestRoundTrips(t *testing.T) { if got := decoded.GetConfigSchema()[0].GetKey(); got != "connection" { t.Fatalf("config_schema key = %q, want connection", got) } + if !proto.Equal(decoded.GetConfigSchema()[0], manifest.GetCapabilities()[0].GetConfigSchema()[0]) { + t.Fatalf("config_schema round trip = %#v, want %#v", decoded.GetConfigSchema()[0], manifest.GetCapabilities()[0].GetConfigSchema()[0]) + } if got := decoded.GetMetadata().AsMap()["provider"]; got != "example" { t.Fatalf("metadata provider = %#v, want example", got) } diff --git a/pkg/pluginsdk/convert/watch_sync_provider_test.go b/pkg/pluginsdk/convert/watch_sync_provider_test.go index f6c5660..32308e3 100644 --- a/pkg/pluginsdk/convert/watch_sync_provider_test.go +++ b/pkg/pluginsdk/convert/watch_sync_provider_test.go @@ -12,8 +12,10 @@ func TestWatchSyncProviderDescriptorRoundTrip(t *testing.T) { Type: "watch_sync_provider.v1", Id: "anilist", WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{ - AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_AUTHORIZATION_CODE}, - ExportWatched: true, + AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE}, + ExportWatched: true, + ImportWatchlist: true, + ScrobblePlayback: true, SupportedMediaTypes: []pluginv1.WatchSyncMediaType{ pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE, @@ -30,8 +32,9 @@ func TestWatchSyncProviderDescriptorRoundTrip(t *testing.T) { t.Fatal(err) } got := decoded.GetWatchSyncProvider() - if got == nil || !got.GetExportWatched() || got.GetMaxBatchSize() != 25 || - len(got.GetAuthMethods()) != 1 || len(got.GetSupportedMediaTypes()) != 2 || + if got == nil || !got.GetExportWatched() || !got.GetImportWatchlist() || !got.GetScrobblePlayback() || got.GetMaxBatchSize() != 25 || + len(got.GetAuthMethods()) != 1 || got.GetAuthMethods()[0] != pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE || + len(got.GetSupportedMediaTypes()) != 2 || got.GetSupportedMediaTypes()[1] != pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE { t.Fatalf("decoded descriptor = %#v", got) } diff --git a/pkg/pluginsdk/imagevariant/imagevariant.go b/pkg/pluginsdk/imagevariant/imagevariant.go new file mode 100644 index 0000000..9902e5c --- /dev/null +++ b/pkg/pluginsdk/imagevariant/imagevariant.go @@ -0,0 +1,52 @@ +// Package imagevariant exposes the canonical image-variant hints Silo sends to +// plugins that resolve image paths into URLs. +// +// A variant is a semantic size hint, not an exact pixel contract: the sender +// says roughly how large the image will be rendered and the resolver picks the +// closest size its upstream offers. Variants travel as plain strings in +// ResolveImageURLRequest.variant, ResolveImageURLsRequest.variant, and +// ResolveCatalogImageURLsRequest.variant. An empty variant uses the receiver's +// default: the plugin default for the metadata-provider requests and the host +// default for the RuntimeHost request. +// +// Two contract rules govern the vocabulary: +// +// 1. The set is open and may grow additively. New variants are added between or +// beyond the existing bands as Silo's clients gain new rendering surfaces; +// adding one is not a breaking change, so a plugin must never assume the +// constants in this package are exhaustive. +// 2. A plugin receiving an unknown variant MUST degrade gracefully to its +// nearest supported size (or its default) and MUST NOT return an error. A +// slightly wrong image size is always preferable to a failed resolve, which +// surfaces to users as a missing poster. +// +// The idiomatic implementation is a switch with a default arm that returns the +// plugin's fallback size, so unrecognized values fall through instead of failing. +package imagevariant + +const ( + // Card is the smallest band: dense grid and list thumbnails, search + // results, and cast rows. Targets roughly 300px-wide posters. + Card = "card" + + // Featured is the mid band: hero artwork, detail-page posters, and section + // headers. Targets roughly 500px-wide posters and logos and 1280px-wide + // backdrops. + Featured = "featured" + + // Large sits between Featured and Full for high-density displays and + // client-selected larger image sizes. Targets roughly 780px-wide posters + // and stills and 1280px-wide logos and backdrops. + Large = "large" + + // Full is the largest sized band, for full-screen and near-source + // rendering. Targets the largest sized rendition the upstream offers, + // falling back to the original asset when no sized rendition is large + // enough. + Full = "full" + + // Original requests the unresized source asset, with no host or plugin + // downscaling. It is also the conventional fallback for an empty or + // unrecognized variant. + Original = "original" +) diff --git a/pkg/pluginsdk/imagevariant/imagevariant_test.go b/pkg/pluginsdk/imagevariant/imagevariant_test.go new file mode 100644 index 0000000..ff2aab8 --- /dev/null +++ b/pkg/pluginsdk/imagevariant/imagevariant_test.go @@ -0,0 +1,21 @@ +package imagevariant + +import "testing" + +func TestCanonicalValues(t *testing.T) { + for _, tc := range []struct { + name string + got string + want string + }{ + {"Card", Card, "card"}, + {"Featured", Featured, "featured"}, + {"Large", Large, "large"}, + {"Full", Full, "full"}, + {"Original", Original, "original"}, + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.name, tc.got, tc.want) + } + } +} diff --git a/pkg/pluginsdk/manifest/manifest.go b/pkg/pluginsdk/manifest/manifest.go index db39975..7dd3f84 100644 --- a/pkg/pluginsdk/manifest/manifest.go +++ b/pkg/pluginsdk/manifest/manifest.go @@ -156,9 +156,17 @@ func validateWatchSyncCapability(descriptor *pluginv1.CapabilityDescriptor) erro return fmt.Errorf("plugin capability %q: watch sync auth method cannot be unspecified", descriptor.GetId()) } } - if !watchSync.GetExportWatched() && !watchSync.GetExportUnwatched() && !watchSync.GetImportWatched() && !watchSync.GetImportProgress() { + if !watchSync.GetExportWatched() && !watchSync.GetExportUnwatched() && + !watchSync.GetImportWatched() && !watchSync.GetImportProgress() && + !watchSync.GetImportFavorites() && !watchSync.GetExportFavorites() && + !watchSync.GetRemoveFavorites() && !watchSync.GetImportWatchlist() && + !watchSync.GetExportWatchlist() && !watchSync.GetRemoveWatchlist() && + !watchSync.GetScrobblePlayback() { return fmt.Errorf("plugin capability %q: at least one watch sync operation is required", descriptor.GetId()) } + if watchSync.GetProvidesWatchlistOrder() && !watchSync.GetImportWatchlist() { + return fmt.Errorf("plugin capability %q: watchlist order requires watchlist import", descriptor.GetId()) + } if watchSync.GetMaxBatchSize() < 1 || watchSync.GetMaxBatchSize() > 100 { return fmt.Errorf("plugin capability %q: watch sync max_batch_size must be between 1 and 100", descriptor.GetId()) } diff --git a/pkg/pluginsdk/manifest/watch_sync_provider_test.go b/pkg/pluginsdk/manifest/watch_sync_provider_test.go index 5123ad6..a9c8447 100644 --- a/pkg/pluginsdk/manifest/watch_sync_provider_test.go +++ b/pkg/pluginsdk/manifest/watch_sync_provider_test.go @@ -127,6 +127,28 @@ func TestValidateWatchSyncProviderAllowsUnknownFutureEnums(t *testing.T) { } } +func TestValidateWatchSyncProviderAllowsDeviceCodeAndListOnly(t *testing.T) { + manifest := validWatchSyncManifest() + descriptor := manifest.Capabilities[0].WatchSyncProvider + descriptor.AuthMethods = []pluginv1.WatchSyncAuthMethod{ + pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE, + } + descriptor.ExportWatched = false + descriptor.ImportWatchlist = true + descriptor.ProvidesWatchlistOrder = true + if err := publicmanifest.Validate(manifest); err != nil { + t.Fatalf("device-code list provider should be valid: %v", err) + } +} + +func TestValidateWatchSyncProviderRejectsOrderWithoutImport(t *testing.T) { + manifest := validWatchSyncManifest() + manifest.Capabilities[0].WatchSyncProvider.ProvidesWatchlistOrder = true + if err := publicmanifest.Validate(manifest); err == nil { + t.Fatal("expected watchlist order without import to fail") + } +} + func validWatchSyncManifest() *pluginv1.PluginManifest { return &pluginv1.PluginManifest{ PluginId: "prairie.valid", Version: "1.0.0", diff --git a/pkg/pluginsdk/runtime/capability_servers_compat_test.go b/pkg/pluginsdk/runtime/capability_servers_compat_test.go new file mode 100644 index 0000000..57d193f --- /dev/null +++ b/pkg/pluginsdk/runtime/capability_servers_compat_test.go @@ -0,0 +1,18 @@ +package runtime + +// Compile-time guard for the released v0.12 unkeyed composite literal shape. +// New optional servers belong behind additive registration APIs, not here. +var _ = CapabilityServers{ + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, +} diff --git a/proto/prairie/plugin/v1/metadata_provider.proto b/proto/prairie/plugin/v1/metadata_provider.proto index 726cc60..e5f0b07 100644 --- a/proto/prairie/plugin/v1/metadata_provider.proto +++ b/proto/prairie/plugin/v1/metadata_provider.proto @@ -255,6 +255,12 @@ message GetImagesResponse { message ResolveImageURLRequest { string path = 1; + // Semantic size hint, not an exact pixel contract. Canonical values, smallest + // to largest: "card", "featured", "large", "full", "original". Empty means + // the plugin default. The vocabulary is open and grows additively, so a + // plugin receiving an unknown variant must degrade gracefully to its nearest + // supported size and must not return an error. See + // pkg/pluginsdk/imagevariant for constants and the full rules. string variant = 2; } @@ -264,6 +270,12 @@ message ResolveImageURLResponse { message ResolveImageURLsRequest { repeated string paths = 1; + // Semantic size hint applied to every path in this request. Canonical values, + // smallest to largest: "card", "featured", "large", "full", "original". Empty + // means the plugin default. The vocabulary is open and grows additively, so a + // plugin receiving an unknown variant must degrade gracefully to its nearest + // supported size and must not return an error. See + // pkg/pluginsdk/imagevariant for constants and the full rules. string variant = 2; } diff --git a/proto/prairie/plugin/v1/runtime_host.proto b/proto/prairie/plugin/v1/runtime_host.proto index 924ef6e..52efe0f 100644 --- a/proto/prairie/plugin/v1/runtime_host.proto +++ b/proto/prairie/plugin/v1/runtime_host.proto @@ -232,9 +232,13 @@ message ResolveCatalogImageURLsRequest { // backdrop_url values returned by ListLibraryMedia. Paths are host-owned // opaque strings; plugins should pass them back unchanged. repeated string paths = 1; - // Optional image variant name understood by the host resolver. Empty uses the - // host default. Common values include "thumbnail" and "original"; unsupported - // variants are handled host-side. + // Optional semantic size hint understood by the host resolver, not an exact + // pixel contract. Canonical values, smallest to largest: "card", "featured", + // "large", "full", "original". Empty uses the host default. The vocabulary is + // open and grows additively; unsupported variants are degraded host-side to + // the nearest available size rather than erroring, and the same graceful + // degradation is required of plugins that resolve variants themselves. See + // pkg/pluginsdk/imagevariant for constants and the full rules. string variant = 2; }