From 13d7cccbff35c287efd146fba7942fc75e38e680 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:37:29 -0400 Subject: [PATCH 1/8] feat(watchsync): expand provider plugin contract (#13) * feat(watchsync): expand provider plugin contract * fix(watchsync): preserve provider compatibility * docs(watchsync): define list tombstones * docs(watchsync): require snapshots for ordered lists * fix(watchsync): preserve device auth compatibility --- README.md | 52 +- .../silo/plugin/v1/watch_sync_provider.pb.go | 1082 ++++++++++++++--- .../v1/watch_sync_provider_compat_test.go | 39 + .../plugin/v1/watch_sync_provider_grpc.pb.go | 146 +++ .../plugin/v1/watch_sync_provider_test.go | 127 +- .../convert/watch_sync_provider_test.go | 11 +- pkg/pluginsdk/manifest/manifest.go | 10 +- .../manifest/watch_sync_provider_test.go | 22 + .../runtime/capability_servers_compat_test.go | 18 + pkg/pluginsdk/runtime/runtime.go | 38 + pkg/pluginsdk/runtime/serve_manifest.go | 43 +- .../runtime/watch_sync_provider_test.go | 20 +- .../silo/plugin/v1/watch_sync_provider.proto | 117 +- 13 files changed, 1520 insertions(+), 205 deletions(-) create mode 100644 pkg/pluginproto/silo/plugin/v1/watch_sync_provider_compat_test.go create mode 100644 pkg/pluginsdk/runtime/capability_servers_compat_test.go diff --git a/README.md b/README.md index b355010..71d8db5 100644 --- a/README.md +++ b/README.md @@ -126,15 +126,16 @@ The `auth_provider.v1` capability also exposes OAuth-flow RPCs (`InitAuthorize`, `watch_sync_provider.v1` lets external plugins participate in Silo'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. Authenticated RPCs receive the same host-owned capability, configuration, and credential data through `WatchSyncAuthenticatedContext`. The context exists @@ -144,6 +145,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 +177,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/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go index 7b21e9c..91bfbd2 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go @@ -30,6 +30,7 @@ const ( WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_UNSPECIFIED WatchSyncAuthMethod = 0 WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_AUTHORIZATION_CODE WatchSyncAuthMethod = 1 WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY WatchSyncAuthMethod = 2 + WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_DEVICE_CODE WatchSyncAuthMethod = 3 ) // Enum value maps for WatchSyncAuthMethod. @@ -38,11 +39,13 @@ var ( 0: "WATCH_SYNC_AUTH_METHOD_UNSPECIFIED", 1: "WATCH_SYNC_AUTH_METHOD_AUTHORIZATION_CODE", 2: "WATCH_SYNC_AUTH_METHOD_API_KEY", + 3: "WATCH_SYNC_AUTH_METHOD_DEVICE_CODE", } WatchSyncAuthMethod_value = map[string]int32{ "WATCH_SYNC_AUTH_METHOD_UNSPECIFIED": 0, "WATCH_SYNC_AUTH_METHOD_AUTHORIZATION_CODE": 1, "WATCH_SYNC_AUTH_METHOD_API_KEY": 2, + "WATCH_SYNC_AUTH_METHOD_DEVICE_CODE": 3, } ) @@ -125,9 +128,16 @@ func (WatchSyncMediaType) EnumDescriptor() ([]byte, []int) { type WatchSyncOperation int32 const ( - WatchSyncOperation_WATCH_SYNC_OPERATION_UNSPECIFIED WatchSyncOperation = 0 - WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_WATCHED WatchSyncOperation = 1 - WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_UNWATCHED WatchSyncOperation = 2 + WatchSyncOperation_WATCH_SYNC_OPERATION_UNSPECIFIED WatchSyncOperation = 0 + WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_WATCHED WatchSyncOperation = 1 + WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_UNWATCHED WatchSyncOperation = 2 + WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_FAVORITE WatchSyncOperation = 3 + WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FAVORITE WatchSyncOperation = 4 + WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST WatchSyncOperation = 5 + WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST WatchSyncOperation = 6 + WatchSyncOperation_WATCH_SYNC_OPERATION_SCROBBLE_START WatchSyncOperation = 7 + WatchSyncOperation_WATCH_SYNC_OPERATION_SCROBBLE_PAUSE WatchSyncOperation = 8 + WatchSyncOperation_WATCH_SYNC_OPERATION_SCROBBLE_STOP WatchSyncOperation = 9 ) // Enum value maps for WatchSyncOperation. @@ -136,11 +146,25 @@ var ( 0: "WATCH_SYNC_OPERATION_UNSPECIFIED", 1: "WATCH_SYNC_OPERATION_MARK_WATCHED", 2: "WATCH_SYNC_OPERATION_MARK_UNWATCHED", + 3: "WATCH_SYNC_OPERATION_ADD_FAVORITE", + 4: "WATCH_SYNC_OPERATION_REMOVE_FAVORITE", + 5: "WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST", + 6: "WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST", + 7: "WATCH_SYNC_OPERATION_SCROBBLE_START", + 8: "WATCH_SYNC_OPERATION_SCROBBLE_PAUSE", + 9: "WATCH_SYNC_OPERATION_SCROBBLE_STOP", } WatchSyncOperation_value = map[string]int32{ - "WATCH_SYNC_OPERATION_UNSPECIFIED": 0, - "WATCH_SYNC_OPERATION_MARK_WATCHED": 1, - "WATCH_SYNC_OPERATION_MARK_UNWATCHED": 2, + "WATCH_SYNC_OPERATION_UNSPECIFIED": 0, + "WATCH_SYNC_OPERATION_MARK_WATCHED": 1, + "WATCH_SYNC_OPERATION_MARK_UNWATCHED": 2, + "WATCH_SYNC_OPERATION_ADD_FAVORITE": 3, + "WATCH_SYNC_OPERATION_REMOVE_FAVORITE": 4, + "WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST": 5, + "WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST": 6, + "WATCH_SYNC_OPERATION_SCROBBLE_START": 7, + "WATCH_SYNC_OPERATION_SCROBBLE_PAUSE": 8, + "WATCH_SYNC_OPERATION_SCROBBLE_STOP": 9, } ) @@ -171,6 +195,116 @@ func (WatchSyncOperation) EnumDescriptor() ([]byte, []int) { return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{2} } +type WatchSyncRemoteStateKind int32 + +const ( + WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_UNSPECIFIED WatchSyncRemoteStateKind = 0 + WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHED WatchSyncRemoteStateKind = 1 + WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS WatchSyncRemoteStateKind = 2 + WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE WatchSyncRemoteStateKind = 3 + WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST WatchSyncRemoteStateKind = 4 +) + +// Enum value maps for WatchSyncRemoteStateKind. +var ( + WatchSyncRemoteStateKind_name = map[int32]string{ + 0: "WATCH_SYNC_REMOTE_STATE_KIND_UNSPECIFIED", + 1: "WATCH_SYNC_REMOTE_STATE_KIND_WATCHED", + 2: "WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS", + 3: "WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE", + 4: "WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST", + } + WatchSyncRemoteStateKind_value = map[string]int32{ + "WATCH_SYNC_REMOTE_STATE_KIND_UNSPECIFIED": 0, + "WATCH_SYNC_REMOTE_STATE_KIND_WATCHED": 1, + "WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS": 2, + "WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE": 3, + "WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST": 4, + } +) + +func (x WatchSyncRemoteStateKind) Enum() *WatchSyncRemoteStateKind { + p := new(WatchSyncRemoteStateKind) + *p = x + return p +} + +func (x WatchSyncRemoteStateKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WatchSyncRemoteStateKind) Descriptor() protoreflect.EnumDescriptor { + return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[3].Descriptor() +} + +func (WatchSyncRemoteStateKind) Type() protoreflect.EnumType { + return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[3] +} + +func (x WatchSyncRemoteStateKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WatchSyncRemoteStateKind.Descriptor instead. +func (WatchSyncRemoteStateKind) EnumDescriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{3} +} + +type WatchSyncDeviceAuthorizationStatus int32 + +const ( + WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED WatchSyncDeviceAuthorizationStatus = 0 + WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_PENDING WatchSyncDeviceAuthorizationStatus = 1 + WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_AUTHORIZED WatchSyncDeviceAuthorizationStatus = 2 + WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_DENIED WatchSyncDeviceAuthorizationStatus = 3 + WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_EXPIRED WatchSyncDeviceAuthorizationStatus = 4 +) + +// Enum value maps for WatchSyncDeviceAuthorizationStatus. +var ( + WatchSyncDeviceAuthorizationStatus_name = map[int32]string{ + 0: "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED", + 1: "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_PENDING", + 2: "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_AUTHORIZED", + 3: "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_DENIED", + 4: "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_EXPIRED", + } + WatchSyncDeviceAuthorizationStatus_value = map[string]int32{ + "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED": 0, + "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_PENDING": 1, + "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_AUTHORIZED": 2, + "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_DENIED": 3, + "WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_EXPIRED": 4, + } +) + +func (x WatchSyncDeviceAuthorizationStatus) Enum() *WatchSyncDeviceAuthorizationStatus { + p := new(WatchSyncDeviceAuthorizationStatus) + *p = x + return p +} + +func (x WatchSyncDeviceAuthorizationStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WatchSyncDeviceAuthorizationStatus) Descriptor() protoreflect.EnumDescriptor { + return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[4].Descriptor() +} + +func (WatchSyncDeviceAuthorizationStatus) Type() protoreflect.EnumType { + return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[4] +} + +func (x WatchSyncDeviceAuthorizationStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WatchSyncDeviceAuthorizationStatus.Descriptor instead. +func (WatchSyncDeviceAuthorizationStatus) EnumDescriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{4} +} + type WatchSyncOrigin int32 const ( @@ -207,11 +341,11 @@ func (x WatchSyncOrigin) String() string { } func (WatchSyncOrigin) Descriptor() protoreflect.EnumDescriptor { - return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[3].Descriptor() + return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[5].Descriptor() } func (WatchSyncOrigin) Type() protoreflect.EnumType { - return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[3] + return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[5] } func (x WatchSyncOrigin) Number() protoreflect.EnumNumber { @@ -220,7 +354,7 @@ func (x WatchSyncOrigin) Number() protoreflect.EnumNumber { // Deprecated: Use WatchSyncOrigin.Descriptor instead. func (WatchSyncOrigin) EnumDescriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{3} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{5} } type WatchSyncApplyStatus int32 @@ -262,11 +396,11 @@ func (x WatchSyncApplyStatus) String() string { } func (WatchSyncApplyStatus) Descriptor() protoreflect.EnumDescriptor { - return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[4].Descriptor() + return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[6].Descriptor() } func (WatchSyncApplyStatus) Type() protoreflect.EnumType { - return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[4] + return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[6] } func (x WatchSyncApplyStatus) Number() protoreflect.EnumNumber { @@ -275,7 +409,7 @@ func (x WatchSyncApplyStatus) Number() protoreflect.EnumNumber { // Deprecated: Use WatchSyncApplyStatus.Descriptor instead. func (WatchSyncApplyStatus) EnumDescriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{4} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{6} } type WatchSyncFaultCode int32 @@ -323,11 +457,11 @@ func (x WatchSyncFaultCode) String() string { } func (WatchSyncFaultCode) Descriptor() protoreflect.EnumDescriptor { - return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[5].Descriptor() + return file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[7].Descriptor() } func (WatchSyncFaultCode) Type() protoreflect.EnumType { - return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[5] + return &file_silo_plugin_v1_watch_sync_provider_proto_enumTypes[7] } func (x WatchSyncFaultCode) Number() protoreflect.EnumNumber { @@ -336,7 +470,7 @@ func (x WatchSyncFaultCode) Number() protoreflect.EnumNumber { // Deprecated: Use WatchSyncFaultCode.Descriptor instead. func (WatchSyncFaultCode) EnumDescriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{5} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{7} } type WatchSyncProviderDescriptor struct { @@ -349,8 +483,19 @@ type WatchSyncProviderDescriptor struct { SupportedMediaTypes []WatchSyncMediaType `protobuf:"varint,6,rep,packed,name=supported_media_types,json=supportedMediaTypes,proto3,enum=silo.plugin.v1.WatchSyncMediaType" json:"supported_media_types,omitempty"` ExternalIdNamespaces []string `protobuf:"bytes,7,rep,name=external_id_namespaces,json=externalIdNamespaces,proto3" json:"external_id_namespaces,omitempty"` MaxBatchSize int32 `protobuf:"varint,8,opt,name=max_batch_size,json=maxBatchSize,proto3" json:"max_batch_size,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ImportFavorites bool `protobuf:"varint,9,opt,name=import_favorites,json=importFavorites,proto3" json:"import_favorites,omitempty"` + ExportFavorites bool `protobuf:"varint,10,opt,name=export_favorites,json=exportFavorites,proto3" json:"export_favorites,omitempty"` + RemoveFavorites bool `protobuf:"varint,11,opt,name=remove_favorites,json=removeFavorites,proto3" json:"remove_favorites,omitempty"` + ImportWatchlist bool `protobuf:"varint,12,opt,name=import_watchlist,json=importWatchlist,proto3" json:"import_watchlist,omitempty"` + ExportWatchlist bool `protobuf:"varint,13,opt,name=export_watchlist,json=exportWatchlist,proto3" json:"export_watchlist,omitempty"` + RemoveWatchlist bool `protobuf:"varint,14,opt,name=remove_watchlist,json=removeWatchlist,proto3" json:"remove_watchlist,omitempty"` + // The order of WATCHLIST states returned by ListRemoteState is meaningful. + // WATCHLIST traversals must set complete_snapshot=true when this is enabled; + // an incremental subset cannot define positions relative to omitted items. + ProvidesWatchlistOrder bool `protobuf:"varint,15,opt,name=provides_watchlist_order,json=providesWatchlistOrder,proto3" json:"provides_watchlist_order,omitempty"` + ScrobblePlayback bool `protobuf:"varint,16,opt,name=scrobble_playback,json=scrobblePlayback,proto3" json:"scrobble_playback,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WatchSyncProviderDescriptor) Reset() { @@ -439,6 +584,62 @@ func (x *WatchSyncProviderDescriptor) GetMaxBatchSize() int32 { return 0 } +func (x *WatchSyncProviderDescriptor) GetImportFavorites() bool { + if x != nil { + return x.ImportFavorites + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetExportFavorites() bool { + if x != nil { + return x.ExportFavorites + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetRemoveFavorites() bool { + if x != nil { + return x.RemoveFavorites + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetImportWatchlist() bool { + if x != nil { + return x.ImportWatchlist + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetExportWatchlist() bool { + if x != nil { + return x.ExportWatchlist + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetRemoveWatchlist() bool { + if x != nil { + return x.RemoveWatchlist + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetProvidesWatchlistOrder() bool { + if x != nil { + return x.ProvidesWatchlistOrder + } + return false +} + +func (x *WatchSyncProviderDescriptor) GetScrobblePlayback() bool { + if x != nil { + return x.ScrobblePlayback + } + return false +} + type WatchSyncProviderConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Values map[string]string `protobuf:"bytes,1,rep,name=values,proto3" json:"values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` @@ -1055,6 +1256,319 @@ func (x *WatchSyncExchangeAPIKeyRequest) GetApiKey() string { return "" } +type WatchSyncDeviceAuthorizationServiceStartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CapabilityId string `protobuf:"bytes,1,opt,name=capability_id,json=capabilityId,proto3" json:"capability_id,omitempty"` + ProviderConfig *WatchSyncProviderConfig `protobuf:"bytes,2,opt,name=provider_config,json=providerConfig,proto3" json:"provider_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSyncDeviceAuthorizationServiceStartRequest) Reset() { + *x = WatchSyncDeviceAuthorizationServiceStartRequest{} + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSyncDeviceAuthorizationServiceStartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSyncDeviceAuthorizationServiceStartRequest) ProtoMessage() {} + +func (x *WatchSyncDeviceAuthorizationServiceStartRequest) ProtoReflect() protoreflect.Message { + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchSyncDeviceAuthorizationServiceStartRequest.ProtoReflect.Descriptor instead. +func (*WatchSyncDeviceAuthorizationServiceStartRequest) Descriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{10} +} + +func (x *WatchSyncDeviceAuthorizationServiceStartRequest) GetCapabilityId() string { + if x != nil { + return x.CapabilityId + } + return "" +} + +func (x *WatchSyncDeviceAuthorizationServiceStartRequest) GetProviderConfig() *WatchSyncProviderConfig { + if x != nil { + return x.ProviderConfig + } + return nil +} + +type WatchSyncDeviceAuthorizationServiceStartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserCode string `protobuf:"bytes,1,opt,name=user_code,json=userCode,proto3" json:"user_code,omitempty"` + VerificationUrl string `protobuf:"bytes,2,opt,name=verification_url,json=verificationUrl,proto3" json:"verification_url,omitempty"` + VerificationUrlComplete string `protobuf:"bytes,3,opt,name=verification_url_complete,json=verificationUrlComplete,proto3" json:"verification_url_complete,omitempty"` + // Plugin-defined flow data. The host encrypts it until authorization + // completes and never returns it through the public HTTP API. + ProviderState []byte `protobuf:"bytes,4,opt,name=provider_state,json=providerState,proto3" json:"provider_state,omitempty"` + PollingInterval *durationpb.Duration `protobuf:"bytes,5,opt,name=polling_interval,json=pollingInterval,proto3" json:"polling_interval,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + Fault *WatchSyncFault `protobuf:"bytes,7,opt,name=fault,proto3" json:"fault,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) Reset() { + *x = WatchSyncDeviceAuthorizationServiceStartResponse{} + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSyncDeviceAuthorizationServiceStartResponse) ProtoMessage() {} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) ProtoReflect() protoreflect.Message { + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchSyncDeviceAuthorizationServiceStartResponse.ProtoReflect.Descriptor instead. +func (*WatchSyncDeviceAuthorizationServiceStartResponse) Descriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{11} +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetVerificationUrl() string { + if x != nil { + return x.VerificationUrl + } + return "" +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetVerificationUrlComplete() string { + if x != nil { + return x.VerificationUrlComplete + } + return "" +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetProviderState() []byte { + if x != nil { + return x.ProviderState + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetPollingInterval() *durationpb.Duration { + if x != nil { + return x.PollingInterval + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServiceStartResponse) GetFault() *WatchSyncFault { + if x != nil { + return x.Fault + } + return nil +} + +type WatchSyncDeviceAuthorizationServicePollRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CapabilityId string `protobuf:"bytes,1,opt,name=capability_id,json=capabilityId,proto3" json:"capability_id,omitempty"` + ProviderConfig *WatchSyncProviderConfig `protobuf:"bytes,2,opt,name=provider_config,json=providerConfig,proto3" json:"provider_config,omitempty"` + ProviderState []byte `protobuf:"bytes,3,opt,name=provider_state,json=providerState,proto3" json:"provider_state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSyncDeviceAuthorizationServicePollRequest) Reset() { + *x = WatchSyncDeviceAuthorizationServicePollRequest{} + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSyncDeviceAuthorizationServicePollRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSyncDeviceAuthorizationServicePollRequest) ProtoMessage() {} + +func (x *WatchSyncDeviceAuthorizationServicePollRequest) ProtoReflect() protoreflect.Message { + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchSyncDeviceAuthorizationServicePollRequest.ProtoReflect.Descriptor instead. +func (*WatchSyncDeviceAuthorizationServicePollRequest) Descriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{12} +} + +func (x *WatchSyncDeviceAuthorizationServicePollRequest) GetCapabilityId() string { + if x != nil { + return x.CapabilityId + } + return "" +} + +func (x *WatchSyncDeviceAuthorizationServicePollRequest) GetProviderConfig() *WatchSyncProviderConfig { + if x != nil { + return x.ProviderConfig + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServicePollRequest) GetProviderState() []byte { + if x != nil { + return x.ProviderState + } + return nil +} + +type WatchSyncDeviceAuthorizationServicePollResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status WatchSyncDeviceAuthorizationStatus `protobuf:"varint,1,opt,name=status,proto3,enum=silo.plugin.v1.WatchSyncDeviceAuthorizationStatus" json:"status,omitempty"` + // Complete authoritative replacement when status is AUTHORIZED. The host + // persists it before consuming account or fault. + Credentials *WatchSyncCredentials `protobuf:"bytes,2,opt,name=credentials,proto3" json:"credentials,omitempty"` + Account *WatchSyncAccount `protobuf:"bytes,3,opt,name=account,proto3" json:"account,omitempty"` + // PENDING normally omits fault. DENIED and EXPIRED may use a safe, + // non-retryable fault. Connection-wide and transport failures use fault. + Fault *WatchSyncFault `protobuf:"bytes,4,opt,name=fault,proto3" json:"fault,omitempty"` + // A PENDING response may update host-private state and timing only for the + // same user challenge. The user_code and verification URLs returned by Start + // must remain valid through expires_at; if those instructions change, expire + // this flow and require a new Start call. + // + // Optional authoritative replacement for provider_state while PENDING. The + // host encrypts and uses it for the next poll. Presence is significant: an + // explicitly empty value clears the prior state, while omission retains it. + ProviderState []byte `protobuf:"bytes,5,opt,name=provider_state,json=providerState,proto3,oneof" json:"provider_state,omitempty"` + // Optional replacement polling interval while PENDING, for example after an + // OAuth device-flow slow_down response. + PollingInterval *durationpb.Duration `protobuf:"bytes,6,opt,name=polling_interval,json=pollingInterval,proto3" json:"polling_interval,omitempty"` + // Optional replacement expiry for the same user challenge. + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) Reset() { + *x = WatchSyncDeviceAuthorizationServicePollResponse{} + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSyncDeviceAuthorizationServicePollResponse) ProtoMessage() {} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) ProtoReflect() protoreflect.Message { + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchSyncDeviceAuthorizationServicePollResponse.ProtoReflect.Descriptor instead. +func (*WatchSyncDeviceAuthorizationServicePollResponse) Descriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetStatus() WatchSyncDeviceAuthorizationStatus { + if x != nil { + return x.Status + } + return WatchSyncDeviceAuthorizationStatus_WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetCredentials() *WatchSyncCredentials { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetAccount() *WatchSyncAccount { + if x != nil { + return x.Account + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetFault() *WatchSyncFault { + if x != nil { + return x.Fault + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetProviderState() []byte { + if x != nil { + return x.ProviderState + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetPollingInterval() *durationpb.Duration { + if x != nil { + return x.PollingInterval + } + return nil +} + +func (x *WatchSyncDeviceAuthorizationServicePollResponse) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + type WatchSyncRefreshCredentialsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Context *WatchSyncAuthenticatedContext `protobuf:"bytes,1,opt,name=context,proto3" json:"context,omitempty"` @@ -1064,7 +1578,7 @@ type WatchSyncRefreshCredentialsRequest struct { func (x *WatchSyncRefreshCredentialsRequest) Reset() { *x = WatchSyncRefreshCredentialsRequest{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[10] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1076,7 +1590,7 @@ func (x *WatchSyncRefreshCredentialsRequest) String() string { func (*WatchSyncRefreshCredentialsRequest) ProtoMessage() {} func (x *WatchSyncRefreshCredentialsRequest) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[10] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1089,7 +1603,7 @@ func (x *WatchSyncRefreshCredentialsRequest) ProtoReflect() protoreflect.Message // Deprecated: Use WatchSyncRefreshCredentialsRequest.ProtoReflect.Descriptor instead. func (*WatchSyncRefreshCredentialsRequest) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{10} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{14} } func (x *WatchSyncRefreshCredentialsRequest) GetContext() *WatchSyncAuthenticatedContext { @@ -1114,7 +1628,7 @@ type WatchSyncCredentialResponse struct { func (x *WatchSyncCredentialResponse) Reset() { *x = WatchSyncCredentialResponse{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[11] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1126,7 +1640,7 @@ func (x *WatchSyncCredentialResponse) String() string { func (*WatchSyncCredentialResponse) ProtoMessage() {} func (x *WatchSyncCredentialResponse) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[11] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1139,7 +1653,7 @@ func (x *WatchSyncCredentialResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncCredentialResponse.ProtoReflect.Descriptor instead. func (*WatchSyncCredentialResponse) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{11} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{15} } func (x *WatchSyncCredentialResponse) GetCredentials() *WatchSyncCredentials { @@ -1172,7 +1686,7 @@ type WatchSyncGetAccountRequest struct { func (x *WatchSyncGetAccountRequest) Reset() { *x = WatchSyncGetAccountRequest{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[12] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1184,7 +1698,7 @@ func (x *WatchSyncGetAccountRequest) String() string { func (*WatchSyncGetAccountRequest) ProtoMessage() {} func (x *WatchSyncGetAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[12] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1197,7 +1711,7 @@ func (x *WatchSyncGetAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncGetAccountRequest.ProtoReflect.Descriptor instead. func (*WatchSyncGetAccountRequest) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{12} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{16} } func (x *WatchSyncGetAccountRequest) GetContext() *WatchSyncAuthenticatedContext { @@ -1217,7 +1731,7 @@ type WatchSyncGetAccountResponse struct { func (x *WatchSyncGetAccountResponse) Reset() { *x = WatchSyncGetAccountResponse{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[13] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1229,7 +1743,7 @@ func (x *WatchSyncGetAccountResponse) String() string { func (*WatchSyncGetAccountResponse) ProtoMessage() {} func (x *WatchSyncGetAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[13] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1242,7 +1756,7 @@ func (x *WatchSyncGetAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncGetAccountResponse.ProtoReflect.Descriptor instead. func (*WatchSyncGetAccountResponse) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{13} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{17} } func (x *WatchSyncGetAccountResponse) GetAccount() *WatchSyncAccount { @@ -1282,7 +1796,7 @@ type WatchSyncMedia struct { func (x *WatchSyncMedia) Reset() { *x = WatchSyncMedia{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[14] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1294,7 +1808,7 @@ func (x *WatchSyncMedia) String() string { func (*WatchSyncMedia) ProtoMessage() {} func (x *WatchSyncMedia) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[14] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1307,7 +1821,7 @@ func (x *WatchSyncMedia) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncMedia.ProtoReflect.Descriptor instead. func (*WatchSyncMedia) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{14} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{18} } func (x *WatchSyncMedia) GetMediaItemId() string { @@ -1429,13 +1943,20 @@ type WatchSyncEvent struct { DurationSeconds float64 `protobuf:"fixed64,9,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` CompletionPercent float64 `protobuf:"fixed64,10,opt,name=completion_percent,json=completionPercent,proto3" json:"completion_percent,omitempty"` PlayCount int32 `protobuf:"varint,11,opt,name=play_count,json=playCount,proto3" json:"play_count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional zero-based position for list providers that accept explicit + // ordering. Providers may ignore it unless their API supports order writes. + ListPosition *int32 `protobuf:"varint,12,opt,name=list_position,json=listPosition,proto3,oneof" json:"list_position,omitempty"` + // Provider-native key previously returned by ListRemoteState. It lets a + // removal address the exact upstream record when external IDs are absent or + // no longer resolve. + ProviderItemKey string `protobuf:"bytes,13,opt,name=provider_item_key,json=providerItemKey,proto3" json:"provider_item_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WatchSyncEvent) Reset() { *x = WatchSyncEvent{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[15] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1447,7 +1968,7 @@ func (x *WatchSyncEvent) String() string { func (*WatchSyncEvent) ProtoMessage() {} func (x *WatchSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[15] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1460,7 +1981,7 @@ func (x *WatchSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncEvent.ProtoReflect.Descriptor instead. func (*WatchSyncEvent) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{15} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{19} } func (x *WatchSyncEvent) GetEventId() string { @@ -1540,6 +2061,20 @@ func (x *WatchSyncEvent) GetPlayCount() int32 { return 0 } +func (x *WatchSyncEvent) GetListPosition() int32 { + if x != nil && x.ListPosition != nil { + return *x.ListPosition + } + return 0 +} + +func (x *WatchSyncEvent) GetProviderItemKey() string { + if x != nil { + return x.ProviderItemKey + } + return "" +} + type WatchSyncApplyEventsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Context *WatchSyncAuthenticatedContext `protobuf:"bytes,1,opt,name=context,proto3" json:"context,omitempty"` @@ -1552,7 +2087,7 @@ type WatchSyncApplyEventsRequest struct { func (x *WatchSyncApplyEventsRequest) Reset() { *x = WatchSyncApplyEventsRequest{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[16] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1564,7 +2099,7 @@ func (x *WatchSyncApplyEventsRequest) String() string { func (*WatchSyncApplyEventsRequest) ProtoMessage() {} func (x *WatchSyncApplyEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[16] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1577,7 +2112,7 @@ func (x *WatchSyncApplyEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncApplyEventsRequest.ProtoReflect.Descriptor instead. func (*WatchSyncApplyEventsRequest) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{16} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{20} } func (x *WatchSyncApplyEventsRequest) GetContext() *WatchSyncAuthenticatedContext { @@ -1609,7 +2144,7 @@ type WatchSyncApplyResult struct { func (x *WatchSyncApplyResult) Reset() { *x = WatchSyncApplyResult{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[17] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1621,7 +2156,7 @@ func (x *WatchSyncApplyResult) String() string { func (*WatchSyncApplyResult) ProtoMessage() {} func (x *WatchSyncApplyResult) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[17] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1634,7 +2169,7 @@ func (x *WatchSyncApplyResult) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncApplyResult.ProtoReflect.Descriptor instead. func (*WatchSyncApplyResult) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{17} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{21} } func (x *WatchSyncApplyResult) GetEventId() string { @@ -1673,7 +2208,7 @@ type WatchSyncApplyEventsResponse struct { func (x *WatchSyncApplyEventsResponse) Reset() { *x = WatchSyncApplyEventsResponse{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[18] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1685,7 +2220,7 @@ func (x *WatchSyncApplyEventsResponse) String() string { func (*WatchSyncApplyEventsResponse) ProtoMessage() {} func (x *WatchSyncApplyEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[18] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1698,7 +2233,7 @@ func (x *WatchSyncApplyEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncApplyEventsResponse.ProtoReflect.Descriptor instead. func (*WatchSyncApplyEventsResponse) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{18} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{22} } func (x *WatchSyncApplyEventsResponse) GetResults() []*WatchSyncApplyResult { @@ -1734,14 +2269,17 @@ type WatchSyncListRemoteStateRequest struct { // after the traversal completes or fails. PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` // Requested maximum number of items. Plugins may return fewer. - PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // State families requested by the host. Empty means every family advertised + // by the descriptor, preserving compatibility with v0.12 hosts and plugins. + StateKinds []WatchSyncRemoteStateKind `protobuf:"varint,5,rep,packed,name=state_kinds,json=stateKinds,proto3,enum=silo.plugin.v1.WatchSyncRemoteStateKind" json:"state_kinds,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *WatchSyncListRemoteStateRequest) Reset() { *x = WatchSyncListRemoteStateRequest{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[19] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1753,7 +2291,7 @@ func (x *WatchSyncListRemoteStateRequest) String() string { func (*WatchSyncListRemoteStateRequest) ProtoMessage() {} func (x *WatchSyncListRemoteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[19] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1766,7 +2304,7 @@ func (x *WatchSyncListRemoteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncListRemoteStateRequest.ProtoReflect.Descriptor instead. func (*WatchSyncListRemoteStateRequest) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{19} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{23} } func (x *WatchSyncListRemoteStateRequest) GetContext() *WatchSyncAuthenticatedContext { @@ -1797,6 +2335,13 @@ func (x *WatchSyncListRemoteStateRequest) GetPageSize() int32 { return 0 } +func (x *WatchSyncListRemoteStateRequest) GetStateKinds() []WatchSyncRemoteStateKind { + if x != nil { + return x.StateKinds + } + return nil +} + type WatchSyncRemoteWatchedState struct { state protoimpl.MessageState `protogen:"open.v1"` // Number of completed plays known to the provider. Must be at least one when @@ -1810,7 +2355,7 @@ type WatchSyncRemoteWatchedState struct { func (x *WatchSyncRemoteWatchedState) Reset() { *x = WatchSyncRemoteWatchedState{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[20] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1822,7 +2367,7 @@ func (x *WatchSyncRemoteWatchedState) String() string { func (*WatchSyncRemoteWatchedState) ProtoMessage() {} func (x *WatchSyncRemoteWatchedState) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[20] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1835,7 +2380,7 @@ func (x *WatchSyncRemoteWatchedState) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncRemoteWatchedState.ProtoReflect.Descriptor instead. func (*WatchSyncRemoteWatchedState) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{20} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{24} } func (x *WatchSyncRemoteWatchedState) GetPlayCount() int32 { @@ -1864,7 +2409,7 @@ type WatchSyncRemoteProgressState struct { func (x *WatchSyncRemoteProgressState) Reset() { *x = WatchSyncRemoteProgressState{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[21] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1876,7 +2421,7 @@ func (x *WatchSyncRemoteProgressState) String() string { func (*WatchSyncRemoteProgressState) ProtoMessage() {} func (x *WatchSyncRemoteProgressState) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[21] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1889,7 +2434,7 @@ func (x *WatchSyncRemoteProgressState) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncRemoteProgressState.ProtoReflect.Descriptor instead. func (*WatchSyncRemoteProgressState) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{21} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{25} } func (x *WatchSyncRemoteProgressState) GetProgressPercent() float64 { @@ -1906,21 +2451,80 @@ func (x *WatchSyncRemoteProgressState) GetPausedAt() *timestamppb.Timestamp { return nil } +type WatchSyncRemoteListState struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Provider timestamp for when the item was added, when available. + ListedAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=listed_at,json=listedAt,proto3" json:"listed_at,omitempty"` + // True is an explicit tombstone. It is meaningful for incremental + // traversals, where omitting an item is not a deletion. + Removed bool `protobuf:"varint,2,opt,name=removed,proto3" json:"removed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchSyncRemoteListState) Reset() { + *x = WatchSyncRemoteListState{} + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchSyncRemoteListState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchSyncRemoteListState) ProtoMessage() {} + +func (x *WatchSyncRemoteListState) ProtoReflect() protoreflect.Message { + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchSyncRemoteListState.ProtoReflect.Descriptor instead. +func (*WatchSyncRemoteListState) Descriptor() ([]byte, []int) { + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{26} +} + +func (x *WatchSyncRemoteListState) GetListedAt() *timestamppb.Timestamp { + if x != nil { + return x.ListedAt + } + return nil +} + +func (x *WatchSyncRemoteListState) GetRemoved() bool { + if x != nil { + return x.Removed + } + return false +} + type WatchSyncRemoteState struct { state protoimpl.MessageState `protogen:"open.v1"` ProviderItemKey string `protobuf:"bytes,1,opt,name=provider_item_key,json=providerItemKey,proto3" json:"provider_item_key,omitempty"` - Media *WatchSyncMedia `protobuf:"bytes,2,opt,name=media,proto3" json:"media,omitempty"` + // Required except on an explicit favorite/watchlist tombstone whose + // provider_item_key identifies a record returned previously. + Media *WatchSyncMedia `protobuf:"bytes,2,opt,name=media,proto3" json:"media,omitempty"` // At least one typed state must be present. Both may be present when the // provider reports a completed play and a separate resume point. Watched *WatchSyncRemoteWatchedState `protobuf:"bytes,3,opt,name=watched,proto3" json:"watched,omitempty"` Progress *WatchSyncRemoteProgressState `protobuf:"bytes,4,opt,name=progress,proto3" json:"progress,omitempty"` + Favorite *WatchSyncRemoteListState `protobuf:"bytes,5,opt,name=favorite,proto3" json:"favorite,omitempty"` + Watchlist *WatchSyncRemoteListState `protobuf:"bytes,6,opt,name=watchlist,proto3" json:"watchlist,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *WatchSyncRemoteState) Reset() { *x = WatchSyncRemoteState{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[22] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1932,7 +2536,7 @@ func (x *WatchSyncRemoteState) String() string { func (*WatchSyncRemoteState) ProtoMessage() {} func (x *WatchSyncRemoteState) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[22] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1945,7 +2549,7 @@ func (x *WatchSyncRemoteState) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncRemoteState.ProtoReflect.Descriptor instead. func (*WatchSyncRemoteState) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{22} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{27} } func (x *WatchSyncRemoteState) GetProviderItemKey() string { @@ -1976,6 +2580,20 @@ func (x *WatchSyncRemoteState) GetProgress() *WatchSyncRemoteProgressState { return nil } +func (x *WatchSyncRemoteState) GetFavorite() *WatchSyncRemoteListState { + if x != nil { + return x.Favorite + } + return nil +} + +func (x *WatchSyncRemoteState) GetWatchlist() *WatchSyncRemoteListState { + if x != nil { + return x.Watchlist + } + return nil +} + type WatchSyncListRemoteStateResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Items []*WatchSyncRemoteState `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` @@ -1987,7 +2605,9 @@ type WatchSyncListRemoteStateResponse struct { NextCursor string `protobuf:"bytes,3,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` // True when this traversal is an authoritative full snapshot. False means an // incremental delta, so an absent item must not be interpreted as deletion. - // The value must remain stable across every page in one traversal. + // The value must remain stable across every page in one traversal. A provider + // advertising provides_watchlist_order must set this true for WATCHLIST + // traversals so the returned order is unambiguous. CompleteSnapshot bool `protobuf:"varint,4,opt,name=complete_snapshot,json=completeSnapshot,proto3" json:"complete_snapshot,omitempty"` // Complete authoritative replacement. The host persists it before consuming // this page or fault; a persistence failure discards the page and cursor. @@ -2002,7 +2622,7 @@ type WatchSyncListRemoteStateResponse struct { func (x *WatchSyncListRemoteStateResponse) Reset() { *x = WatchSyncListRemoteStateResponse{} - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[23] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2014,7 +2634,7 @@ func (x *WatchSyncListRemoteStateResponse) String() string { func (*WatchSyncListRemoteStateResponse) ProtoMessage() {} func (x *WatchSyncListRemoteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[23] + mi := &file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2027,7 +2647,7 @@ func (x *WatchSyncListRemoteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchSyncListRemoteStateResponse.ProtoReflect.Descriptor instead. func (*WatchSyncListRemoteStateResponse) Descriptor() ([]byte, []int) { - return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{23} + return file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP(), []int{28} } func (x *WatchSyncListRemoteStateResponse) GetItems() []*WatchSyncRemoteState { @@ -2076,7 +2696,7 @@ var File_silo_plugin_v1_watch_sync_provider_proto protoreflect.FileDescriptor const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\n" + - "(silo/plugin/v1/watch_sync_provider.proto\x12\x0esilo.plugin.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xbb\x03\n" + + "(silo/plugin/v1/watch_sync_provider.proto\x12\x0esilo.plugin.v1\x1a\x1egoogle/protobuf/duration.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa4\x06\n" + "\x1bWatchSyncProviderDescriptor\x12F\n" + "\fauth_methods\x18\x01 \x03(\x0e2#.silo.plugin.v1.WatchSyncAuthMethodR\vauthMethods\x12%\n" + "\x0eexport_watched\x18\x02 \x01(\bR\rexportWatched\x12)\n" + @@ -2085,7 +2705,16 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\x0fimport_progress\x18\x05 \x01(\bR\x0eimportProgress\x12V\n" + "\x15supported_media_types\x18\x06 \x03(\x0e2\".silo.plugin.v1.WatchSyncMediaTypeR\x13supportedMediaTypes\x124\n" + "\x16external_id_namespaces\x18\a \x03(\tR\x14externalIdNamespaces\x12$\n" + - "\x0emax_batch_size\x18\b \x01(\x05R\fmaxBatchSize\"\xc2\x02\n" + + "\x0emax_batch_size\x18\b \x01(\x05R\fmaxBatchSize\x12)\n" + + "\x10import_favorites\x18\t \x01(\bR\x0fimportFavorites\x12)\n" + + "\x10export_favorites\x18\n" + + " \x01(\bR\x0fexportFavorites\x12)\n" + + "\x10remove_favorites\x18\v \x01(\bR\x0fremoveFavorites\x12)\n" + + "\x10import_watchlist\x18\f \x01(\bR\x0fimportWatchlist\x12)\n" + + "\x10export_watchlist\x18\r \x01(\bR\x0fexportWatchlist\x12)\n" + + "\x10remove_watchlist\x18\x0e \x01(\bR\x0fremoveWatchlist\x128\n" + + "\x18provides_watchlist_order\x18\x0f \x01(\bR\x16providesWatchlistOrder\x12+\n" + + "\x11scrobble_playback\x18\x10 \x01(\bR\x10scrobblePlayback\"\xc2\x02\n" + "\x17WatchSyncProviderConfig\x12K\n" + "\x06values\x18\x01 \x03(\v23.silo.plugin.v1.WatchSyncProviderConfig.ValuesEntryR\x06values\x12^\n" + "\rsecret_values\x18\x02 \x03(\v29.silo.plugin.v1.WatchSyncProviderConfig.SecretValuesEntryR\fsecretValues\x1a9\n" + @@ -2142,7 +2771,33 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\x1eWatchSyncExchangeAPIKeyRequest\x12#\n" + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12P\n" + "\x0fprovider_config\x18\x02 \x01(\v2'.silo.plugin.v1.WatchSyncProviderConfigR\x0eproviderConfig\x12\x17\n" + - "\aapi_key\x18\x03 \x01(\tR\x06apiKey\"m\n" + + "\aapi_key\x18\x03 \x01(\tR\x06apiKey\"\xa8\x01\n" + + "/WatchSyncDeviceAuthorizationServiceStartRequest\x12#\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12P\n" + + "\x0fprovider_config\x18\x02 \x01(\v2'.silo.plugin.v1.WatchSyncProviderConfigR\x0eproviderConfig\"\x94\x03\n" + + "0WatchSyncDeviceAuthorizationServiceStartResponse\x12\x1b\n" + + "\tuser_code\x18\x01 \x01(\tR\buserCode\x12)\n" + + "\x10verification_url\x18\x02 \x01(\tR\x0fverificationUrl\x12:\n" + + "\x19verification_url_complete\x18\x03 \x01(\tR\x17verificationUrlComplete\x12%\n" + + "\x0eprovider_state\x18\x04 \x01(\fR\rproviderState\x12D\n" + + "\x10polling_interval\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\x0fpollingInterval\x129\n" + + "\n" + + "expires_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x124\n" + + "\x05fault\x18\a \x01(\v2\x1e.silo.plugin.v1.WatchSyncFaultR\x05fault\"\xce\x01\n" + + ".WatchSyncDeviceAuthorizationServicePollRequest\x12#\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12P\n" + + "\x0fprovider_config\x18\x02 \x01(\v2'.silo.plugin.v1.WatchSyncProviderConfigR\x0eproviderConfig\x12%\n" + + "\x0eprovider_state\x18\x03 \x01(\fR\rproviderState\"\xf7\x03\n" + + "/WatchSyncDeviceAuthorizationServicePollResponse\x12J\n" + + "\x06status\x18\x01 \x01(\x0e22.silo.plugin.v1.WatchSyncDeviceAuthorizationStatusR\x06status\x12F\n" + + "\vcredentials\x18\x02 \x01(\v2$.silo.plugin.v1.WatchSyncCredentialsR\vcredentials\x12:\n" + + "\aaccount\x18\x03 \x01(\v2 .silo.plugin.v1.WatchSyncAccountR\aaccount\x124\n" + + "\x05fault\x18\x04 \x01(\v2\x1e.silo.plugin.v1.WatchSyncFaultR\x05fault\x12*\n" + + "\x0eprovider_state\x18\x05 \x01(\fH\x00R\rproviderState\x88\x01\x01\x12D\n" + + "\x10polling_interval\x18\x06 \x01(\v2\x19.google.protobuf.DurationR\x0fpollingInterval\x129\n" + + "\n" + + "expires_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAtB\x11\n" + + "\x0f_provider_state\"m\n" + "\"WatchSyncRefreshCredentialsRequest\x12G\n" + "\acontext\x18\x01 \x01(\v2-.silo.plugin.v1.WatchSyncAuthenticatedContextR\acontext\"\xd7\x01\n" + "\x1bWatchSyncCredentialResponse\x12F\n" + @@ -2178,7 +2833,7 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aD\n" + "\x16SeriesExternalIdsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x97\x04\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xff\x04\n" + "\x0eWatchSyncEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12@\n" + "\toperation\x18\x02 \x01(\x0e2\".silo.plugin.v1.WatchSyncOperationR\toperation\x127\n" + @@ -2193,7 +2848,10 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\x12completion_percent\x18\n" + " \x01(\x01R\x11completionPercent\x12\x1d\n" + "\n" + - "play_count\x18\v \x01(\x05R\tplayCount\"\x9e\x01\n" + + "play_count\x18\v \x01(\x05R\tplayCount\x12(\n" + + "\rlist_position\x18\f \x01(\x05H\x00R\flistPosition\x88\x01\x01\x12*\n" + + "\x11provider_item_key\x18\r \x01(\tR\x0fproviderItemKeyB\x10\n" + + "\x0e_list_position\"\x9e\x01\n" + "\x1bWatchSyncApplyEventsRequest\x12G\n" + "\acontext\x18\x01 \x01(\v2-.silo.plugin.v1.WatchSyncAuthenticatedContextR\acontext\x126\n" + "\x06events\x18\x02 \x03(\v2\x1e.silo.plugin.v1.WatchSyncEventR\x06events\"\xa5\x01\n" + @@ -2204,25 +2862,32 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\x1cWatchSyncApplyEventsResponse\x12>\n" + "\aresults\x18\x01 \x03(\v2$.silo.plugin.v1.WatchSyncApplyResultR\aresults\x12U\n" + "\x13updated_credentials\x18\x02 \x01(\v2$.silo.plugin.v1.WatchSyncCredentialsR\x12updatedCredentials\x124\n" + - "\x05fault\x18\x03 \x01(\v2\x1e.silo.plugin.v1.WatchSyncFaultR\x05fault\"\xbe\x01\n" + + "\x05fault\x18\x03 \x01(\v2\x1e.silo.plugin.v1.WatchSyncFaultR\x05fault\"\x89\x02\n" + "\x1fWatchSyncListRemoteStateRequest\x12G\n" + "\acontext\x18\x01 \x01(\v2-.silo.plugin.v1.WatchSyncAuthenticatedContextR\acontext\x12\x16\n" + "\x06cursor\x18\x02 \x01(\tR\x06cursor\x12\x1d\n" + "\n" + "page_token\x18\x03 \x01(\tR\tpageToken\x12\x1b\n" + - "\tpage_size\x18\x04 \x01(\x05R\bpageSize\"\x80\x01\n" + + "\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12I\n" + + "\vstate_kinds\x18\x05 \x03(\x0e2(.silo.plugin.v1.WatchSyncRemoteStateKindR\n" + + "stateKinds\"\x80\x01\n" + "\x1bWatchSyncRemoteWatchedState\x12\x1d\n" + "\n" + "play_count\x18\x01 \x01(\x05R\tplayCount\x12B\n" + "\x0flast_watched_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\rlastWatchedAt\"\x82\x01\n" + "\x1cWatchSyncRemoteProgressState\x12)\n" + "\x10progress_percent\x18\x01 \x01(\x01R\x0fprogressPercent\x127\n" + - "\tpaused_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\bpausedAt\"\x89\x02\n" + + "\tpaused_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\bpausedAt\"m\n" + + "\x18WatchSyncRemoteListState\x127\n" + + "\tlisted_at\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\blistedAt\x12\x18\n" + + "\aremoved\x18\x02 \x01(\bR\aremoved\"\x97\x03\n" + "\x14WatchSyncRemoteState\x12*\n" + "\x11provider_item_key\x18\x01 \x01(\tR\x0fproviderItemKey\x124\n" + "\x05media\x18\x02 \x01(\v2\x1e.silo.plugin.v1.WatchSyncMediaR\x05media\x12E\n" + "\awatched\x18\x03 \x01(\v2+.silo.plugin.v1.WatchSyncRemoteWatchedStateR\awatched\x12H\n" + - "\bprogress\x18\x04 \x01(\v2,.silo.plugin.v1.WatchSyncRemoteProgressStateR\bprogress\"\xe1\x02\n" + + "\bprogress\x18\x04 \x01(\v2,.silo.plugin.v1.WatchSyncRemoteProgressStateR\bprogress\x12D\n" + + "\bfavorite\x18\x05 \x01(\v2(.silo.plugin.v1.WatchSyncRemoteListStateR\bfavorite\x12F\n" + + "\twatchlist\x18\x06 \x01(\v2(.silo.plugin.v1.WatchSyncRemoteListStateR\twatchlist\"\xe1\x02\n" + " WatchSyncListRemoteStateResponse\x12:\n" + "\x05items\x18\x01 \x03(\v2$.silo.plugin.v1.WatchSyncRemoteStateR\x05items\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12\x1f\n" + @@ -2230,19 +2895,39 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "nextCursor\x12+\n" + "\x11complete_snapshot\x18\x04 \x01(\bR\x10completeSnapshot\x12U\n" + "\x13updated_credentials\x18\x05 \x01(\v2$.silo.plugin.v1.WatchSyncCredentialsR\x12updatedCredentials\x124\n" + - "\x05fault\x18\x06 \x01(\v2\x1e.silo.plugin.v1.WatchSyncFaultR\x05fault*\x90\x01\n" + + "\x05fault\x18\x06 \x01(\v2\x1e.silo.plugin.v1.WatchSyncFaultR\x05fault*\xb8\x01\n" + "\x13WatchSyncAuthMethod\x12&\n" + "\"WATCH_SYNC_AUTH_METHOD_UNSPECIFIED\x10\x00\x12-\n" + ")WATCH_SYNC_AUTH_METHOD_AUTHORIZATION_CODE\x10\x01\x12\"\n" + - "\x1eWATCH_SYNC_AUTH_METHOD_API_KEY\x10\x02*\x7f\n" + + "\x1eWATCH_SYNC_AUTH_METHOD_API_KEY\x10\x02\x12&\n" + + "\"WATCH_SYNC_AUTH_METHOD_DEVICE_CODE\x10\x03*\x7f\n" + "\x12WatchSyncMediaType\x12%\n" + "!WATCH_SYNC_MEDIA_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n" + "\x1bWATCH_SYNC_MEDIA_TYPE_MOVIE\x10\x01\x12!\n" + - "\x1dWATCH_SYNC_MEDIA_TYPE_EPISODE\x10\x02*\x8a\x01\n" + + "\x1dWATCH_SYNC_MEDIA_TYPE_EPISODE\x10\x02*\xb0\x03\n" + "\x12WatchSyncOperation\x12$\n" + " WATCH_SYNC_OPERATION_UNSPECIFIED\x10\x00\x12%\n" + "!WATCH_SYNC_OPERATION_MARK_WATCHED\x10\x01\x12'\n" + - "#WATCH_SYNC_OPERATION_MARK_UNWATCHED\x10\x02*\xa3\x01\n" + + "#WATCH_SYNC_OPERATION_MARK_UNWATCHED\x10\x02\x12%\n" + + "!WATCH_SYNC_OPERATION_ADD_FAVORITE\x10\x03\x12(\n" + + "$WATCH_SYNC_OPERATION_REMOVE_FAVORITE\x10\x04\x12)\n" + + "%WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST\x10\x05\x12.\n" + + "*WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST\x10\x06\x12'\n" + + "#WATCH_SYNC_OPERATION_SCROBBLE_START\x10\a\x12'\n" + + "#WATCH_SYNC_OPERATION_SCROBBLE_PAUSE\x10\b\x12&\n" + + "\"WATCH_SYNC_OPERATION_SCROBBLE_STOP\x10\t*\xf4\x01\n" + + "\x18WatchSyncRemoteStateKind\x12,\n" + + "(WATCH_SYNC_REMOTE_STATE_KIND_UNSPECIFIED\x10\x00\x12(\n" + + "$WATCH_SYNC_REMOTE_STATE_KIND_WATCHED\x10\x01\x12)\n" + + "%WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS\x10\x02\x12)\n" + + "%WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE\x10\x03\x12*\n" + + "&WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST\x10\x04*\xae\x02\n" + + "\"WatchSyncDeviceAuthorizationStatus\x126\n" + + "2WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED\x10\x00\x122\n" + + ".WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_PENDING\x10\x01\x125\n" + + "1WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_AUTHORIZED\x10\x02\x121\n" + + "-WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_DENIED\x10\x03\x122\n" + + ".WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_EXPIRED\x10\x04*\xa3\x01\n" + "\x0fWatchSyncOrigin\x12!\n" + "\x1dWATCH_SYNC_ORIGIN_UNSPECIFIED\x10\x00\x12)\n" + "%WATCH_SYNC_ORIGIN_PLAYBACK_COMPLETION\x10\x01\x12\x1c\n" + @@ -2270,7 +2955,10 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\n" + "GetAccount\x12*.silo.plugin.v1.WatchSyncGetAccountRequest\x1a+.silo.plugin.v1.WatchSyncGetAccountResponse\x12h\n" + "\vApplyEvents\x12+.silo.plugin.v1.WatchSyncApplyEventsRequest\x1a,.silo.plugin.v1.WatchSyncApplyEventsResponse\x12t\n" + - "\x0fListRemoteState\x12/.silo.plugin.v1.WatchSyncListRemoteStateRequest\x1a0.silo.plugin.v1.WatchSyncListRemoteStateResponseBPZNgithub.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1;pluginv1b\x06proto3" + "\x0fListRemoteState\x12/.silo.plugin.v1.WatchSyncListRemoteStateRequest\x1a0.silo.plugin.v1.WatchSyncListRemoteStateResponse2\xbc\x02\n" + + "#WatchSyncDeviceAuthorizationService\x12\x8a\x01\n" + + "\x05Start\x12?.silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartRequest\x1a@.silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartResponse\x12\x87\x01\n" + + "\x04Poll\x12>.silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollRequest\x1a?.silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponseBPZNgithub.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1;pluginv1b\x06proto3" var ( file_silo_plugin_v1_watch_sync_provider_proto_rawDescOnce sync.Once @@ -2284,113 +2972,139 @@ func file_silo_plugin_v1_watch_sync_provider_proto_rawDescGZIP() []byte { return file_silo_plugin_v1_watch_sync_provider_proto_rawDescData } -var file_silo_plugin_v1_watch_sync_provider_proto_enumTypes = make([]protoimpl.EnumInfo, 6) -var file_silo_plugin_v1_watch_sync_provider_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_silo_plugin_v1_watch_sync_provider_proto_enumTypes = make([]protoimpl.EnumInfo, 8) +var file_silo_plugin_v1_watch_sync_provider_proto_msgTypes = make([]protoimpl.MessageInfo, 34) var file_silo_plugin_v1_watch_sync_provider_proto_goTypes = []any{ - (WatchSyncAuthMethod)(0), // 0: silo.plugin.v1.WatchSyncAuthMethod - (WatchSyncMediaType)(0), // 1: silo.plugin.v1.WatchSyncMediaType - (WatchSyncOperation)(0), // 2: silo.plugin.v1.WatchSyncOperation - (WatchSyncOrigin)(0), // 3: silo.plugin.v1.WatchSyncOrigin - (WatchSyncApplyStatus)(0), // 4: silo.plugin.v1.WatchSyncApplyStatus - (WatchSyncFaultCode)(0), // 5: silo.plugin.v1.WatchSyncFaultCode - (*WatchSyncProviderDescriptor)(nil), // 6: silo.plugin.v1.WatchSyncProviderDescriptor - (*WatchSyncProviderConfig)(nil), // 7: silo.plugin.v1.WatchSyncProviderConfig - (*WatchSyncCredentials)(nil), // 8: silo.plugin.v1.WatchSyncCredentials - (*WatchSyncAuthenticatedContext)(nil), // 9: silo.plugin.v1.WatchSyncAuthenticatedContext - (*WatchSyncAccount)(nil), // 10: silo.plugin.v1.WatchSyncAccount - (*WatchSyncFault)(nil), // 11: silo.plugin.v1.WatchSyncFault - (*WatchSyncInitAuthorizeRequest)(nil), // 12: silo.plugin.v1.WatchSyncInitAuthorizeRequest - (*WatchSyncInitAuthorizeResponse)(nil), // 13: silo.plugin.v1.WatchSyncInitAuthorizeResponse - (*WatchSyncExchangeCodeRequest)(nil), // 14: silo.plugin.v1.WatchSyncExchangeCodeRequest - (*WatchSyncExchangeAPIKeyRequest)(nil), // 15: silo.plugin.v1.WatchSyncExchangeAPIKeyRequest - (*WatchSyncRefreshCredentialsRequest)(nil), // 16: silo.plugin.v1.WatchSyncRefreshCredentialsRequest - (*WatchSyncCredentialResponse)(nil), // 17: silo.plugin.v1.WatchSyncCredentialResponse - (*WatchSyncGetAccountRequest)(nil), // 18: silo.plugin.v1.WatchSyncGetAccountRequest - (*WatchSyncGetAccountResponse)(nil), // 19: silo.plugin.v1.WatchSyncGetAccountResponse - (*WatchSyncMedia)(nil), // 20: silo.plugin.v1.WatchSyncMedia - (*WatchSyncEvent)(nil), // 21: silo.plugin.v1.WatchSyncEvent - (*WatchSyncApplyEventsRequest)(nil), // 22: silo.plugin.v1.WatchSyncApplyEventsRequest - (*WatchSyncApplyResult)(nil), // 23: silo.plugin.v1.WatchSyncApplyResult - (*WatchSyncApplyEventsResponse)(nil), // 24: silo.plugin.v1.WatchSyncApplyEventsResponse - (*WatchSyncListRemoteStateRequest)(nil), // 25: silo.plugin.v1.WatchSyncListRemoteStateRequest - (*WatchSyncRemoteWatchedState)(nil), // 26: silo.plugin.v1.WatchSyncRemoteWatchedState - (*WatchSyncRemoteProgressState)(nil), // 27: silo.plugin.v1.WatchSyncRemoteProgressState - (*WatchSyncRemoteState)(nil), // 28: silo.plugin.v1.WatchSyncRemoteState - (*WatchSyncListRemoteStateResponse)(nil), // 29: silo.plugin.v1.WatchSyncListRemoteStateResponse - nil, // 30: silo.plugin.v1.WatchSyncProviderConfig.ValuesEntry - nil, // 31: silo.plugin.v1.WatchSyncProviderConfig.SecretValuesEntry - nil, // 32: silo.plugin.v1.WatchSyncCredentials.SecretAttributesEntry - nil, // 33: silo.plugin.v1.WatchSyncMedia.ExternalIdsEntry - nil, // 34: silo.plugin.v1.WatchSyncMedia.SeriesExternalIdsEntry - (*timestamppb.Timestamp)(nil), // 35: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 36: google.protobuf.Duration - (*structpb.Struct)(nil), // 37: google.protobuf.Struct + (WatchSyncAuthMethod)(0), // 0: silo.plugin.v1.WatchSyncAuthMethod + (WatchSyncMediaType)(0), // 1: silo.plugin.v1.WatchSyncMediaType + (WatchSyncOperation)(0), // 2: silo.plugin.v1.WatchSyncOperation + (WatchSyncRemoteStateKind)(0), // 3: silo.plugin.v1.WatchSyncRemoteStateKind + (WatchSyncDeviceAuthorizationStatus)(0), // 4: silo.plugin.v1.WatchSyncDeviceAuthorizationStatus + (WatchSyncOrigin)(0), // 5: silo.plugin.v1.WatchSyncOrigin + (WatchSyncApplyStatus)(0), // 6: silo.plugin.v1.WatchSyncApplyStatus + (WatchSyncFaultCode)(0), // 7: silo.plugin.v1.WatchSyncFaultCode + (*WatchSyncProviderDescriptor)(nil), // 8: silo.plugin.v1.WatchSyncProviderDescriptor + (*WatchSyncProviderConfig)(nil), // 9: silo.plugin.v1.WatchSyncProviderConfig + (*WatchSyncCredentials)(nil), // 10: silo.plugin.v1.WatchSyncCredentials + (*WatchSyncAuthenticatedContext)(nil), // 11: silo.plugin.v1.WatchSyncAuthenticatedContext + (*WatchSyncAccount)(nil), // 12: silo.plugin.v1.WatchSyncAccount + (*WatchSyncFault)(nil), // 13: silo.plugin.v1.WatchSyncFault + (*WatchSyncInitAuthorizeRequest)(nil), // 14: silo.plugin.v1.WatchSyncInitAuthorizeRequest + (*WatchSyncInitAuthorizeResponse)(nil), // 15: silo.plugin.v1.WatchSyncInitAuthorizeResponse + (*WatchSyncExchangeCodeRequest)(nil), // 16: silo.plugin.v1.WatchSyncExchangeCodeRequest + (*WatchSyncExchangeAPIKeyRequest)(nil), // 17: silo.plugin.v1.WatchSyncExchangeAPIKeyRequest + (*WatchSyncDeviceAuthorizationServiceStartRequest)(nil), // 18: silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartRequest + (*WatchSyncDeviceAuthorizationServiceStartResponse)(nil), // 19: silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartResponse + (*WatchSyncDeviceAuthorizationServicePollRequest)(nil), // 20: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollRequest + (*WatchSyncDeviceAuthorizationServicePollResponse)(nil), // 21: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse + (*WatchSyncRefreshCredentialsRequest)(nil), // 22: silo.plugin.v1.WatchSyncRefreshCredentialsRequest + (*WatchSyncCredentialResponse)(nil), // 23: silo.plugin.v1.WatchSyncCredentialResponse + (*WatchSyncGetAccountRequest)(nil), // 24: silo.plugin.v1.WatchSyncGetAccountRequest + (*WatchSyncGetAccountResponse)(nil), // 25: silo.plugin.v1.WatchSyncGetAccountResponse + (*WatchSyncMedia)(nil), // 26: silo.plugin.v1.WatchSyncMedia + (*WatchSyncEvent)(nil), // 27: silo.plugin.v1.WatchSyncEvent + (*WatchSyncApplyEventsRequest)(nil), // 28: silo.plugin.v1.WatchSyncApplyEventsRequest + (*WatchSyncApplyResult)(nil), // 29: silo.plugin.v1.WatchSyncApplyResult + (*WatchSyncApplyEventsResponse)(nil), // 30: silo.plugin.v1.WatchSyncApplyEventsResponse + (*WatchSyncListRemoteStateRequest)(nil), // 31: silo.plugin.v1.WatchSyncListRemoteStateRequest + (*WatchSyncRemoteWatchedState)(nil), // 32: silo.plugin.v1.WatchSyncRemoteWatchedState + (*WatchSyncRemoteProgressState)(nil), // 33: silo.plugin.v1.WatchSyncRemoteProgressState + (*WatchSyncRemoteListState)(nil), // 34: silo.plugin.v1.WatchSyncRemoteListState + (*WatchSyncRemoteState)(nil), // 35: silo.plugin.v1.WatchSyncRemoteState + (*WatchSyncListRemoteStateResponse)(nil), // 36: silo.plugin.v1.WatchSyncListRemoteStateResponse + nil, // 37: silo.plugin.v1.WatchSyncProviderConfig.ValuesEntry + nil, // 38: silo.plugin.v1.WatchSyncProviderConfig.SecretValuesEntry + nil, // 39: silo.plugin.v1.WatchSyncCredentials.SecretAttributesEntry + nil, // 40: silo.plugin.v1.WatchSyncMedia.ExternalIdsEntry + nil, // 41: silo.plugin.v1.WatchSyncMedia.SeriesExternalIdsEntry + (*timestamppb.Timestamp)(nil), // 42: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 43: google.protobuf.Duration + (*structpb.Struct)(nil), // 44: google.protobuf.Struct } var file_silo_plugin_v1_watch_sync_provider_proto_depIdxs = []int32{ 0, // 0: silo.plugin.v1.WatchSyncProviderDescriptor.auth_methods:type_name -> silo.plugin.v1.WatchSyncAuthMethod 1, // 1: silo.plugin.v1.WatchSyncProviderDescriptor.supported_media_types:type_name -> silo.plugin.v1.WatchSyncMediaType - 30, // 2: silo.plugin.v1.WatchSyncProviderConfig.values:type_name -> silo.plugin.v1.WatchSyncProviderConfig.ValuesEntry - 31, // 3: silo.plugin.v1.WatchSyncProviderConfig.secret_values:type_name -> silo.plugin.v1.WatchSyncProviderConfig.SecretValuesEntry - 35, // 4: silo.plugin.v1.WatchSyncCredentials.expires_at:type_name -> google.protobuf.Timestamp - 32, // 5: silo.plugin.v1.WatchSyncCredentials.secret_attributes:type_name -> silo.plugin.v1.WatchSyncCredentials.SecretAttributesEntry - 7, // 6: silo.plugin.v1.WatchSyncAuthenticatedContext.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig - 8, // 7: silo.plugin.v1.WatchSyncAuthenticatedContext.credentials:type_name -> silo.plugin.v1.WatchSyncCredentials - 5, // 8: silo.plugin.v1.WatchSyncFault.code:type_name -> silo.plugin.v1.WatchSyncFaultCode - 36, // 9: silo.plugin.v1.WatchSyncFault.retry_after:type_name -> google.protobuf.Duration - 7, // 10: silo.plugin.v1.WatchSyncInitAuthorizeRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig - 11, // 11: silo.plugin.v1.WatchSyncInitAuthorizeResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault - 7, // 12: silo.plugin.v1.WatchSyncExchangeCodeRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig - 7, // 13: silo.plugin.v1.WatchSyncExchangeAPIKeyRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig - 9, // 14: silo.plugin.v1.WatchSyncRefreshCredentialsRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext - 8, // 15: silo.plugin.v1.WatchSyncCredentialResponse.credentials:type_name -> silo.plugin.v1.WatchSyncCredentials - 10, // 16: silo.plugin.v1.WatchSyncCredentialResponse.account:type_name -> silo.plugin.v1.WatchSyncAccount - 11, // 17: silo.plugin.v1.WatchSyncCredentialResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault - 9, // 18: silo.plugin.v1.WatchSyncGetAccountRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext - 10, // 19: silo.plugin.v1.WatchSyncGetAccountResponse.account:type_name -> silo.plugin.v1.WatchSyncAccount - 11, // 20: silo.plugin.v1.WatchSyncGetAccountResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault - 1, // 21: silo.plugin.v1.WatchSyncMedia.media_type:type_name -> silo.plugin.v1.WatchSyncMediaType - 33, // 22: silo.plugin.v1.WatchSyncMedia.external_ids:type_name -> silo.plugin.v1.WatchSyncMedia.ExternalIdsEntry - 34, // 23: silo.plugin.v1.WatchSyncMedia.series_external_ids:type_name -> silo.plugin.v1.WatchSyncMedia.SeriesExternalIdsEntry - 37, // 24: silo.plugin.v1.WatchSyncMedia.metadata:type_name -> google.protobuf.Struct - 2, // 25: silo.plugin.v1.WatchSyncEvent.operation:type_name -> silo.plugin.v1.WatchSyncOperation - 3, // 26: silo.plugin.v1.WatchSyncEvent.origin:type_name -> silo.plugin.v1.WatchSyncOrigin - 35, // 27: silo.plugin.v1.WatchSyncEvent.occurred_at:type_name -> google.protobuf.Timestamp - 20, // 28: silo.plugin.v1.WatchSyncEvent.media:type_name -> silo.plugin.v1.WatchSyncMedia - 9, // 29: silo.plugin.v1.WatchSyncApplyEventsRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext - 21, // 30: silo.plugin.v1.WatchSyncApplyEventsRequest.events:type_name -> silo.plugin.v1.WatchSyncEvent - 4, // 31: silo.plugin.v1.WatchSyncApplyResult.status:type_name -> silo.plugin.v1.WatchSyncApplyStatus - 11, // 32: silo.plugin.v1.WatchSyncApplyResult.fault:type_name -> silo.plugin.v1.WatchSyncFault - 23, // 33: silo.plugin.v1.WatchSyncApplyEventsResponse.results:type_name -> silo.plugin.v1.WatchSyncApplyResult - 8, // 34: silo.plugin.v1.WatchSyncApplyEventsResponse.updated_credentials:type_name -> silo.plugin.v1.WatchSyncCredentials - 11, // 35: silo.plugin.v1.WatchSyncApplyEventsResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault - 9, // 36: silo.plugin.v1.WatchSyncListRemoteStateRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext - 35, // 37: silo.plugin.v1.WatchSyncRemoteWatchedState.last_watched_at:type_name -> google.protobuf.Timestamp - 35, // 38: silo.plugin.v1.WatchSyncRemoteProgressState.paused_at:type_name -> google.protobuf.Timestamp - 20, // 39: silo.plugin.v1.WatchSyncRemoteState.media:type_name -> silo.plugin.v1.WatchSyncMedia - 26, // 40: silo.plugin.v1.WatchSyncRemoteState.watched:type_name -> silo.plugin.v1.WatchSyncRemoteWatchedState - 27, // 41: silo.plugin.v1.WatchSyncRemoteState.progress:type_name -> silo.plugin.v1.WatchSyncRemoteProgressState - 28, // 42: silo.plugin.v1.WatchSyncListRemoteStateResponse.items:type_name -> silo.plugin.v1.WatchSyncRemoteState - 8, // 43: silo.plugin.v1.WatchSyncListRemoteStateResponse.updated_credentials:type_name -> silo.plugin.v1.WatchSyncCredentials - 11, // 44: silo.plugin.v1.WatchSyncListRemoteStateResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault - 12, // 45: silo.plugin.v1.WatchSyncProvider.InitAuthorize:input_type -> silo.plugin.v1.WatchSyncInitAuthorizeRequest - 14, // 46: silo.plugin.v1.WatchSyncProvider.ExchangeCode:input_type -> silo.plugin.v1.WatchSyncExchangeCodeRequest - 15, // 47: silo.plugin.v1.WatchSyncProvider.ExchangeAPIKey:input_type -> silo.plugin.v1.WatchSyncExchangeAPIKeyRequest - 16, // 48: silo.plugin.v1.WatchSyncProvider.RefreshCredentials:input_type -> silo.plugin.v1.WatchSyncRefreshCredentialsRequest - 18, // 49: silo.plugin.v1.WatchSyncProvider.GetAccount:input_type -> silo.plugin.v1.WatchSyncGetAccountRequest - 22, // 50: silo.plugin.v1.WatchSyncProvider.ApplyEvents:input_type -> silo.plugin.v1.WatchSyncApplyEventsRequest - 25, // 51: silo.plugin.v1.WatchSyncProvider.ListRemoteState:input_type -> silo.plugin.v1.WatchSyncListRemoteStateRequest - 13, // 52: silo.plugin.v1.WatchSyncProvider.InitAuthorize:output_type -> silo.plugin.v1.WatchSyncInitAuthorizeResponse - 17, // 53: silo.plugin.v1.WatchSyncProvider.ExchangeCode:output_type -> silo.plugin.v1.WatchSyncCredentialResponse - 17, // 54: silo.plugin.v1.WatchSyncProvider.ExchangeAPIKey:output_type -> silo.plugin.v1.WatchSyncCredentialResponse - 17, // 55: silo.plugin.v1.WatchSyncProvider.RefreshCredentials:output_type -> silo.plugin.v1.WatchSyncCredentialResponse - 19, // 56: silo.plugin.v1.WatchSyncProvider.GetAccount:output_type -> silo.plugin.v1.WatchSyncGetAccountResponse - 24, // 57: silo.plugin.v1.WatchSyncProvider.ApplyEvents:output_type -> silo.plugin.v1.WatchSyncApplyEventsResponse - 29, // 58: silo.plugin.v1.WatchSyncProvider.ListRemoteState:output_type -> silo.plugin.v1.WatchSyncListRemoteStateResponse - 52, // [52:59] is the sub-list for method output_type - 45, // [45:52] is the sub-list for method input_type - 45, // [45:45] is the sub-list for extension type_name - 45, // [45:45] is the sub-list for extension extendee - 0, // [0:45] is the sub-list for field type_name + 37, // 2: silo.plugin.v1.WatchSyncProviderConfig.values:type_name -> silo.plugin.v1.WatchSyncProviderConfig.ValuesEntry + 38, // 3: silo.plugin.v1.WatchSyncProviderConfig.secret_values:type_name -> silo.plugin.v1.WatchSyncProviderConfig.SecretValuesEntry + 42, // 4: silo.plugin.v1.WatchSyncCredentials.expires_at:type_name -> google.protobuf.Timestamp + 39, // 5: silo.plugin.v1.WatchSyncCredentials.secret_attributes:type_name -> silo.plugin.v1.WatchSyncCredentials.SecretAttributesEntry + 9, // 6: silo.plugin.v1.WatchSyncAuthenticatedContext.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig + 10, // 7: silo.plugin.v1.WatchSyncAuthenticatedContext.credentials:type_name -> silo.plugin.v1.WatchSyncCredentials + 7, // 8: silo.plugin.v1.WatchSyncFault.code:type_name -> silo.plugin.v1.WatchSyncFaultCode + 43, // 9: silo.plugin.v1.WatchSyncFault.retry_after:type_name -> google.protobuf.Duration + 9, // 10: silo.plugin.v1.WatchSyncInitAuthorizeRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig + 13, // 11: silo.plugin.v1.WatchSyncInitAuthorizeResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 9, // 12: silo.plugin.v1.WatchSyncExchangeCodeRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig + 9, // 13: silo.plugin.v1.WatchSyncExchangeAPIKeyRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig + 9, // 14: silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig + 43, // 15: silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartResponse.polling_interval:type_name -> google.protobuf.Duration + 42, // 16: silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartResponse.expires_at:type_name -> google.protobuf.Timestamp + 13, // 17: silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 9, // 18: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollRequest.provider_config:type_name -> silo.plugin.v1.WatchSyncProviderConfig + 4, // 19: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse.status:type_name -> silo.plugin.v1.WatchSyncDeviceAuthorizationStatus + 10, // 20: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse.credentials:type_name -> silo.plugin.v1.WatchSyncCredentials + 12, // 21: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse.account:type_name -> silo.plugin.v1.WatchSyncAccount + 13, // 22: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 43, // 23: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse.polling_interval:type_name -> google.protobuf.Duration + 42, // 24: silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse.expires_at:type_name -> google.protobuf.Timestamp + 11, // 25: silo.plugin.v1.WatchSyncRefreshCredentialsRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext + 10, // 26: silo.plugin.v1.WatchSyncCredentialResponse.credentials:type_name -> silo.plugin.v1.WatchSyncCredentials + 12, // 27: silo.plugin.v1.WatchSyncCredentialResponse.account:type_name -> silo.plugin.v1.WatchSyncAccount + 13, // 28: silo.plugin.v1.WatchSyncCredentialResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 11, // 29: silo.plugin.v1.WatchSyncGetAccountRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext + 12, // 30: silo.plugin.v1.WatchSyncGetAccountResponse.account:type_name -> silo.plugin.v1.WatchSyncAccount + 13, // 31: silo.plugin.v1.WatchSyncGetAccountResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 1, // 32: silo.plugin.v1.WatchSyncMedia.media_type:type_name -> silo.plugin.v1.WatchSyncMediaType + 40, // 33: silo.plugin.v1.WatchSyncMedia.external_ids:type_name -> silo.plugin.v1.WatchSyncMedia.ExternalIdsEntry + 41, // 34: silo.plugin.v1.WatchSyncMedia.series_external_ids:type_name -> silo.plugin.v1.WatchSyncMedia.SeriesExternalIdsEntry + 44, // 35: silo.plugin.v1.WatchSyncMedia.metadata:type_name -> google.protobuf.Struct + 2, // 36: silo.plugin.v1.WatchSyncEvent.operation:type_name -> silo.plugin.v1.WatchSyncOperation + 5, // 37: silo.plugin.v1.WatchSyncEvent.origin:type_name -> silo.plugin.v1.WatchSyncOrigin + 42, // 38: silo.plugin.v1.WatchSyncEvent.occurred_at:type_name -> google.protobuf.Timestamp + 26, // 39: silo.plugin.v1.WatchSyncEvent.media:type_name -> silo.plugin.v1.WatchSyncMedia + 11, // 40: silo.plugin.v1.WatchSyncApplyEventsRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext + 27, // 41: silo.plugin.v1.WatchSyncApplyEventsRequest.events:type_name -> silo.plugin.v1.WatchSyncEvent + 6, // 42: silo.plugin.v1.WatchSyncApplyResult.status:type_name -> silo.plugin.v1.WatchSyncApplyStatus + 13, // 43: silo.plugin.v1.WatchSyncApplyResult.fault:type_name -> silo.plugin.v1.WatchSyncFault + 29, // 44: silo.plugin.v1.WatchSyncApplyEventsResponse.results:type_name -> silo.plugin.v1.WatchSyncApplyResult + 10, // 45: silo.plugin.v1.WatchSyncApplyEventsResponse.updated_credentials:type_name -> silo.plugin.v1.WatchSyncCredentials + 13, // 46: silo.plugin.v1.WatchSyncApplyEventsResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 11, // 47: silo.plugin.v1.WatchSyncListRemoteStateRequest.context:type_name -> silo.plugin.v1.WatchSyncAuthenticatedContext + 3, // 48: silo.plugin.v1.WatchSyncListRemoteStateRequest.state_kinds:type_name -> silo.plugin.v1.WatchSyncRemoteStateKind + 42, // 49: silo.plugin.v1.WatchSyncRemoteWatchedState.last_watched_at:type_name -> google.protobuf.Timestamp + 42, // 50: silo.plugin.v1.WatchSyncRemoteProgressState.paused_at:type_name -> google.protobuf.Timestamp + 42, // 51: silo.plugin.v1.WatchSyncRemoteListState.listed_at:type_name -> google.protobuf.Timestamp + 26, // 52: silo.plugin.v1.WatchSyncRemoteState.media:type_name -> silo.plugin.v1.WatchSyncMedia + 32, // 53: silo.plugin.v1.WatchSyncRemoteState.watched:type_name -> silo.plugin.v1.WatchSyncRemoteWatchedState + 33, // 54: silo.plugin.v1.WatchSyncRemoteState.progress:type_name -> silo.plugin.v1.WatchSyncRemoteProgressState + 34, // 55: silo.plugin.v1.WatchSyncRemoteState.favorite:type_name -> silo.plugin.v1.WatchSyncRemoteListState + 34, // 56: silo.plugin.v1.WatchSyncRemoteState.watchlist:type_name -> silo.plugin.v1.WatchSyncRemoteListState + 35, // 57: silo.plugin.v1.WatchSyncListRemoteStateResponse.items:type_name -> silo.plugin.v1.WatchSyncRemoteState + 10, // 58: silo.plugin.v1.WatchSyncListRemoteStateResponse.updated_credentials:type_name -> silo.plugin.v1.WatchSyncCredentials + 13, // 59: silo.plugin.v1.WatchSyncListRemoteStateResponse.fault:type_name -> silo.plugin.v1.WatchSyncFault + 14, // 60: silo.plugin.v1.WatchSyncProvider.InitAuthorize:input_type -> silo.plugin.v1.WatchSyncInitAuthorizeRequest + 16, // 61: silo.plugin.v1.WatchSyncProvider.ExchangeCode:input_type -> silo.plugin.v1.WatchSyncExchangeCodeRequest + 17, // 62: silo.plugin.v1.WatchSyncProvider.ExchangeAPIKey:input_type -> silo.plugin.v1.WatchSyncExchangeAPIKeyRequest + 22, // 63: silo.plugin.v1.WatchSyncProvider.RefreshCredentials:input_type -> silo.plugin.v1.WatchSyncRefreshCredentialsRequest + 24, // 64: silo.plugin.v1.WatchSyncProvider.GetAccount:input_type -> silo.plugin.v1.WatchSyncGetAccountRequest + 28, // 65: silo.plugin.v1.WatchSyncProvider.ApplyEvents:input_type -> silo.plugin.v1.WatchSyncApplyEventsRequest + 31, // 66: silo.plugin.v1.WatchSyncProvider.ListRemoteState:input_type -> silo.plugin.v1.WatchSyncListRemoteStateRequest + 18, // 67: silo.plugin.v1.WatchSyncDeviceAuthorizationService.Start:input_type -> silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartRequest + 20, // 68: silo.plugin.v1.WatchSyncDeviceAuthorizationService.Poll:input_type -> silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollRequest + 15, // 69: silo.plugin.v1.WatchSyncProvider.InitAuthorize:output_type -> silo.plugin.v1.WatchSyncInitAuthorizeResponse + 23, // 70: silo.plugin.v1.WatchSyncProvider.ExchangeCode:output_type -> silo.plugin.v1.WatchSyncCredentialResponse + 23, // 71: silo.plugin.v1.WatchSyncProvider.ExchangeAPIKey:output_type -> silo.plugin.v1.WatchSyncCredentialResponse + 23, // 72: silo.plugin.v1.WatchSyncProvider.RefreshCredentials:output_type -> silo.plugin.v1.WatchSyncCredentialResponse + 25, // 73: silo.plugin.v1.WatchSyncProvider.GetAccount:output_type -> silo.plugin.v1.WatchSyncGetAccountResponse + 30, // 74: silo.plugin.v1.WatchSyncProvider.ApplyEvents:output_type -> silo.plugin.v1.WatchSyncApplyEventsResponse + 36, // 75: silo.plugin.v1.WatchSyncProvider.ListRemoteState:output_type -> silo.plugin.v1.WatchSyncListRemoteStateResponse + 19, // 76: silo.plugin.v1.WatchSyncDeviceAuthorizationService.Start:output_type -> silo.plugin.v1.WatchSyncDeviceAuthorizationServiceStartResponse + 21, // 77: silo.plugin.v1.WatchSyncDeviceAuthorizationService.Poll:output_type -> silo.plugin.v1.WatchSyncDeviceAuthorizationServicePollResponse + 69, // [69:78] is the sub-list for method output_type + 60, // [60:69] is the sub-list for method input_type + 60, // [60:60] is the sub-list for extension type_name + 60, // [60:60] is the sub-list for extension extendee + 0, // [0:60] is the sub-list for field type_name } func init() { file_silo_plugin_v1_watch_sync_provider_proto_init() } @@ -2398,15 +3112,17 @@ func file_silo_plugin_v1_watch_sync_provider_proto_init() { if File_silo_plugin_v1_watch_sync_provider_proto != nil { return } + file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[13].OneofWrappers = []any{} + file_silo_plugin_v1_watch_sync_provider_proto_msgTypes[19].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_silo_plugin_v1_watch_sync_provider_proto_rawDesc), len(file_silo_plugin_v1_watch_sync_provider_proto_rawDesc)), - NumEnums: 6, - NumMessages: 29, + NumEnums: 8, + NumMessages: 34, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_silo_plugin_v1_watch_sync_provider_proto_goTypes, DependencyIndexes: file_silo_plugin_v1_watch_sync_provider_proto_depIdxs, diff --git a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_compat_test.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_compat_test.go new file mode 100644 index 0000000..e415a1e --- /dev/null +++ b/pkg/pluginproto/silo/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/silo/plugin/v1/watch_sync_provider_grpc.pb.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_grpc.pb.go index 4257841..c5fbd31 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_grpc.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_grpc.pb.go @@ -355,3 +355,149 @@ var WatchSyncProvider_ServiceDesc = grpc.ServiceDesc{ Streams: []grpc.StreamDesc{}, Metadata: "silo/plugin/v1/watch_sync_provider.proto", } + +const ( + WatchSyncDeviceAuthorizationService_Start_FullMethodName = "/silo.plugin.v1.WatchSyncDeviceAuthorizationService/Start" + WatchSyncDeviceAuthorizationService_Poll_FullMethodName = "/silo.plugin.v1.WatchSyncDeviceAuthorizationService/Poll" +) + +// WatchSyncDeviceAuthorizationServiceClient is the client API for WatchSyncDeviceAuthorizationService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// WatchSyncDeviceAuthorizationService is separate from WatchSyncProvider so adding +// device authorization does not add methods to the released v0.12 Go server +// interface. A plugin advertising DEVICE_CODE registers both services. +type WatchSyncDeviceAuthorizationServiceClient interface { + Start(ctx context.Context, in *WatchSyncDeviceAuthorizationServiceStartRequest, opts ...grpc.CallOption) (*WatchSyncDeviceAuthorizationServiceStartResponse, error) + Poll(ctx context.Context, in *WatchSyncDeviceAuthorizationServicePollRequest, opts ...grpc.CallOption) (*WatchSyncDeviceAuthorizationServicePollResponse, error) +} + +type watchSyncDeviceAuthorizationServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewWatchSyncDeviceAuthorizationServiceClient(cc grpc.ClientConnInterface) WatchSyncDeviceAuthorizationServiceClient { + return &watchSyncDeviceAuthorizationServiceClient{cc} +} + +func (c *watchSyncDeviceAuthorizationServiceClient) Start(ctx context.Context, in *WatchSyncDeviceAuthorizationServiceStartRequest, opts ...grpc.CallOption) (*WatchSyncDeviceAuthorizationServiceStartResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WatchSyncDeviceAuthorizationServiceStartResponse) + err := c.cc.Invoke(ctx, WatchSyncDeviceAuthorizationService_Start_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *watchSyncDeviceAuthorizationServiceClient) Poll(ctx context.Context, in *WatchSyncDeviceAuthorizationServicePollRequest, opts ...grpc.CallOption) (*WatchSyncDeviceAuthorizationServicePollResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(WatchSyncDeviceAuthorizationServicePollResponse) + err := c.cc.Invoke(ctx, WatchSyncDeviceAuthorizationService_Poll_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// WatchSyncDeviceAuthorizationServiceServer is the server API for WatchSyncDeviceAuthorizationService service. +// All implementations should embed UnimplementedWatchSyncDeviceAuthorizationServiceServer +// for forward compatibility. +// +// WatchSyncDeviceAuthorizationService is separate from WatchSyncProvider so adding +// device authorization does not add methods to the released v0.12 Go server +// interface. A plugin advertising DEVICE_CODE registers both services. +type WatchSyncDeviceAuthorizationServiceServer interface { + Start(context.Context, *WatchSyncDeviceAuthorizationServiceStartRequest) (*WatchSyncDeviceAuthorizationServiceStartResponse, error) + Poll(context.Context, *WatchSyncDeviceAuthorizationServicePollRequest) (*WatchSyncDeviceAuthorizationServicePollResponse, error) +} + +// UnimplementedWatchSyncDeviceAuthorizationServiceServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedWatchSyncDeviceAuthorizationServiceServer struct{} + +func (UnimplementedWatchSyncDeviceAuthorizationServiceServer) Start(context.Context, *WatchSyncDeviceAuthorizationServiceStartRequest) (*WatchSyncDeviceAuthorizationServiceStartResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Start not implemented") +} +func (UnimplementedWatchSyncDeviceAuthorizationServiceServer) Poll(context.Context, *WatchSyncDeviceAuthorizationServicePollRequest) (*WatchSyncDeviceAuthorizationServicePollResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Poll not implemented") +} +func (UnimplementedWatchSyncDeviceAuthorizationServiceServer) testEmbeddedByValue() {} + +// UnsafeWatchSyncDeviceAuthorizationServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to WatchSyncDeviceAuthorizationServiceServer will +// result in compilation errors. +type UnsafeWatchSyncDeviceAuthorizationServiceServer interface { + mustEmbedUnimplementedWatchSyncDeviceAuthorizationServiceServer() +} + +func RegisterWatchSyncDeviceAuthorizationServiceServer(s grpc.ServiceRegistrar, srv WatchSyncDeviceAuthorizationServiceServer) { + // If the following call panics, it indicates UnimplementedWatchSyncDeviceAuthorizationServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&WatchSyncDeviceAuthorizationService_ServiceDesc, srv) +} + +func _WatchSyncDeviceAuthorizationService_Start_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WatchSyncDeviceAuthorizationServiceStartRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WatchSyncDeviceAuthorizationServiceServer).Start(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WatchSyncDeviceAuthorizationService_Start_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WatchSyncDeviceAuthorizationServiceServer).Start(ctx, req.(*WatchSyncDeviceAuthorizationServiceStartRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _WatchSyncDeviceAuthorizationService_Poll_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WatchSyncDeviceAuthorizationServicePollRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WatchSyncDeviceAuthorizationServiceServer).Poll(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WatchSyncDeviceAuthorizationService_Poll_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WatchSyncDeviceAuthorizationServiceServer).Poll(ctx, req.(*WatchSyncDeviceAuthorizationServicePollRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// WatchSyncDeviceAuthorizationService_ServiceDesc is the grpc.ServiceDesc for WatchSyncDeviceAuthorizationService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var WatchSyncDeviceAuthorizationService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "silo.plugin.v1.WatchSyncDeviceAuthorizationService", + HandlerType: (*WatchSyncDeviceAuthorizationServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Start", + Handler: _WatchSyncDeviceAuthorizationService_Start_Handler, + }, + { + MethodName: "Poll", + Handler: _WatchSyncDeviceAuthorizationService_Poll_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "silo/plugin/v1/watch_sync_provider.proto", +} diff --git a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go index dff73a3..51e4fba 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go +++ b/pkg/pluginproto/silo/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,135 @@ 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) + withZero := &WatchSyncEvent{ListPosition: &zero} + data, err := proto.Marshal(withZero) + if err != nil { + t.Fatal(err) + } + var output WatchSyncEvent + if err := proto.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if output.ListPosition == nil || output.GetListPosition() != 0 { + t.Fatalf("explicit zero list position = %#v", output.ListPosition) + } + omittedData, err := proto.Marshal(&WatchSyncEvent{}) + if err != nil { + t.Fatal(err) + } + var omittedOutput WatchSyncEvent + if err := proto.Unmarshal(omittedData, &omittedOutput); err != nil { + t.Fatal(err) + } + if omittedOutput.ListPosition != nil { + t.Fatal("omitted list position unexpectedly has presence") + } +} + +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{ diff --git a/pkg/pluginsdk/convert/watch_sync_provider_test.go b/pkg/pluginsdk/convert/watch_sync_provider_test.go index e554dc4..9c607b9 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/manifest/manifest.go b/pkg/pluginsdk/manifest/manifest.go index a5bd670..f6de2c0 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 97f93b7..115b992 100644 --- a/pkg/pluginsdk/manifest/watch_sync_provider_test.go +++ b/pkg/pluginsdk/manifest/watch_sync_provider_test.go @@ -87,6 +87,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: "silo.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/pkg/pluginsdk/runtime/runtime.go b/pkg/pluginsdk/runtime/runtime.go index 4bd1285..09d18b8 100644 --- a/pkg/pluginsdk/runtime/runtime.go +++ b/pkg/pluginsdk/runtime/runtime.go @@ -70,6 +70,24 @@ func DefaultPluginSet(servers CapabilityServers) plugin.PluginSet { } } +// DefaultPluginSetWithWatchSyncDeviceAuthorization returns the default plugin +// set plus the separate device-authorization service. Keeping that server out +// of CapabilityServers preserves the released v0.12 unkeyed struct shape. +func DefaultPluginSetWithWatchSyncDeviceAuthorization( + servers CapabilityServers, + deviceAuthorization pluginv1.WatchSyncDeviceAuthorizationServiceServer, +) plugin.PluginSet { + if deviceAuthorization == nil { + return DefaultPluginSet(servers) + } + return plugin.PluginSet{ + PluginSetName: &grpcPluginWithWatchSyncDeviceAuthorization{ + GRPCPlugin: &GRPCPlugin{Servers: servers}, + deviceAuthorization: deviceAuthorization, + }, + } +} + func NewClient(conn *grpc.ClientConn) *Client { return &Client{conn: conn} } @@ -133,11 +151,31 @@ func (c *Client) WatchSyncProvider() pluginv1.WatchSyncProviderClient { return pluginv1.NewWatchSyncProviderClient(c.conn) } +func (c *Client) WatchSyncDeviceAuthorization() pluginv1.WatchSyncDeviceAuthorizationServiceClient { + return pluginv1.NewWatchSyncDeviceAuthorizationServiceClient(c.conn) +} + type GRPCPlugin struct { plugin.Plugin Servers CapabilityServers } +type grpcPluginWithWatchSyncDeviceAuthorization struct { + *GRPCPlugin + deviceAuthorization pluginv1.WatchSyncDeviceAuthorizationServiceServer +} + +func (p *grpcPluginWithWatchSyncDeviceAuthorization) GRPCServer( + broker *plugin.GRPCBroker, + server *grpc.Server, +) error { + if err := p.GRPCPlugin.GRPCServer(broker, server); err != nil { + return err + } + pluginv1.RegisterWatchSyncDeviceAuthorizationServiceServer(server, p.deviceAuthorization) + return nil +} + func (p *GRPCPlugin) GRPCServer(broker *plugin.GRPCBroker, server *grpc.Server) error { pluginHost.setBroker(broker) if p.Servers.Runtime == nil { diff --git a/pkg/pluginsdk/runtime/serve_manifest.go b/pkg/pluginsdk/runtime/serve_manifest.go index 4d4b6bb..8b7dbdc 100644 --- a/pkg/pluginsdk/runtime/serve_manifest.go +++ b/pkg/pluginsdk/runtime/serve_manifest.go @@ -19,6 +19,24 @@ type manifestRuntime struct { manifest *pluginv1.PluginManifest } +type serveManifestOptions struct { + watchSyncDeviceAuthorization pluginv1.WatchSyncDeviceAuthorizationServiceServer +} + +// ServeManifestOption adds a service without changing the released +// CapabilityServers or ServeConfig struct layouts. +type ServeManifestOption func(*serveManifestOptions) + +// WithWatchSyncDeviceAuthorization registers the separate device-code service +// for a watch-sync provider that advertises DEVICE_CODE authentication. +func WithWatchSyncDeviceAuthorization( + server pluginv1.WatchSyncDeviceAuthorizationServiceServer, +) ServeManifestOption { + return func(options *serveManifestOptions) { + options.watchSyncDeviceAuthorization = server + } +} + func (s *manifestRuntime) GetManifest(context.Context, *pluginv1.GetManifestRequest) (*pluginv1.GetManifestResponse, error) { return &pluginv1.GetManifestResponse{Manifest: s.manifest}, nil } @@ -37,10 +55,33 @@ func (s *manifestRuntime) BindHostBroker(_ context.Context, req *pluginv1.BindHo // servers (the caller supplies only the non-Runtime servers). It never returns; // a fatal manifest error panics, matching a misbuilt plugin's old main(). func ServeManifest(manifestBytes []byte, version string, servers CapabilityServers) { + ServeManifestWithOptions(manifestBytes, version, servers) +} + +// ServeManifestWithOptions is the additive form of ServeManifest for services +// introduced after the released CapabilityServers shape. +func ServeManifestWithOptions( + manifestBytes []byte, + version string, + servers CapabilityServers, + options ...ServeManifestOption, +) { m, err := manifest.LoadWithChecksum(manifestBytes, version) if err != nil { panic(err) } servers.Runtime = &manifestRuntime{manifest: m} - Serve(ServeConfig{Servers: servers}) + var resolved serveManifestOptions + for _, option := range options { + if option != nil { + option(&resolved) + } + } + Serve(ServeConfig{ + Servers: servers, + Plugins: DefaultPluginSetWithWatchSyncDeviceAuthorization( + servers, + resolved.watchSyncDeviceAuthorization, + ), + }) } diff --git a/pkg/pluginsdk/runtime/watch_sync_provider_test.go b/pkg/pluginsdk/runtime/watch_sync_provider_test.go index 4b5d408..d96ba34 100644 --- a/pkg/pluginsdk/runtime/watch_sync_provider_test.go +++ b/pkg/pluginsdk/runtime/watch_sync_provider_test.go @@ -5,6 +5,7 @@ import ( pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" runtime "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/runtime" + "github.com/hashicorp/go-plugin" "google.golang.org/grpc" ) @@ -12,16 +13,27 @@ type stubWatchSyncProvider struct { pluginv1.UnimplementedWatchSyncProviderServer } -func TestGRPCServerRegistersWatchSyncProvider(t *testing.T) { - p := &runtime.GRPCPlugin{Servers: runtime.CapabilityServers{ +type stubWatchSyncDeviceAuthorization struct { + pluginv1.UnimplementedWatchSyncDeviceAuthorizationServiceServer +} + +func TestGRPCServerRegistersWatchSyncServices(t *testing.T) { + plugins := runtime.DefaultPluginSetWithWatchSyncDeviceAuthorization(runtime.CapabilityServers{ Runtime: stubRuntime{}, WatchSyncProvider: stubWatchSyncProvider{}, - }} + }, stubWatchSyncDeviceAuthorization{}) + p, ok := plugins[runtime.PluginSetName].(plugin.GRPCPlugin) + if !ok { + t.Fatalf("watch-sync plugin type = %T, want plugin.GRPCPlugin", plugins[runtime.PluginSetName]) + } srv := grpc.NewServer() if err := p.GRPCServer(nil, srv); err != nil { - t.Fatalf("GRPCServer with WatchSyncProvider = %v, want nil", err) + t.Fatalf("GRPCServer with watch-sync services = %v, want nil", err) } if _, ok := srv.GetServiceInfo()["silo.plugin.v1.WatchSyncProvider"]; !ok { t.Fatalf("WatchSyncProvider service not registered; got %v", srv.GetServiceInfo()) } + if _, ok := srv.GetServiceInfo()["silo.plugin.v1.WatchSyncDeviceAuthorizationService"]; !ok { + t.Fatalf("WatchSyncDeviceAuthorization service not registered; got %v", srv.GetServiceInfo()) + } } diff --git a/proto/silo/plugin/v1/watch_sync_provider.proto b/proto/silo/plugin/v1/watch_sync_provider.proto index eb959d2..c76b717 100644 --- a/proto/silo/plugin/v1/watch_sync_provider.proto +++ b/proto/silo/plugin/v1/watch_sync_provider.proto @@ -22,10 +22,19 @@ service WatchSyncProvider { rpc ListRemoteState(WatchSyncListRemoteStateRequest) returns (WatchSyncListRemoteStateResponse); } +// WatchSyncDeviceAuthorizationService is separate from WatchSyncProvider so adding +// device authorization does not add methods to the released v0.12 Go server +// interface. A plugin advertising DEVICE_CODE registers both services. +service WatchSyncDeviceAuthorizationService { + rpc Start(WatchSyncDeviceAuthorizationServiceStartRequest) returns (WatchSyncDeviceAuthorizationServiceStartResponse); + rpc Poll(WatchSyncDeviceAuthorizationServicePollRequest) returns (WatchSyncDeviceAuthorizationServicePollResponse); +} + enum WatchSyncAuthMethod { WATCH_SYNC_AUTH_METHOD_UNSPECIFIED = 0; WATCH_SYNC_AUTH_METHOD_AUTHORIZATION_CODE = 1; WATCH_SYNC_AUTH_METHOD_API_KEY = 2; + WATCH_SYNC_AUTH_METHOD_DEVICE_CODE = 3; } enum WatchSyncMediaType { @@ -38,6 +47,29 @@ enum WatchSyncOperation { WATCH_SYNC_OPERATION_UNSPECIFIED = 0; WATCH_SYNC_OPERATION_MARK_WATCHED = 1; WATCH_SYNC_OPERATION_MARK_UNWATCHED = 2; + WATCH_SYNC_OPERATION_ADD_FAVORITE = 3; + WATCH_SYNC_OPERATION_REMOVE_FAVORITE = 4; + WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST = 5; + WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST = 6; + WATCH_SYNC_OPERATION_SCROBBLE_START = 7; + WATCH_SYNC_OPERATION_SCROBBLE_PAUSE = 8; + WATCH_SYNC_OPERATION_SCROBBLE_STOP = 9; +} + +enum WatchSyncRemoteStateKind { + WATCH_SYNC_REMOTE_STATE_KIND_UNSPECIFIED = 0; + WATCH_SYNC_REMOTE_STATE_KIND_WATCHED = 1; + WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS = 2; + WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE = 3; + WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST = 4; +} + +enum WatchSyncDeviceAuthorizationStatus { + WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_UNSPECIFIED = 0; + WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_PENDING = 1; + WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_AUTHORIZED = 2; + WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_DENIED = 3; + WATCH_SYNC_DEVICE_AUTHORIZATION_STATUS_EXPIRED = 4; } enum WatchSyncOrigin { @@ -74,6 +106,17 @@ message WatchSyncProviderDescriptor { repeated WatchSyncMediaType supported_media_types = 6; repeated string external_id_namespaces = 7; int32 max_batch_size = 8; + bool import_favorites = 9; + bool export_favorites = 10; + bool remove_favorites = 11; + bool import_watchlist = 12; + bool export_watchlist = 13; + bool remove_watchlist = 14; + // The order of WATCHLIST states returned by ListRemoteState is meaningful. + // WATCHLIST traversals must set complete_snapshot=true when this is enabled; + // an incremental subset cannot define positions relative to omitted items. + bool provides_watchlist_order = 15; + bool scrobble_playback = 16; } message WatchSyncProviderConfig { @@ -157,6 +200,54 @@ message WatchSyncExchangeAPIKeyRequest { string api_key = 3; } +message WatchSyncDeviceAuthorizationServiceStartRequest { + string capability_id = 1; + WatchSyncProviderConfig provider_config = 2; +} + +message WatchSyncDeviceAuthorizationServiceStartResponse { + string user_code = 1; + string verification_url = 2; + string verification_url_complete = 3; + // Plugin-defined flow data. The host encrypts it until authorization + // completes and never returns it through the public HTTP API. + bytes provider_state = 4; + google.protobuf.Duration polling_interval = 5; + google.protobuf.Timestamp expires_at = 6; + WatchSyncFault fault = 7; +} + +message WatchSyncDeviceAuthorizationServicePollRequest { + string capability_id = 1; + WatchSyncProviderConfig provider_config = 2; + bytes provider_state = 3; +} + +message WatchSyncDeviceAuthorizationServicePollResponse { + WatchSyncDeviceAuthorizationStatus status = 1; + // Complete authoritative replacement when status is AUTHORIZED. The host + // persists it before consuming account or fault. + WatchSyncCredentials credentials = 2; + WatchSyncAccount account = 3; + // PENDING normally omits fault. DENIED and EXPIRED may use a safe, + // non-retryable fault. Connection-wide and transport failures use fault. + WatchSyncFault fault = 4; + // A PENDING response may update host-private state and timing only for the + // same user challenge. The user_code and verification URLs returned by Start + // must remain valid through expires_at; if those instructions change, expire + // this flow and require a new Start call. + // + // Optional authoritative replacement for provider_state while PENDING. The + // host encrypts and uses it for the next poll. Presence is significant: an + // explicitly empty value clears the prior state, while omission retains it. + optional bytes provider_state = 5; + // Optional replacement polling interval while PENDING, for example after an + // OAuth device-flow slow_down response. + google.protobuf.Duration polling_interval = 6; + // Optional replacement expiry for the same user challenge. + google.protobuf.Timestamp expires_at = 7; +} + message WatchSyncRefreshCredentialsRequest { WatchSyncAuthenticatedContext context = 1; } @@ -211,6 +302,13 @@ message WatchSyncEvent { double duration_seconds = 9; double completion_percent = 10; int32 play_count = 11; + // Optional zero-based position for list providers that accept explicit + // ordering. Providers may ignore it unless their API supports order writes. + optional int32 list_position = 12; + // Provider-native key previously returned by ListRemoteState. It lets a + // removal address the exact upstream record when external IDs are absent or + // no longer resolve. + string provider_item_key = 13; } message WatchSyncApplyEventsRequest { @@ -252,6 +350,9 @@ message WatchSyncListRemoteStateRequest { string page_token = 3; // Requested maximum number of items. Plugins may return fewer. int32 page_size = 4; + // State families requested by the host. Empty means every family advertised + // by the descriptor, preserving compatibility with v0.12 hosts and plugins. + repeated WatchSyncRemoteStateKind state_kinds = 5; } message WatchSyncRemoteWatchedState { @@ -269,13 +370,25 @@ message WatchSyncRemoteProgressState { google.protobuf.Timestamp paused_at = 2; } +message WatchSyncRemoteListState { + // Provider timestamp for when the item was added, when available. + google.protobuf.Timestamp listed_at = 1; + // True is an explicit tombstone. It is meaningful for incremental + // traversals, where omitting an item is not a deletion. + bool removed = 2; +} + message WatchSyncRemoteState { string provider_item_key = 1; + // Required except on an explicit favorite/watchlist tombstone whose + // provider_item_key identifies a record returned previously. WatchSyncMedia media = 2; // At least one typed state must be present. Both may be present when the // provider reports a completed play and a separate resume point. WatchSyncRemoteWatchedState watched = 3; WatchSyncRemoteProgressState progress = 4; + WatchSyncRemoteListState favorite = 5; + WatchSyncRemoteListState watchlist = 6; } message WatchSyncListRemoteStateResponse { @@ -288,7 +401,9 @@ message WatchSyncListRemoteStateResponse { string next_cursor = 3; // True when this traversal is an authoritative full snapshot. False means an // incremental delta, so an absent item must not be interpreted as deletion. - // The value must remain stable across every page in one traversal. + // The value must remain stable across every page in one traversal. A provider + // advertising provides_watchlist_order must set this true for WATCHLIST + // traversals so the returned order is unambiguous. bool complete_snapshot = 4; // Complete authoritative replacement. The host persists it before consuming // this page or fault; a persistence failure discards the page and cursor. From 214cfe5ca131462a6d33245825f5a224b43cbc2d Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:41:05 -0400 Subject: [PATCH 2/8] fix(convert): preserve capability config forms (#14) * fix(convert): preserve capability config forms * fix(convert): preserve config metadata defaults --- pkg/pluginsdk/convert/convert.go | 53 +++++++++++++-------------- pkg/pluginsdk/convert/convert_test.go | 27 +++++++++++++- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/pkg/pluginsdk/convert/convert.go b/pkg/pluginsdk/convert/convert.go index a05658a..65fa2f0 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 762283f..b1719f4 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/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/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) } From 1ad0fe54408e99d35e6aee86c489a0edd528f6b2 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:58:49 -0400 Subject: [PATCH 3/8] feat(watchsync): expose authoritative completion state (#15) --- README.md | 2 ++ .../silo/plugin/v1/watch_sync_provider.pb.go | 20 +++++++++--- .../plugin/v1/watch_sync_provider_test.go | 32 +++++++++++++++++++ .../silo/plugin/v1/watch_sync_provider.proto | 4 +++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71d8db5..b1fe23f 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ 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. 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. Authenticated RPCs receive the same host-owned capability, configuration, and credential data through `WatchSyncAuthenticatedContext`. The context exists diff --git a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go index 91bfbd2..d561e27 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go @@ -1950,8 +1950,12 @@ type WatchSyncEvent struct { // removal address the exact upstream record when external IDs are absent or // no longer resolve. ProviderItemKey string `protobuf:"bytes,13,opt,name=provider_item_key,json=providerItemKey,proto3" json:"provider_item_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Authoritative host completion state for playback events. A history ID can + // exist for incomplete playback, and completion_percent may be below 100 + // when the host's configured watched threshold has been reached. + Completed bool `protobuf:"varint,14,opt,name=completed,proto3" json:"completed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *WatchSyncEvent) Reset() { @@ -2075,6 +2079,13 @@ func (x *WatchSyncEvent) GetProviderItemKey() string { return "" } +func (x *WatchSyncEvent) GetCompleted() bool { + if x != nil { + return x.Completed + } + return false +} + type WatchSyncApplyEventsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Context *WatchSyncAuthenticatedContext `protobuf:"bytes,1,opt,name=context,proto3" json:"context,omitempty"` @@ -2833,7 +2844,7 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aD\n" + "\x16SeriesExternalIdsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xff\x04\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x9d\x05\n" + "\x0eWatchSyncEvent\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12@\n" + "\toperation\x18\x02 \x01(\x0e2\".silo.plugin.v1.WatchSyncOperationR\toperation\x127\n" + @@ -2850,7 +2861,8 @@ const file_silo_plugin_v1_watch_sync_provider_proto_rawDesc = "" + "\n" + "play_count\x18\v \x01(\x05R\tplayCount\x12(\n" + "\rlist_position\x18\f \x01(\x05H\x00R\flistPosition\x88\x01\x01\x12*\n" + - "\x11provider_item_key\x18\r \x01(\tR\x0fproviderItemKeyB\x10\n" + + "\x11provider_item_key\x18\r \x01(\tR\x0fproviderItemKey\x12\x1c\n" + + "\tcompleted\x18\x0e \x01(\bR\tcompletedB\x10\n" + "\x0e_list_position\"\x9e\x01\n" + "\x1bWatchSyncApplyEventsRequest\x12G\n" + "\acontext\x18\x01 \x01(\v2-.silo.plugin.v1.WatchSyncAuthenticatedContextR\acontext\x126\n" + diff --git a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go index 51e4fba..4e21030 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go +++ b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go @@ -212,3 +212,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/proto/silo/plugin/v1/watch_sync_provider.proto b/proto/silo/plugin/v1/watch_sync_provider.proto index c76b717..b8b0033 100644 --- a/proto/silo/plugin/v1/watch_sync_provider.proto +++ b/proto/silo/plugin/v1/watch_sync_provider.proto @@ -309,6 +309,10 @@ message WatchSyncEvent { // removal address the exact upstream record when external IDs are absent or // no longer resolve. string provider_item_key = 13; + // Authoritative host completion state for playback events. A history ID can + // exist for incomplete playback, and completion_percent may be below 100 + // when the host's configured watched threshold has been reached. + bool completed = 14; } message WatchSyncApplyEventsRequest { From 636acff04b36fd4fb414eb2bfdcdb04fab3dd621 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:37:00 -0400 Subject: [PATCH 4/8] build(deps): bump google.golang.org/grpc from 1.75.1 to 1.82.1 (#12) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.75.1 to 1.82.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.75.1...v1.82.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.82.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 50 ++++++++++++++++++++++++++------------------------ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index a26e4bb..64e39e2 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= From 0950ea3249991437cead0d5b42fe059c3ea531d9 Mon Sep 17 00:00:00 2001 From: blurbery <41898282+blurbery@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:37:13 +1000 Subject: [PATCH 5/8] feat(metadata): support scoped season image galleries --- .../silo/plugin/v1/metadata_provider.pb.go | 56 ++++++++++++++----- .../silo/plugin/v1/metadata_provider_test.go | 47 ++++++++++++++++ proto/silo/plugin/v1/metadata_provider.proto | 6 ++ 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go b/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go index 1d0b224..31cd31d 100644 --- a/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go @@ -1680,13 +1680,15 @@ 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"` + 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. + SeasonNumber *int32 `protobuf:"varint,7,opt,name=season_number,json=seasonNumber,proto3,oneof" json:"season_number,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1763,12 +1765,23 @@ func (x *ImageRecord) GetMetadata() *structpb.Struct { return nil } +func (x *ImageRecord) GetSeasonNumber() int32 { + if x != nil && x.SeasonNumber != nil { + return *x.SeasonNumber + } + return 0 +} + type GetImagesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ProviderId string `protobuf:"bytes,1,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - 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"` + state protoimpl.MessageState `protogen:"open.v1"` + ProviderId string `protobuf:"bytes,1,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + 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"` + // Present only when requesting artwork for a specific TV season. + // Season zero represents Specials, so presence must not be inferred from + // the numeric value. + SeasonNumber *int32 `protobuf:"varint,5,opt,name=season_number,json=seasonNumber,proto3,oneof" json:"season_number,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1831,6 +1844,13 @@ func (x *GetImagesRequest) GetLanguage() string { return "" } +func (x *GetImagesRequest) GetSeasonNumber() int32 { + if x != nil && x.SeasonNumber != nil { + return *x.SeasonNumber + } + return 0 +} + type GetImagesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Images []*ImageRecord `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"` @@ -2236,20 +2256,24 @@ const file_silo_plugin_v1_metadata_provider_proto_rawDesc = "" + "\fprovider_ids\x18\x03 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\x12\x1a\n" + "\blanguage\x18\x04 \x01(\tR\blanguage\"P\n" + "\x13GetEpisodesResponse\x129\n" + - "\bepisodes\x18\x01 \x03(\v2\x1d.silo.plugin.v1.EpisodeRecordR\bepisodes\"\xb2\x01\n" + + "\bepisodes\x18\x01 \x03(\v2\x1d.silo.plugin.v1.EpisodeRecordR\bepisodes\"\xee\x01\n" + "\vImageRecord\x12\x12\n" + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x10\n" + "\x03url\x18\x02 \x01(\tR\x03url\x12\x1a\n" + "\blanguage\x18\x03 \x01(\tR\blanguage\x12\x14\n" + "\x05width\x18\x04 \x01(\x05R\x05width\x12\x16\n" + "\x06height\x18\x05 \x01(\x05R\x06height\x123\n" + - "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\xa8\x01\n" + + "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12(\n" + + "\rseason_number\x18\a \x01(\x05H\x00R\fseasonNumber\x88\x01\x01B\x10\n" + + "\x0e_season_number\"\xe4\x01\n" + "\x10GetImagesRequest\x12\x1f\n" + "\vprovider_id\x18\x01 \x01(\tR\n" + "providerId\x12\x1b\n" + "\titem_type\x18\x02 \x01(\tR\bitemType\x12:\n" + "\fprovider_ids\x18\x03 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\x12\x1a\n" + - "\blanguage\x18\x04 \x01(\tR\blanguage\"H\n" + + "\blanguage\x18\x04 \x01(\tR\blanguage\x12(\n" + + "\rseason_number\x18\x05 \x01(\x05H\x00R\fseasonNumber\x88\x01\x01B\x10\n" + + "\x0e_season_number\"H\n" + "\x11GetImagesResponse\x123\n" + "\x06images\x18\x01 \x03(\v2\x1b.silo.plugin.v1.ImageRecordR\x06images\"F\n" + "\x16ResolveImageURLRequest\x12\x12\n" + @@ -2383,6 +2407,8 @@ func file_silo_plugin_v1_metadata_provider_proto_init() { if File_silo_plugin_v1_metadata_provider_proto != nil { return } + file_silo_plugin_v1_metadata_provider_proto_msgTypes[18].OneofWrappers = []any{} + file_silo_plugin_v1_metadata_provider_proto_msgTypes[19].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go b/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go index ea803db..c6fb3a3 100644 --- a/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go +++ b/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go @@ -123,6 +123,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 +154,48 @@ func TestMetadataProviderRequestDescriptors_IncludeProviderContext(t *testing.T) } } +func TestGetImagesRequest_SeasonNumberPreservesPresenceAndSpecials(t *testing.T) { + seasonZero := int32(0) + payload, err := proto.Marshal(&GetImagesRequest{SeasonNumber: &seasonZero}) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + var decoded GetImagesRequest + if err := proto.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if decoded.SeasonNumber == nil { + t.Fatal("season_number presence was lost for Specials") + } + if decoded.GetSeasonNumber() != 0 { + t.Fatalf("season_number = %d, want 0", decoded.GetSeasonNumber()) + } + if (&GetImagesRequest{}).SeasonNumber != nil { + t.Fatal("season_number should be absent for item-level image requests") + } +} + +func TestImageRecord_SeasonNumberPreservesPresenceAndSpecials(t *testing.T) { + seasonZero := int32(0) + payload, err := proto.Marshal(&ImageRecord{SeasonNumber: &seasonZero}) + if err != nil { + t.Fatalf("marshal image: %v", err) + } + + var decoded ImageRecord + if err := proto.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal image: %v", err) + } + if decoded.SeasonNumber == nil || decoded.GetSeasonNumber() != 0 { + t.Fatalf("season_number = %v, want present Specials value 0", decoded.SeasonNumber) + } + field := decoded.ProtoReflect().Descriptor().Fields().ByName("season_number") + if field == nil || field.Number() != 7 { + t.Fatalf("season_number descriptor = %v, want field 7", field) + } +} + func TestMetadataProviderServiceDescriptor_IncludesPersonDetailRPC(t *testing.T) { method := File_silo_plugin_v1_metadata_provider_proto.Services(). ByName("MetadataProvider"). diff --git a/proto/silo/plugin/v1/metadata_provider.proto b/proto/silo/plugin/v1/metadata_provider.proto index 1df8018..8e9f582 100644 --- a/proto/silo/plugin/v1/metadata_provider.proto +++ b/proto/silo/plugin/v1/metadata_provider.proto @@ -229,6 +229,8 @@ message ImageRecord { int32 width = 4; int32 height = 5; google.protobuf.Struct metadata = 6; + // Present only when the image is confirmed to belong to one exact season. + optional int32 season_number = 7; } message GetImagesRequest { @@ -236,6 +238,10 @@ message GetImagesRequest { string item_type = 2; google.protobuf.Struct provider_ids = 3; string language = 4; + // Present only when requesting artwork for a specific TV season. + // Season zero represents Specials, so presence must not be inferred from + // the numeric value. + optional int32 season_number = 5; } message GetImagesResponse { From f8a46f6069cd86d47aee913843e6d209f4e72b07 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:45:18 -0400 Subject: [PATCH 6/8] feat(imagevariant): add the "large" image-variant tier Silo sends metadata and image-resolver plugins a semantic image-variant hint on ResolveImageURL(s)Request and ResolveCatalogImageURLsRequest, but the vocabulary was documented nowhere and plugins hardcoded the strings. The server now supports client-selectable image sizes and will start sending "large" (~780px posters/stills, ~1280px logos/backdrops) between "featured" and "full". Export the canonical values from a new pkg/pluginsdk/imagevariant package, document the vocabulary on the proto fields and in docs, and state the two contract rules: the set is open and grows additively, and a plugin receiving an unknown variant must degrade to its nearest supported size rather than error. Additive and comment-only on the wire: the regenerated .pb.go diff is struct-field doc comments only. Co-Authored-By: Claude Fable 5 --- docs/compatibility.md | 23 +++++++++ docs/runtime-host.md | 26 +++++++++- .../silo/plugin/v1/metadata_provider.pb.go | 24 ++++++--- .../silo/plugin/v1/runtime_host.pb.go | 10 ++-- pkg/pluginsdk/imagevariant/imagevariant.go | 50 +++++++++++++++++++ .../imagevariant/imagevariant_test.go | 21 ++++++++ proto/silo/plugin/v1/metadata_provider.proto | 12 +++++ proto/silo/plugin/v1/runtime_host.proto | 10 ++-- 8 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 pkg/pluginsdk/imagevariant/imagevariant.go create mode 100644 pkg/pluginsdk/imagevariant/imagevariant_test.go diff --git a/docs/compatibility.md b/docs/compatibility.md index a2502d4..59068fe 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,28 @@ 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`. + ## Runtime Compatibility - `silo_api_version` is the coarse runtime compatibility gate between Silo and a plugin binary. diff --git a/docs/runtime-host.md b/docs/runtime-host.md index bdf4dc4..ef040a9 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/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go b/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go index 1d0b224..594b796 100644 --- a/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go @@ -1876,9 +1876,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 } @@ -1972,9 +1978,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/silo/plugin/v1/runtime_host.pb.go b/pkg/pluginproto/silo/plugin/v1/runtime_host.pb.go index 11ba83b..a7643a5 100644 --- a/pkg/pluginproto/silo/plugin/v1/runtime_host.pb.go +++ b/pkg/pluginproto/silo/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/pluginsdk/imagevariant/imagevariant.go b/pkg/pluginsdk/imagevariant/imagevariant.go new file mode 100644 index 0000000..ec5df5c --- /dev/null +++ b/pkg/pluginsdk/imagevariant/imagevariant.go @@ -0,0 +1,50 @@ +// 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 host says +// roughly how large the image will be rendered and the plugin picks the closest +// size its upstream offers. Variants travel as plain strings in +// ResolveImageURLRequest.variant, ResolveImageURLsRequest.variant, and +// ResolveCatalogImageURLsRequest.variant. An empty variant means "host default". +// +// 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/proto/silo/plugin/v1/metadata_provider.proto b/proto/silo/plugin/v1/metadata_provider.proto index 1df8018..1e6004f 100644 --- a/proto/silo/plugin/v1/metadata_provider.proto +++ b/proto/silo/plugin/v1/metadata_provider.proto @@ -244,6 +244,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; } @@ -253,6 +259,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/silo/plugin/v1/runtime_host.proto b/proto/silo/plugin/v1/runtime_host.proto index e316fc6..5932819 100644 --- a/proto/silo/plugin/v1/runtime_host.proto +++ b/proto/silo/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; } From 8ef0e2098696c51351ff7352d86bda750965862a Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:56:44 -0400 Subject: [PATCH 7/8] docs(imagevariant): state the receiver-specific empty-variant default An empty variant resolves to the plugin default on the metadata-provider RPCs and the host default on the RuntimeHost RPC; the package doc claimed "host default" for all three. Co-Authored-By: Claude Fable 5 --- pkg/pluginsdk/imagevariant/imagevariant.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/pluginsdk/imagevariant/imagevariant.go b/pkg/pluginsdk/imagevariant/imagevariant.go index ec5df5c..9902e5c 100644 --- a/pkg/pluginsdk/imagevariant/imagevariant.go +++ b/pkg/pluginsdk/imagevariant/imagevariant.go @@ -1,11 +1,13 @@ // 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 host says -// roughly how large the image will be rendered and the plugin picks the closest -// size its upstream offers. Variants travel as plain strings in +// 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 means "host default". +// 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: // From d3465c8ac3aa4b21d10300348a21d5a7979aaa77 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:09:50 -0400 Subject: [PATCH 8/8] docs(metadata): tighten season_number presence contract and dedupe tests Address review findings on the season-scoped image gallery contract: document the Specials/season-zero presence rule on ImageRecord and the scope-not-guarantee invariant on GetImagesRequest, add compatibility guidance for presence-sensitive optional fields, replace the tautological absence assertion with an empty-message round trip, and consolidate the field-number and optional-int32 presence checks into shared test helpers. Co-Authored-By: Claude Fable 5 --- README.md | 4 + docs/compatibility.md | 16 ++++ .../silo/plugin/v1/metadata_provider.pb.go | 7 +- .../silo/plugin/v1/metadata_provider_test.go | 83 ++++++++----------- .../silo/plugin/v1/optional_presence_test.go | 36 ++++++++ .../plugin/v1/watch_sync_provider_test.go | 31 ++----- proto/silo/plugin/v1/metadata_provider.proto | 7 +- 7 files changed, 110 insertions(+), 74 deletions(-) create mode 100644 pkg/pluginproto/silo/plugin/v1/optional_presence_test.go diff --git a/README.md b/README.md index b1fe23f..c6b0f0e 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ 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 diff --git a/docs/compatibility.md b/docs/compatibility.md index a2502d4..d7d5ee3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -29,6 +29,22 @@ 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. +## 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 - `silo_api_version` is the coarse runtime compatibility gate between Silo and a plugin binary. diff --git a/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go b/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go index 31cd31d..7e7335f 100644 --- a/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/metadata_provider.pb.go @@ -1688,6 +1688,9 @@ type ImageRecord struct { 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 @@ -1780,7 +1783,9 @@ type GetImagesRequest struct { Language string `protobuf:"bytes,4,opt,name=language,proto3" json:"language,omitempty"` // Present only when requesting artwork for a specific TV season. // Season zero represents Specials, so presence must not be inferred from - // the numeric value. + // 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 sizeCache protoimpl.SizeCache diff --git a/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go b/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go index c6fb3a3..f47c4f8 100644 --- a/pkg/pluginproto/silo/plugin/v1/metadata_provider_test.go +++ b/pkg/pluginproto/silo/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) { @@ -156,44 +157,28 @@ func TestMetadataProviderRequestDescriptors_IncludeProviderContext(t *testing.T) func TestGetImagesRequest_SeasonNumberPreservesPresenceAndSpecials(t *testing.T) { seasonZero := int32(0) - payload, err := proto.Marshal(&GetImagesRequest{SeasonNumber: &seasonZero}) - if err != nil { - t.Fatalf("marshal request: %v", err) - } - - var decoded GetImagesRequest - if err := proto.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal request: %v", err) - } - if decoded.SeasonNumber == nil { - t.Fatal("season_number presence was lost for Specials") - } - if decoded.GetSeasonNumber() != 0 { - t.Fatalf("season_number = %d, want 0", decoded.GetSeasonNumber()) - } - if (&GetImagesRequest{}).SeasonNumber != nil { - t.Fatal("season_number should be absent for item-level image requests") - } + 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) - payload, err := proto.Marshal(&ImageRecord{SeasonNumber: &seasonZero}) - if err != nil { - t.Fatalf("marshal image: %v", err) - } - - var decoded ImageRecord - if err := proto.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal image: %v", err) - } - if decoded.SeasonNumber == nil || decoded.GetSeasonNumber() != 0 { - t.Fatalf("season_number = %v, want present Specials value 0", decoded.SeasonNumber) - } - field := decoded.ProtoReflect().Descriptor().Fields().ByName("season_number") - if field == nil || field.Number() != 7 { - t.Fatalf("season_number descriptor = %v, want field 7", field) - } + 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) { diff --git a/pkg/pluginproto/silo/plugin/v1/optional_presence_test.go b/pkg/pluginproto/silo/plugin/v1/optional_presence_test.go new file mode 100644 index 0000000..38c089a --- /dev/null +++ b/pkg/pluginproto/silo/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/silo/plugin/v1/watch_sync_provider_test.go b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go index 4e21030..2c1d4f1 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go +++ b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider_test.go @@ -131,29 +131,14 @@ func TestWatchSyncDeviceAuthorizationPendingStatePreservesPresence(t *testing.T) func TestWatchSyncListPositionPreservesPresence(t *testing.T) { zero := int32(0) - withZero := &WatchSyncEvent{ListPosition: &zero} - data, err := proto.Marshal(withZero) - if err != nil { - t.Fatal(err) - } - var output WatchSyncEvent - if err := proto.Unmarshal(data, &output); err != nil { - t.Fatal(err) - } - if output.ListPosition == nil || output.GetListPosition() != 0 { - t.Fatalf("explicit zero list position = %#v", output.ListPosition) - } - omittedData, err := proto.Marshal(&WatchSyncEvent{}) - if err != nil { - t.Fatal(err) - } - var omittedOutput WatchSyncEvent - if err := proto.Unmarshal(omittedData, &omittedOutput); err != nil { - t.Fatal(err) - } - if omittedOutput.ListPosition != nil { - t.Fatal("omitted list position unexpectedly has presence") - } + assertOptionalInt32Presence( + t, + &WatchSyncEvent{ListPosition: &zero}, + &WatchSyncEvent{}, + func(message proto.Message) *int32 { + return message.(*WatchSyncEvent).ListPosition + }, + ) } func TestWatchSyncListTombstoneDoesNotRequireMedia(t *testing.T) { diff --git a/proto/silo/plugin/v1/metadata_provider.proto b/proto/silo/plugin/v1/metadata_provider.proto index 8e9f582..c290f90 100644 --- a/proto/silo/plugin/v1/metadata_provider.proto +++ b/proto/silo/plugin/v1/metadata_provider.proto @@ -230,6 +230,9 @@ message ImageRecord { int32 height = 5; google.protobuf.Struct metadata = 6; // 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. optional int32 season_number = 7; } @@ -240,7 +243,9 @@ message GetImagesRequest { string language = 4; // Present only when requesting artwork for a specific TV season. // Season zero represents Specials, so presence must not be inferred from - // the numeric value. + // 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. optional int32 season_number = 5; }