From 6d717477a86f44300880ffe362e4db33395ee0cc Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:16:14 +1100 Subject: [PATCH 1/4] feat(requestrouter): let request routers say they need no API key or base URL (#43) * feat(requestrouter): let request routers say they need no API key or base URL Add api_key_optional and base_url_optional to RequestRouterDescriptor. A router that sets api_key_optional receives connections whose api_key is empty; one that sets base_url_optional can be saved and tested without a base URL. Unset, or an absent descriptor, keeps today's behaviour: a connection needs both. Closes #42 * fix(requestrouter): word the credential flags by what hosts enforce Say that the admin form, not the API, refuses a server without a key or URL, that only Fulfill, CheckStatus and ListConfigOptions skip a connection without a key, and that httpclient needs both. Update the plugin rather than reinstall it, so its servers stay bound. Cover base_url_optional alone and unchanged metadata for plugins without the flags. * docs(requestrouter): say older Silo hosts refuse a plugin binary with the flags Silo reads a bare plugin binary's manifest strictly, so a host that predates the flags refuses such a binary; an archive installs. Tracked in Silo-Server/silo-server#2436. --- README.md | 30 ++++++++++ docs/compatibility.md | 14 +++++ .../silo/plugin/v1/request_router.pb.go | 48 ++++++++++++--- pkg/pluginsdk/convert/request_router_test.go | 59 +++++++++++++++++++ pkg/pluginsdk/manifest/request_router_test.go | 44 ++++++++++++++ proto/silo/plugin/v1/request_router.proto | 14 +++++ 6 files changed, 200 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4fd4a92..9d42773 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,36 @@ last stored. One `DownloadProgress` covers all of a target's downloads: `downloading`, so a phase added later still shows as a download in progress on hosts that predate it. +### API key and base URL + +An admin connects a server to a request router with a base URL and an API key, +which the plugin receives in `RouterConnection`. By default the host needs +both: its admin form won't save a server without them, and it never sends a +request to a connection without a key. A plugin that works without them, for +example one that talks to a local tool or authenticates some other way, +declares it in its manifest: + +```json +{ + "type": "request_router.v1", + "id": "local", + "request_router": { "api_key_optional": true, "base_url_optional": true } +} +``` + +- `api_key_optional`: the admin form saves and tests a server without a key, + and the host sends the plugin connections whose `api_key` is empty. +- `base_url_optional`: the admin form saves and tests a server without a base + URL, and the plugin receives an empty `base_url`. + +An admin can still enter a key or URL, and the plugin receives them. Plugins +without the flags, including every plugin built before they existed, need both. +A host that predates the flags still skips a connection without a key, and +refuses to install a bare plugin binary that declares them; see +[docs/compatibility.md](docs/compatibility.md). The SDK's `httpclient` refuses +an empty key or base URL, so a plugin that declares either flag needs its own +client for connections without them. + ### Wording Silo clients draw a request as four steps: requested, approved, the router's diff --git a/docs/compatibility.md b/docs/compatibility.md index 07dfacf..db47911 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -160,6 +160,20 @@ newer host stored could reject it. An absent wording, or any empty value in it, means the host's own neutral words. Plugins can therefore declare wording without raising their minimum host version. +`RequestRouterDescriptor.api_key_optional` and `base_url_optional` (added in +v0.26.0) are plain booleans, so they have no presence: unset, or an absent +descriptor, keeps the behaviour hosts had before the flags existed. The host +skips a connection without an API key, and its admin form asks for a key and a +base URL. Hosts that predate the flags drop them as unknown fields when they +install a plugin archive, so a plugin that sets `api_key_optional` gets +requests without a key only from a host that reads it. Silo reads a bare +plugin binary's manifest strictly, so a Silo host that predates the flags +refuses to install a binary that declares them; install it from an archive +there (Silo-Server/silo-server#2436). Hosts read the flags from the capability +metadata stored when the plugin was installed or updated. A host built on an +older SDK stored that metadata without them, so they take effect after the +plugin is next updated on a host that reads them. + 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 diff --git a/pkg/pluginproto/silo/plugin/v1/request_router.pb.go b/pkg/pluginproto/silo/plugin/v1/request_router.pb.go index 0c0bb9e..883b6bd 100644 --- a/pkg/pluginproto/silo/plugin/v1/request_router.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/request_router.pb.go @@ -170,9 +170,18 @@ type RequestRouterDescriptor struct { ReportsDownloadProgress bool `protobuf:"varint,2,opt,name=reports_download_progress,json=reportsDownloadProgress,proto3" json:"reports_download_progress,omitempty"` // wording is how clients describe a request while this plugin fulfils it. // Unset, or any field left empty, means the host's own neutral words. - Wording *RequestRouterWording `protobuf:"bytes,3,opt,name=wording,proto3" json:"wording,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Wording *RequestRouterWording `protobuf:"bytes,3,opt,name=wording,proto3" json:"wording,omitempty"` + // api_key_optional declares that the plugin works without an API key, for + // example because it talks to a local tool or authenticates some other way. + // The host then sends it connections whose api_key is empty, and its admin + // form saves and tests a server without a key. Hosts skip a connection + // without a key when its plugin does not declare this. + ApiKeyOptional bool `protobuf:"varint,4,opt,name=api_key_optional,json=apiKeyOptional,proto3" json:"api_key_optional,omitempty"` + // base_url_optional declares that the plugin works without a base URL. The + // host's admin form then saves and tests a server without one. + BaseUrlOptional bool `protobuf:"varint,5,opt,name=base_url_optional,json=baseUrlOptional,proto3" json:"base_url_optional,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RequestRouterDescriptor) Reset() { @@ -226,6 +235,20 @@ func (x *RequestRouterDescriptor) GetWording() *RequestRouterWording { return nil } +func (x *RequestRouterDescriptor) GetApiKeyOptional() bool { + if x != nil { + return x.ApiKeyOptional + } + return false +} + +func (x *RequestRouterDescriptor) GetBaseUrlOptional() bool { + if x != nil { + return x.BaseUrlOptional + } + return false +} + // RequestRouterWording names the part of a request that a request router // handles, in words that fit how the plugin works. Clients draw a request as // four steps: requested, approved, the router's step, and in the library. The @@ -357,10 +380,15 @@ func (x *RequestStatusWording) GetDetail() string { } type RouterConnection struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - BaseUrl string `protobuf:"bytes,2,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` - ApiKey string `protobuf:"bytes,3,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // base_url is empty when none was saved. The host's admin form allows that + // only for a plugin that declares RequestRouterDescriptor.base_url_optional. + BaseUrl string `protobuf:"bytes,2,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + // api_key is empty when none was saved. The host sends such a connection + // to Fulfill, CheckStatus and ListConfigOptions only for a plugin that + // declares RequestRouterDescriptor.api_key_optional. + ApiKey string `protobuf:"bytes,3,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` // plugin-declared config blob (e.g. service_kind, root_folder, // quality_profile_id) Config *structpb.Struct `protobuf:"bytes,4,opt,name=config,proto3" json:"config,omitempty"` @@ -1509,11 +1537,13 @@ const file_silo_plugin_v1_request_router_proto_rawDesc = "" + " \x03(\x05R\aseasons\x1a>\n" + "\x10ExternalIdsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc0\x01\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x96\x02\n" + "\x17RequestRouterDescriptor\x12)\n" + "\x10supports_seasons\x18\x01 \x01(\bR\x0fsupportsSeasons\x12:\n" + "\x19reports_download_progress\x18\x02 \x01(\bR\x17reportsDownloadProgress\x12>\n" + - "\awording\x18\x03 \x01(\v2$.silo.plugin.v1.RequestRouterWordingR\awording\"\xb0\x01\n" + + "\awording\x18\x03 \x01(\v2$.silo.plugin.v1.RequestRouterWordingR\awording\x12(\n" + + "\x10api_key_optional\x18\x04 \x01(\bR\x0eapiKeyOptional\x12*\n" + + "\x11base_url_optional\x18\x05 \x01(\bR\x0fbaseUrlOptional\"\xb0\x01\n" + "\x14RequestRouterWording\x12\x12\n" + "\x04step\x18\x01 \x01(\tR\x04step\x12<\n" + "\x06queued\x18\x02 \x01(\v2$.silo.plugin.v1.RequestStatusWordingR\x06queued\x12F\n" + diff --git a/pkg/pluginsdk/convert/request_router_test.go b/pkg/pluginsdk/convert/request_router_test.go index 09cb24e..eea47bc 100644 --- a/pkg/pluginsdk/convert/request_router_test.go +++ b/pkg/pluginsdk/convert/request_router_test.go @@ -17,6 +17,9 @@ func TestRequestRouterDescriptorRoundTrip(t *testing.T) { {name: "seasons", descriptor: &pluginv1.RequestRouterDescriptor{SupportsSeasons: true}}, {name: "download progress", descriptor: &pluginv1.RequestRouterDescriptor{ReportsDownloadProgress: true}}, {name: "both", descriptor: &pluginv1.RequestRouterDescriptor{SupportsSeasons: true, ReportsDownloadProgress: true}}, + {name: "optional credentials", descriptor: &pluginv1.RequestRouterDescriptor{ApiKeyOptional: true, BaseUrlOptional: true}}, + {name: "optional api key", descriptor: &pluginv1.RequestRouterDescriptor{ApiKeyOptional: true}}, + {name: "optional base url", descriptor: &pluginv1.RequestRouterDescriptor{BaseUrlOptional: true}}, {name: "wording", descriptor: &pluginv1.RequestRouterDescriptor{Wording: &pluginv1.RequestRouterWording{ Step: "Processing", Queued: &pluginv1.RequestStatusWording{Label: "Sent to Seerr", Detail: "Seerr takes it from here."}, @@ -85,6 +88,62 @@ func TestCapabilityRecordStoresWording(t *testing.T) { } } +// Other nodes read the flags back from the stored JSON, so they are stored +// under their proto field names. +func TestCapabilityRecordStoresOptionalCredentialFlags(t *testing.T) { + manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{ + Type: "request_router.v1", + Id: "local", + RequestRouter: &pluginv1.RequestRouterDescriptor{ApiKeyOptional: true, BaseUrlOptional: true}, + }}} + records, err := convert.CapabilityRecordsFromManifest(manifest) + if err != nil { + t.Fatal(err) + } + stored, _ := records[0].Metadata["request_router"].(map[string]any) + if stored["api_key_optional"] != true || stored["base_url_optional"] != true { + t.Fatalf("stored request_router = %v, want api_key_optional and base_url_optional true", stored) + } +} + +// A plugin that leaves the flags unset stores the same metadata as before they +// existed, so hosts on older SDKs read it unchanged. +func TestCapabilityRecordWithoutCredentialFlagsStoresNoNewKeys(t *testing.T) { + manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{ + Type: "request_router.v1", + Id: "arr", + RequestRouter: &pluginv1.RequestRouterDescriptor{SupportsSeasons: true}, + }}} + records, err := convert.CapabilityRecordsFromManifest(manifest) + if err != nil { + t.Fatal(err) + } + stored, _ := records[0].Metadata["request_router"].(map[string]any) + if len(stored) != 1 || stored["supports_seasons"] != true { + t.Fatalf("stored request_router = %v, want only supports_seasons", stored) + } +} + +// Metadata stored for a request router built on an SDK without the flags must +// decode as needing an API key and a base URL. +func TestDecodeCapabilityStoredWithoutCredentialFlagsRequiresBoth(t *testing.T) { + record := convert.CapabilityRecord{ + Type: "request_router.v1", + ID: "arr", + Metadata: map[string]any{ + "request_router": map[string]any{"supports_seasons": true, "reports_download_progress": true}, + }, + } + decoded, err := convert.DecodeCapability(record) + if err != nil { + t.Fatal(err) + } + descriptor := decoded.GetRequestRouter() + if descriptor.GetApiKeyOptional() || descriptor.GetBaseUrlOptional() { + t.Fatalf("decoded descriptor = %v, want an API key and a base URL required", descriptor) + } +} + // Metadata stored for a request router built on an SDK that knew only // supports_seasons must decode as not reporting download progress. func TestDecodeCapabilityWithOnlySeasonSupportReportsNoDownloadProgress(t *testing.T) { diff --git a/pkg/pluginsdk/manifest/request_router_test.go b/pkg/pluginsdk/manifest/request_router_test.go index c895cc4..8c8fe51 100644 --- a/pkg/pluginsdk/manifest/request_router_test.go +++ b/pkg/pluginsdk/manifest/request_router_test.go @@ -82,6 +82,50 @@ func TestLoadRequestRouterWithoutDescriptorDeclaresNoSeasonSupport(t *testing.T) } } +func TestLoadRequestRouterDeclaresOptionalCredentials(t *testing.T) { + raw := []byte(`{ + "plugin_id":"silo.requests-local", "version":"1.0.0", "silo_api_version":"v1", + "capabilities":[{ + "type":"request_router.v1", "id":"local", "display_name":"Local", + "request_router":{"api_key_optional":true, "base_url_optional":true} + }] + }`) + manifest, err := publicmanifest.Load(raw) + if err != nil { + t.Fatal(err) + } + descriptor := manifest.GetCapabilities()[0].GetRequestRouter() + if !descriptor.GetApiKeyOptional() { + t.Fatal("api_key_optional = false, want true") + } + if !descriptor.GetBaseUrlOptional() { + t.Fatal("base_url_optional = false, want true") + } +} + +// A manifest that leaves the flags out, or has no descriptor, needs an API key +// and a base URL, so routers written before the flags existed are unchanged. +func TestLoadRequestRouterWithoutCredentialFlagsRequiresBoth(t *testing.T) { + for name, raw := range map[string]string{ + "other flags": `"request_router":{"supports_seasons":true, "reports_download_progress":true}`, + "no descriptor": `"display_name":"Arr"`, + } { + t.Run(name, func(t *testing.T) { + manifest, err := publicmanifest.Load([]byte(`{ + "plugin_id":"silo.requests-arr", "version":"1.0.0", "silo_api_version":"v1", + "capabilities":[{"type":"request_router.v1", "id":"arr", ` + raw + `}] + }`)) + if err != nil { + t.Fatal(err) + } + descriptor := manifest.GetCapabilities()[0].GetRequestRouter() + if descriptor.GetApiKeyOptional() || descriptor.GetBaseUrlOptional() { + t.Fatalf("descriptor = %v, want an API key and a base URL required", descriptor) + } + }) + } +} + func TestValidateRequestRouterRejectsDescriptorOnOtherType(t *testing.T) { for _, descriptor := range []*pluginv1.RequestRouterDescriptor{ {SupportsSeasons: true}, diff --git a/proto/silo/plugin/v1/request_router.proto b/proto/silo/plugin/v1/request_router.proto index c5f4ea9..a1d48cb 100644 --- a/proto/silo/plugin/v1/request_router.proto +++ b/proto/silo/plugin/v1/request_router.proto @@ -64,6 +64,15 @@ message RequestRouterDescriptor { // wording is how clients describe a request while this plugin fulfils it. // Unset, or any field left empty, means the host's own neutral words. RequestRouterWording wording = 3; + // api_key_optional declares that the plugin works without an API key, for + // example because it talks to a local tool or authenticates some other way. + // The host then sends it connections whose api_key is empty, and its admin + // form saves and tests a server without a key. Hosts skip a connection + // without a key when its plugin does not declare this. + bool api_key_optional = 4; + // base_url_optional declares that the plugin works without a base URL. The + // host's admin form then saves and tests a server without one. + bool base_url_optional = 5; } // RequestRouterWording names the part of a request that a request router @@ -97,7 +106,12 @@ message RequestStatusWording { message RouterConnection { string id = 1; + // base_url is empty when none was saved. The host's admin form allows that + // only for a plugin that declares RequestRouterDescriptor.base_url_optional. string base_url = 2; + // api_key is empty when none was saved. The host sends such a connection + // to Fulfill, CheckStatus and ListConfigOptions only for a plugin that + // declares RequestRouterDescriptor.api_key_optional. string api_key = 3; // plugin-declared config blob (e.g. service_kind, root_folder, // quality_profile_id) From aa8df5e09adb17dad7148f29261d3e331df7ee6a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:04:25 -0400 Subject: [PATCH 2/4] feat(watchsync): allow watch-sync batch sizes up to 1000 (#44) Manifest validation now accepts max_batch_size from 1 to 1000 and exports the limit as manifest.MaxWatchSyncBatchSize. The proto comment and docs/compatibility.md state that the value bounds every ApplyEvents call and the ListRemoteState page size, and that hosts and catalog tooling on earlier SDKs reject values over 100. Co-authored-by: Claude Opus 5.5 --- docs/compatibility.md | 11 +++++++++ .../silo/plugin/v1/watch_sync_provider.pb.go | 18 +++++++++------ pkg/pluginsdk/manifest/manifest.go | 9 ++++++-- .../manifest/watch_sync_provider_test.go | 23 +++++++++++++++++++ .../silo/plugin/v1/watch_sync_provider.proto | 4 ++++ 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index db47911..f321bed 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -186,6 +186,17 @@ verify images by the per-image field rather than assume a filtered response. - A plugin binary should return the same manifest shape that Silo installs, except that binaries may compute their checksum dynamically at runtime. - From this version, `convert.DecodeCapability` ignores fields and enum values it does not know, so a server node built on this SDK or later, in a mixed-version cluster sharing one database, still loads capability metadata written by a newer SDK; it only loses the parts added after its own SDK version. Nodes built on an earlier SDK decode strictly and reject such metadata, so upgrade every node past them before installing plugins that publish newer fields. +### Watch-sync batch size + +`WatchSyncProviderDescriptor.max_batch_size` accepts 1 to 1000 from v0.27.0; +earlier releases accept 1 to 100. The value bounds every `ApplyEvents` call +(watched history, watchlist, favorites, and ratings) and sets the page size a +host asks for in `ListRemoteState`, so a plugin that raises it should handle +batches and pages of that size for every flow it declares. Manifest validation runs in the host when it +installs or loads a plugin and in the catalog tooling when it publishes one, so +a plugin that declares more than 100 needs both built on v0.27.0 or later. An +older host refuses to install or load it. + ## Go Support The supported public authoring path today is Go-only. 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 4d96343..5fa2149 100644 --- a/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/watch_sync_provider.pb.go @@ -560,13 +560,17 @@ type WatchSyncProviderDescriptor struct { ImportProgress bool `protobuf:"varint,5,opt,name=import_progress,json=importProgress,proto3" json:"import_progress,omitempty"` 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"` - 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 most events the host sends in one ApplyEvents call, and the page size + // it requests from ListRemoteState, from 1 to 1000. SDK releases before + // v0.27.0 accept only 1 to 100, so a larger value needs a host built on + // v0.27.0 or later. + MaxBatchSize int32 `protobuf:"varint,8,opt,name=max_batch_size,json=maxBatchSize,proto3" json:"max_batch_size,omitempty"` + 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. diff --git a/pkg/pluginsdk/manifest/manifest.go b/pkg/pluginsdk/manifest/manifest.go index 565e7e1..0eccc5b 100644 --- a/pkg/pluginsdk/manifest/manifest.go +++ b/pkg/pluginsdk/manifest/manifest.go @@ -202,8 +202,8 @@ func validateWatchSyncCapability(descriptor *pluginv1.CapabilityDescriptor) erro 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()) + if watchSync.GetMaxBatchSize() < 1 || watchSync.GetMaxBatchSize() > MaxWatchSyncBatchSize { + return fmt.Errorf("plugin capability %q: watch sync max_batch_size must be between 1 and %d", descriptor.GetId(), MaxWatchSyncBatchSize) } if len(watchSync.GetSupportedMediaTypes()) == 0 { return fmt.Errorf("plugin capability %q: at least one supported media type is required", descriptor.GetId()) @@ -324,6 +324,11 @@ func validateRequestRouterCapability(descriptor *pluginv1.CapabilityDescriptor) return nil } +// MaxWatchSyncBatchSize is the largest max_batch_size a watch sync provider +// may declare. A host sends at most max_batch_size events in one ApplyEvents +// call and asks ListRemoteState for pages of that size. +const MaxWatchSyncBatchSize = 1000 + // Limits on RequestRouterWording values, in characters. const ( MaxRequestWordingLabelRunes = 24 diff --git a/pkg/pluginsdk/manifest/watch_sync_provider_test.go b/pkg/pluginsdk/manifest/watch_sync_provider_test.go index 9bea12a..b3c690a 100644 --- a/pkg/pluginsdk/manifest/watch_sync_provider_test.go +++ b/pkg/pluginsdk/manifest/watch_sync_provider_test.go @@ -1,6 +1,7 @@ package manifest_test import ( + "strconv" "testing" "google.golang.org/protobuf/types/known/structpb" @@ -436,3 +437,25 @@ func TestLoadWatchSyncProviderWithAFutureConnectionSettingType(t *testing.T) { t.Fatalf("connection settings = %v", settings) } } + +func TestValidateWatchSyncProviderBatchSizeBounds(t *testing.T) { + for _, tc := range []struct { + size int32 + wantErr bool + }{ + {size: 0, wantErr: true}, + {size: 1}, + {size: 100}, + {size: publicmanifest.MaxWatchSyncBatchSize}, + {size: publicmanifest.MaxWatchSyncBatchSize + 1, wantErr: true}, + } { + t.Run(strconv.Itoa(int(tc.size)), func(t *testing.T) { + manifest := validWatchSyncManifest() + manifest.Capabilities[0].WatchSyncProvider.MaxBatchSize = tc.size + err := publicmanifest.Validate(manifest) + if (err != nil) != tc.wantErr { + t.Fatalf("Validate(max_batch_size=%d) error = %v, wantErr %v", tc.size, err, tc.wantErr) + } + }) + } +} diff --git a/proto/silo/plugin/v1/watch_sync_provider.proto b/proto/silo/plugin/v1/watch_sync_provider.proto index 43c0f31..f74d3dc 100644 --- a/proto/silo/plugin/v1/watch_sync_provider.proto +++ b/proto/silo/plugin/v1/watch_sync_provider.proto @@ -120,6 +120,10 @@ message WatchSyncProviderDescriptor { bool import_progress = 5; repeated WatchSyncMediaType supported_media_types = 6; repeated string external_id_namespaces = 7; + // The most events the host sends in one ApplyEvents call, and the page size + // it requests from ListRemoteState, from 1 to 1000. SDK releases before + // v0.27.0 accept only 1 to 100, so a larger value needs a host built on + // v0.27.0 or later. int32 max_batch_size = 8; bool import_favorites = 9; bool export_favorites = 10; From 6a1b575ae92b47b578648da84b94d77e8427e97f Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:39:33 -0400 Subject: [PATCH 3/4] feat(markers): send the episode's own provider IDs and title to marker providers (#45) * feat(markers): send the episode's own provider IDs and title to marker providers FetchMarkersRequest and SubmitMarkerRequest gain episode_external_ids and episode_title. A marker provider such as TheIntroDB v4 reads season and episode numbers in the numbering of the ID it receives, so it needs to know which providers matched the host's episode at those numbers. Older hosts leave the fields empty and older plugins ignore them. Co-Authored-By: Claude Opus 5.5 * feat(markers): tell marker providers whose numbering an episode follows FetchMarkersRequest and SubmitMarkerRequest gain episode_numbering: the external ID key ("tvdb" or "tmdb") of the provider whose numbering the episode follows. Hosts match each metadata provider's episodes by number alone, so an episode row usually carries both TMDB and TVDB IDs and a plugin cannot tell from the IDs which numbering applies. The field is an open vocabulary; an unknown value reads as empty. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/compatibility.md | 5 + .../silo/plugin/v1/marker_provider.pb.go | 155 +++++++++++++----- .../silo/plugin/v1/marker_provider_test.go | 64 ++++++++ proto/silo/plugin/v1/marker_provider.proto | 18 ++ 4 files changed, 199 insertions(+), 43 deletions(-) create mode 100644 pkg/pluginproto/silo/plugin/v1/marker_provider_test.go diff --git a/docs/compatibility.md b/docs/compatibility.md index f321bed..1186460 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -52,6 +52,11 @@ 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`. +`FetchMarkersRequest.episode_numbering` and `SubmitMarkerRequest.episode_numbering` +are open too. Hosts send an external ID key, today `tvdb` or `tmdb`, or leave the +field empty when they cannot tell. A marker provider treats a value it does not +recognize like an empty one. + `DownloadProgress.phase` runs the other way: plugins send it and the host reads it. The current values are `queued`, `downloading`, `paused`, `stalled`, `importing`, and `import_blocked`. Hosts treat a value they do not recognize as diff --git a/pkg/pluginproto/silo/plugin/v1/marker_provider.pb.go b/pkg/pluginproto/silo/plugin/v1/marker_provider.pb.go index 7fa99a1..5131683 100644 --- a/pkg/pluginproto/silo/plugin/v1/marker_provider.pb.go +++ b/pkg/pluginproto/silo/plugin/v1/marker_provider.pb.go @@ -90,16 +90,30 @@ func (x *MarkerExternalIDs) GetProviderIds() *structpb.Struct { return nil } +// For an episode, external_ids names the series, and season_number and +// episode_number place the episode in it using the host catalog's numbering +// (season 0 holds specials). episode_external_ids names the provider episode +// records the host matched at those numbers. The host matches each +// provider's episodes by number alone, so IDs from two providers can name +// different episodes where the providers number a series differently. +// episode_numbering resolves that: it is the external ID key ("tvdb" or +// "tmdb") of the provider whose numbering the episode follows, the first in +// the library's episode chain that supplied it. episode_title is in the +// library's metadata language. The episode fields are empty for a movie and +// when the host has no value. type FetchMarkersRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ItemType string `protobuf:"bytes,1,opt,name=item_type,json=itemType,proto3" json:"item_type,omitempty"` // "movie" | "episode" - ExternalIds *MarkerExternalIDs `protobuf:"bytes,2,opt,name=external_ids,json=externalIds,proto3" json:"external_ids,omitempty"` - SeasonNumber int32 `protobuf:"varint,3,opt,name=season_number,json=seasonNumber,proto3" json:"season_number,omitempty"` - EpisodeNumber int32 `protobuf:"varint,4,opt,name=episode_number,json=episodeNumber,proto3" json:"episode_number,omitempty"` - DurationSeconds float64 `protobuf:"fixed64,5,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` - Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ItemType string `protobuf:"bytes,1,opt,name=item_type,json=itemType,proto3" json:"item_type,omitempty"` // "movie" | "episode" + ExternalIds *MarkerExternalIDs `protobuf:"bytes,2,opt,name=external_ids,json=externalIds,proto3" json:"external_ids,omitempty"` + SeasonNumber int32 `protobuf:"varint,3,opt,name=season_number,json=seasonNumber,proto3" json:"season_number,omitempty"` + EpisodeNumber int32 `protobuf:"varint,4,opt,name=episode_number,json=episodeNumber,proto3" json:"episode_number,omitempty"` + DurationSeconds float64 `protobuf:"fixed64,5,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` + Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` + EpisodeExternalIds *MarkerExternalIDs `protobuf:"bytes,7,opt,name=episode_external_ids,json=episodeExternalIds,proto3" json:"episode_external_ids,omitempty"` + EpisodeTitle string `protobuf:"bytes,8,opt,name=episode_title,json=episodeTitle,proto3" json:"episode_title,omitempty"` + EpisodeNumbering string `protobuf:"bytes,9,opt,name=episode_numbering,json=episodeNumbering,proto3" json:"episode_numbering,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FetchMarkersRequest) Reset() { @@ -174,6 +188,27 @@ func (x *FetchMarkersRequest) GetMetadata() *structpb.Struct { return nil } +func (x *FetchMarkersRequest) GetEpisodeExternalIds() *MarkerExternalIDs { + if x != nil { + return x.EpisodeExternalIds + } + return nil +} + +func (x *FetchMarkersRequest) GetEpisodeTitle() string { + if x != nil { + return x.EpisodeTitle + } + return "" +} + +func (x *FetchMarkersRequest) GetEpisodeNumbering() string { + if x != nil { + return x.EpisodeNumbering + } + return "" +} + type MarkerSegment struct { state protoimpl.MessageState `protogen:"open.v1"` Segment string `protobuf:"bytes,1,opt,name=segment,proto3" json:"segment,omitempty"` // "intro" | "credits" | "recap" | "preview" @@ -318,19 +353,23 @@ func (x *FetchMarkersResponse) GetMetadata() *structpb.Struct { return nil } +// The item fields follow FetchMarkersRequest. type SubmitMarkerRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ItemType string `protobuf:"bytes,1,opt,name=item_type,json=itemType,proto3" json:"item_type,omitempty"` // "movie" | "episode" - ExternalIds *MarkerExternalIDs `protobuf:"bytes,2,opt,name=external_ids,json=externalIds,proto3" json:"external_ids,omitempty"` - SeasonNumber int32 `protobuf:"varint,3,opt,name=season_number,json=seasonNumber,proto3" json:"season_number,omitempty"` - EpisodeNumber int32 `protobuf:"varint,4,opt,name=episode_number,json=episodeNumber,proto3" json:"episode_number,omitempty"` - Segment string `protobuf:"bytes,5,opt,name=segment,proto3" json:"segment,omitempty"` // "intro" | "credits" | "recap" | "preview" - StartSeconds *float64 `protobuf:"fixed64,6,opt,name=start_seconds,json=startSeconds,proto3,oneof" json:"start_seconds,omitempty"` - EndSeconds *float64 `protobuf:"fixed64,7,opt,name=end_seconds,json=endSeconds,proto3,oneof" json:"end_seconds,omitempty"` - DurationSeconds float64 `protobuf:"fixed64,8,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` - Metadata *structpb.Struct `protobuf:"bytes,9,opt,name=metadata,proto3" json:"metadata,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ItemType string `protobuf:"bytes,1,opt,name=item_type,json=itemType,proto3" json:"item_type,omitempty"` // "movie" | "episode" + ExternalIds *MarkerExternalIDs `protobuf:"bytes,2,opt,name=external_ids,json=externalIds,proto3" json:"external_ids,omitempty"` + SeasonNumber int32 `protobuf:"varint,3,opt,name=season_number,json=seasonNumber,proto3" json:"season_number,omitempty"` + EpisodeNumber int32 `protobuf:"varint,4,opt,name=episode_number,json=episodeNumber,proto3" json:"episode_number,omitempty"` + Segment string `protobuf:"bytes,5,opt,name=segment,proto3" json:"segment,omitempty"` // "intro" | "credits" | "recap" | "preview" + StartSeconds *float64 `protobuf:"fixed64,6,opt,name=start_seconds,json=startSeconds,proto3,oneof" json:"start_seconds,omitempty"` + EndSeconds *float64 `protobuf:"fixed64,7,opt,name=end_seconds,json=endSeconds,proto3,oneof" json:"end_seconds,omitempty"` + DurationSeconds float64 `protobuf:"fixed64,8,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` + Metadata *structpb.Struct `protobuf:"bytes,9,opt,name=metadata,proto3" json:"metadata,omitempty"` + EpisodeExternalIds *MarkerExternalIDs `protobuf:"bytes,10,opt,name=episode_external_ids,json=episodeExternalIds,proto3" json:"episode_external_ids,omitempty"` + EpisodeTitle string `protobuf:"bytes,11,opt,name=episode_title,json=episodeTitle,proto3" json:"episode_title,omitempty"` + EpisodeNumbering string `protobuf:"bytes,12,opt,name=episode_numbering,json=episodeNumbering,proto3" json:"episode_numbering,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SubmitMarkerRequest) Reset() { @@ -426,6 +465,27 @@ func (x *SubmitMarkerRequest) GetMetadata() *structpb.Struct { return nil } +func (x *SubmitMarkerRequest) GetEpisodeExternalIds() *MarkerExternalIDs { + if x != nil { + return x.EpisodeExternalIds + } + return nil +} + +func (x *SubmitMarkerRequest) GetEpisodeTitle() string { + if x != nil { + return x.EpisodeTitle + } + return "" +} + +func (x *SubmitMarkerRequest) GetEpisodeNumbering() string { + if x != nil { + return x.EpisodeNumbering + } + return "" +} + type SubmitMarkerResponse struct { state protoimpl.MessageState `protogen:"open.v1"` SubmissionId string `protobuf:"bytes,1,opt,name=submission_id,json=submissionId,proto3" json:"submission_id,omitempty"` @@ -639,14 +699,17 @@ const file_silo_plugin_v1_marker_provider_proto_rawDesc = "" + "\atmdb_id\x18\x01 \x01(\tR\x06tmdbId\x12\x17\n" + "\aimdb_id\x18\x02 \x01(\tR\x06imdbId\x12\x17\n" + "\atvdb_id\x18\x03 \x01(\tR\x06tvdbId\x12:\n" + - "\fprovider_ids\x18\x04 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"\xa4\x02\n" + + "\fprovider_ids\x18\x04 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"\xcb\x03\n" + "\x13FetchMarkersRequest\x12\x1b\n" + "\titem_type\x18\x01 \x01(\tR\bitemType\x12D\n" + "\fexternal_ids\x18\x02 \x01(\v2!.silo.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + "\rseason_number\x18\x03 \x01(\x05R\fseasonNumber\x12%\n" + "\x0eepisode_number\x18\x04 \x01(\x05R\repisodeNumber\x12)\n" + "\x10duration_seconds\x18\x05 \x01(\x01R\x0fdurationSeconds\x123\n" + - "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\xb9\x02\n" + + "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12S\n" + + "\x14episode_external_ids\x18\a \x01(\v2!.silo.plugin.v1.MarkerExternalIDsR\x12episodeExternalIds\x12#\n" + + "\repisode_title\x18\b \x01(\tR\fepisodeTitle\x12+\n" + + "\x11episode_numbering\x18\t \x01(\tR\x10episodeNumbering\"\xb9\x02\n" + "\rMarkerSegment\x12\x18\n" + "\asegment\x18\x01 \x01(\tR\asegment\x12(\n" + "\rstart_seconds\x18\x02 \x01(\x01H\x00R\fstartSeconds\x88\x01\x01\x12$\n" + @@ -662,7 +725,7 @@ const file_silo_plugin_v1_marker_provider_proto_rawDesc = "" + "\f_end_seconds\"\x84\x01\n" + "\x14FetchMarkersResponse\x127\n" + "\amarkers\x18\x01 \x03(\v2\x1d.silo.plugin.v1.MarkerSegmentR\amarkers\x123\n" + - "\bmetadata\x18\x02 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\xb0\x03\n" + + "\bmetadata\x18\x02 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\xd7\x04\n" + "\x13SubmitMarkerRequest\x12\x1b\n" + "\titem_type\x18\x01 \x01(\tR\bitemType\x12D\n" + "\fexternal_ids\x18\x02 \x01(\v2!.silo.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + @@ -673,7 +736,11 @@ const file_silo_plugin_v1_marker_provider_proto_rawDesc = "" + "\vend_seconds\x18\a \x01(\x01H\x01R\n" + "endSeconds\x88\x01\x01\x12)\n" + "\x10duration_seconds\x18\b \x01(\x01R\x0fdurationSeconds\x123\n" + - "\bmetadata\x18\t \x01(\v2\x17.google.protobuf.StructR\bmetadataB\x10\n" + + "\bmetadata\x18\t \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12S\n" + + "\x14episode_external_ids\x18\n" + + " \x01(\v2!.silo.plugin.v1.MarkerExternalIDsR\x12episodeExternalIds\x12#\n" + + "\repisode_title\x18\v \x01(\tR\fepisodeTitle\x12+\n" + + "\x11episode_numbering\x18\f \x01(\tR\x10episodeNumberingB\x10\n" + "\x0e_start_secondsB\x0e\n" + "\f_end_seconds\"\xa0\x01\n" + "\x14SubmitMarkerResponse\x12#\n" + @@ -725,24 +792,26 @@ var file_silo_plugin_v1_marker_provider_proto_depIdxs = []int32{ 8, // 0: silo.plugin.v1.MarkerExternalIDs.provider_ids:type_name -> google.protobuf.Struct 0, // 1: silo.plugin.v1.FetchMarkersRequest.external_ids:type_name -> silo.plugin.v1.MarkerExternalIDs 8, // 2: silo.plugin.v1.FetchMarkersRequest.metadata:type_name -> google.protobuf.Struct - 8, // 3: silo.plugin.v1.MarkerSegment.metadata:type_name -> google.protobuf.Struct - 2, // 4: silo.plugin.v1.FetchMarkersResponse.markers:type_name -> silo.plugin.v1.MarkerSegment - 8, // 5: silo.plugin.v1.FetchMarkersResponse.metadata:type_name -> google.protobuf.Struct - 0, // 6: silo.plugin.v1.SubmitMarkerRequest.external_ids:type_name -> silo.plugin.v1.MarkerExternalIDs - 8, // 7: silo.plugin.v1.SubmitMarkerRequest.metadata:type_name -> google.protobuf.Struct - 8, // 8: silo.plugin.v1.SubmitMarkerResponse.metadata:type_name -> google.protobuf.Struct - 8, // 9: silo.plugin.v1.MarkerProviderStatsResponse.metadata:type_name -> google.protobuf.Struct - 1, // 10: silo.plugin.v1.MarkerProvider.FetchMarkers:input_type -> silo.plugin.v1.FetchMarkersRequest - 4, // 11: silo.plugin.v1.MarkerProvider.SubmitMarker:input_type -> silo.plugin.v1.SubmitMarkerRequest - 6, // 12: silo.plugin.v1.MarkerProvider.GetMarkerProviderStats:input_type -> silo.plugin.v1.GetMarkerProviderStatsRequest - 3, // 13: silo.plugin.v1.MarkerProvider.FetchMarkers:output_type -> silo.plugin.v1.FetchMarkersResponse - 5, // 14: silo.plugin.v1.MarkerProvider.SubmitMarker:output_type -> silo.plugin.v1.SubmitMarkerResponse - 7, // 15: silo.plugin.v1.MarkerProvider.GetMarkerProviderStats:output_type -> silo.plugin.v1.MarkerProviderStatsResponse - 13, // [13:16] is the sub-list for method output_type - 10, // [10:13] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 0, // 3: silo.plugin.v1.FetchMarkersRequest.episode_external_ids:type_name -> silo.plugin.v1.MarkerExternalIDs + 8, // 4: silo.plugin.v1.MarkerSegment.metadata:type_name -> google.protobuf.Struct + 2, // 5: silo.plugin.v1.FetchMarkersResponse.markers:type_name -> silo.plugin.v1.MarkerSegment + 8, // 6: silo.plugin.v1.FetchMarkersResponse.metadata:type_name -> google.protobuf.Struct + 0, // 7: silo.plugin.v1.SubmitMarkerRequest.external_ids:type_name -> silo.plugin.v1.MarkerExternalIDs + 8, // 8: silo.plugin.v1.SubmitMarkerRequest.metadata:type_name -> google.protobuf.Struct + 0, // 9: silo.plugin.v1.SubmitMarkerRequest.episode_external_ids:type_name -> silo.plugin.v1.MarkerExternalIDs + 8, // 10: silo.plugin.v1.SubmitMarkerResponse.metadata:type_name -> google.protobuf.Struct + 8, // 11: silo.plugin.v1.MarkerProviderStatsResponse.metadata:type_name -> google.protobuf.Struct + 1, // 12: silo.plugin.v1.MarkerProvider.FetchMarkers:input_type -> silo.plugin.v1.FetchMarkersRequest + 4, // 13: silo.plugin.v1.MarkerProvider.SubmitMarker:input_type -> silo.plugin.v1.SubmitMarkerRequest + 6, // 14: silo.plugin.v1.MarkerProvider.GetMarkerProviderStats:input_type -> silo.plugin.v1.GetMarkerProviderStatsRequest + 3, // 15: silo.plugin.v1.MarkerProvider.FetchMarkers:output_type -> silo.plugin.v1.FetchMarkersResponse + 5, // 16: silo.plugin.v1.MarkerProvider.SubmitMarker:output_type -> silo.plugin.v1.SubmitMarkerResponse + 7, // 17: silo.plugin.v1.MarkerProvider.GetMarkerProviderStats:output_type -> silo.plugin.v1.MarkerProviderStatsResponse + 15, // [15:18] is the sub-list for method output_type + 12, // [12:15] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_silo_plugin_v1_marker_provider_proto_init() } diff --git a/pkg/pluginproto/silo/plugin/v1/marker_provider_test.go b/pkg/pluginproto/silo/plugin/v1/marker_provider_test.go new file mode 100644 index 0000000..f61962b --- /dev/null +++ b/pkg/pluginproto/silo/plugin/v1/marker_provider_test.go @@ -0,0 +1,64 @@ +package pluginv1 + +import ( + "testing" + + "google.golang.org/protobuf/proto" +) + +func TestMarkerRequests_EpisodeIdentityFieldNumbers(t *testing.T) { + assertFieldNumber(t, &FetchMarkersRequest{}, "episode_external_ids", 7) + assertFieldNumber(t, &FetchMarkersRequest{}, "episode_title", 8) + assertFieldNumber(t, &SubmitMarkerRequest{}, "episode_external_ids", 10) + assertFieldNumber(t, &SubmitMarkerRequest{}, "episode_title", 11) + assertFieldNumber(t, &FetchMarkersRequest{}, "episode_numbering", 9) + assertFieldNumber(t, &SubmitMarkerRequest{}, "episode_numbering", 12) +} + +// A host that predates the episode fields sends requests without them; the +// plugin must see the series identity unchanged and empty episode fields. +func TestFetchMarkersRequest_OldPayloadDecodesWithoutEpisodeIdentity(t *testing.T) { + oldPayload, err := proto.Marshal(&FetchMarkersRequest{ + ItemType: "episode", + ExternalIds: &MarkerExternalIDs{TmdbId: "1399", TvdbId: "121361"}, + SeasonNumber: 1, + EpisodeNumber: 2, + }) + if err != nil { + t.Fatalf("marshal old-style payload: %v", err) + } + var decoded FetchMarkersRequest + if err := proto.Unmarshal(oldPayload, &decoded); err != nil { + t.Fatalf("unmarshal old-style payload: %v", err) + } + if decoded.GetExternalIds().GetTvdbId() != "121361" || decoded.GetSeasonNumber() != 1 || decoded.GetEpisodeNumber() != 2 { + t.Fatalf("decoded series identity = %+v", &decoded) + } + if decoded.GetEpisodeExternalIds().GetTvdbId() != "" || decoded.GetEpisodeTitle() != "" || decoded.GetEpisodeNumbering() != "" { + t.Fatalf("episode identity = %q/%q/%q, want empty", decoded.GetEpisodeExternalIds().GetTvdbId(), decoded.GetEpisodeTitle(), decoded.GetEpisodeNumbering()) + } +} + +func TestSubmitMarkerRequest_EpisodeIdentityRoundTrips(t *testing.T) { + data, err := proto.Marshal(&SubmitMarkerRequest{ + ItemType: "episode", + ExternalIds: &MarkerExternalIDs{TvdbId: "121361"}, + SeasonNumber: 1, + EpisodeNumber: 1, + Segment: "intro", + EpisodeExternalIds: &MarkerExternalIDs{TvdbId: "3254641", TmdbId: "63056"}, + EpisodeTitle: "Winter Is Coming", + EpisodeNumbering: "tvdb", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded SubmitMarkerRequest + if err := proto.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.GetEpisodeExternalIds().GetTvdbId() != "3254641" || decoded.GetEpisodeExternalIds().GetTmdbId() != "63056" || + decoded.GetEpisodeTitle() != "Winter Is Coming" || decoded.GetEpisodeNumbering() != "tvdb" { + t.Fatalf("decoded episode identity = %+v", &decoded) + } +} diff --git a/proto/silo/plugin/v1/marker_provider.proto b/proto/silo/plugin/v1/marker_provider.proto index 1b62f70..63cb02d 100644 --- a/proto/silo/plugin/v1/marker_provider.proto +++ b/proto/silo/plugin/v1/marker_provider.proto @@ -19,6 +19,17 @@ message MarkerExternalIDs { google.protobuf.Struct provider_ids = 4; } +// For an episode, external_ids names the series, and season_number and +// episode_number place the episode in it using the host catalog's numbering +// (season 0 holds specials). episode_external_ids names the provider episode +// records the host matched at those numbers. The host matches each +// provider's episodes by number alone, so IDs from two providers can name +// different episodes where the providers number a series differently. +// episode_numbering resolves that: it is the external ID key ("tvdb" or +// "tmdb") of the provider whose numbering the episode follows, the first in +// the library's episode chain that supplied it. episode_title is in the +// library's metadata language. The episode fields are empty for a movie and +// when the host has no value. message FetchMarkersRequest { string item_type = 1; // "movie" | "episode" MarkerExternalIDs external_ids = 2; @@ -26,6 +37,9 @@ message FetchMarkersRequest { int32 episode_number = 4; double duration_seconds = 5; google.protobuf.Struct metadata = 6; + MarkerExternalIDs episode_external_ids = 7; + string episode_title = 8; + string episode_numbering = 9; } message MarkerSegment { @@ -43,6 +57,7 @@ message FetchMarkersResponse { google.protobuf.Struct metadata = 2; } +// The item fields follow FetchMarkersRequest. message SubmitMarkerRequest { string item_type = 1; // "movie" | "episode" MarkerExternalIDs external_ids = 2; @@ -53,6 +68,9 @@ message SubmitMarkerRequest { optional double end_seconds = 7; double duration_seconds = 8; google.protobuf.Struct metadata = 9; + MarkerExternalIDs episode_external_ids = 10; + string episode_title = 11; + string episode_numbering = 12; } message SubmitMarkerResponse { From eecf6b728c74146c3a352176669ba7997e63913f Mon Sep 17 00:00:00 2001 From: "prairie-sync[bot]" Date: Sun, 18 Oct 2026 23:09:26 +0000 Subject: [PATCH 4/4] Regenerate generated files after the upstream sync Ran: command -v protoc >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq protobuf-compiler); make proto; go mod tidy; --- .../prairie/plugin/v1/marker_provider.pb.go | 38 +++--- .../prairie/plugin/v1/request_router.pb.go | 112 +++++++++--------- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go b/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go index c47ecfa..1809979 100644 --- a/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go +++ b/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go @@ -694,20 +694,20 @@ var File_prairie_plugin_v1_marker_provider_proto protoreflect.FileDescriptor const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\n" + - "$prairie/plugin/v1/marker_provider.proto\x12\x0eprairie.plugin.v1\x1a\x1cgoogle/protobuf/struct.proto\"\x9a\x01\n" + + "'prairie/plugin/v1/marker_provider.proto\x12\x11prairie.plugin.v1\x1a\x1cgoogle/protobuf/struct.proto\"\x9a\x01\n" + "\x11MarkerExternalIDs\x12\x17\n" + "\atmdb_id\x18\x01 \x01(\tR\x06tmdbId\x12\x17\n" + "\aimdb_id\x18\x02 \x01(\tR\x06imdbId\x12\x17\n" + "\atvdb_id\x18\x03 \x01(\tR\x06tvdbId\x12:\n" + - "\fprovider_ids\x18\x04 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"\xcb\x03\n" + + "\fprovider_ids\x18\x04 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"\xd1\x03\n" + "\x13FetchMarkersRequest\x12\x1b\n" + - "\titem_type\x18\x01 \x01(\tR\bitemType\x12D\n" + - "\fexternal_ids\x18\x02 \x01(\v2!.prairie.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + + "\titem_type\x18\x01 \x01(\tR\bitemType\x12G\n" + + "\fexternal_ids\x18\x02 \x01(\v2$.prairie.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + "\rseason_number\x18\x03 \x01(\x05R\fseasonNumber\x12%\n" + "\x0eepisode_number\x18\x04 \x01(\x05R\repisodeNumber\x12)\n" + "\x10duration_seconds\x18\x05 \x01(\x01R\x0fdurationSeconds\x123\n" + - "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12S\n" + - "\x14episode_external_ids\x18\a \x01(\v2!.prairie.plugin.v1.MarkerExternalIDsR\x12episodeExternalIds\x12#\n" + + "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12V\n" + + "\x14episode_external_ids\x18\a \x01(\v2$.prairie.plugin.v1.MarkerExternalIDsR\x12episodeExternalIds\x12#\n" + "\repisode_title\x18\b \x01(\tR\fepisodeTitle\x12+\n" + "\x11episode_numbering\x18\t \x01(\tR\x10episodeNumbering\"\xb9\x02\n" + "\rMarkerSegment\x12\x18\n" + @@ -722,13 +722,13 @@ const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\talgorithm\x18\x06 \x01(\tR\talgorithm\x123\n" + "\bmetadata\x18\a \x01(\v2\x17.google.protobuf.StructR\bmetadataB\x10\n" + "\x0e_start_secondsB\x0e\n" + - "\f_end_seconds\"\x84\x01\n" + - "\x14FetchMarkersResponse\x127\n" + - "\amarkers\x18\x01 \x03(\v2\x1d.prairie.plugin.v1.MarkerSegmentR\amarkers\x123\n" + - "\bmetadata\x18\x02 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\xd7\x04\n" + + "\f_end_seconds\"\x87\x01\n" + + "\x14FetchMarkersResponse\x12:\n" + + "\amarkers\x18\x01 \x03(\v2 .prairie.plugin.v1.MarkerSegmentR\amarkers\x123\n" + + "\bmetadata\x18\x02 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\xdd\x04\n" + "\x13SubmitMarkerRequest\x12\x1b\n" + - "\titem_type\x18\x01 \x01(\tR\bitemType\x12D\n" + - "\fexternal_ids\x18\x02 \x01(\v2!.prairie.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + + "\titem_type\x18\x01 \x01(\tR\bitemType\x12G\n" + + "\fexternal_ids\x18\x02 \x01(\v2$.prairie.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + "\rseason_number\x18\x03 \x01(\x05R\fseasonNumber\x12%\n" + "\x0eepisode_number\x18\x04 \x01(\x05R\repisodeNumber\x12\x18\n" + "\asegment\x18\x05 \x01(\tR\asegment\x12(\n" + @@ -736,9 +736,9 @@ const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\vend_seconds\x18\a \x01(\x01H\x01R\n" + "endSeconds\x88\x01\x01\x12)\n" + "\x10duration_seconds\x18\b \x01(\x01R\x0fdurationSeconds\x123\n" + - "\bmetadata\x18\t \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12S\n" + + "\bmetadata\x18\t \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12V\n" + "\x14episode_external_ids\x18\n" + - " \x01(\v2!.prairie.plugin.v1.MarkerExternalIDsR\x12episodeExternalIds\x12#\n" + + " \x01(\v2$.prairie.plugin.v1.MarkerExternalIDsR\x12episodeExternalIds\x12#\n" + "\repisode_title\x18\v \x01(\tR\fepisodeTitle\x12+\n" + "\x11episode_numbering\x18\f \x01(\tR\x10episodeNumberingB\x10\n" + "\x0e_start_secondsB\x0e\n" + @@ -758,11 +758,11 @@ const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\x0ecurrent_streak\x18\x06 \x01(\x05R\rcurrentStreak\x12\x1f\n" + "\vbest_streak\x18\a \x01(\x05R\n" + "bestStreak\x123\n" + - "\bmetadata\x18\b \x01(\v2\x17.google.protobuf.StructR\bmetadata2\xbc\x02\n" + - "\x0eMarkerProvider\x12Y\n" + - "\fFetchMarkers\x12#.prairie.plugin.v1.FetchMarkersRequest\x1a$.prairie.plugin.v1.FetchMarkersResponse\x12Y\n" + - "\fSubmitMarker\x12#.prairie.plugin.v1.SubmitMarkerRequest\x1a$.prairie.plugin.v1.SubmitMarkerResponse\x12t\n" + - "\x16GetMarkerProviderStats\x12-.prairie.plugin.v1.GetMarkerProviderStatsRequest\x1a+.prairie.plugin.v1.MarkerProviderStatsResponseBPZNgithub.com/prairie-server/prairie-plugin-sdk/pkg/pluginproto/prairie/plugin/v1;pluginv1b\x06proto3" + "\bmetadata\x18\b \x01(\v2\x17.google.protobuf.StructR\bmetadata2\xce\x02\n" + + "\x0eMarkerProvider\x12_\n" + + "\fFetchMarkers\x12&.prairie.plugin.v1.FetchMarkersRequest\x1a'.prairie.plugin.v1.FetchMarkersResponse\x12_\n" + + "\fSubmitMarker\x12&.prairie.plugin.v1.SubmitMarkerRequest\x1a'.prairie.plugin.v1.SubmitMarkerResponse\x12z\n" + + "\x16GetMarkerProviderStats\x120.prairie.plugin.v1.GetMarkerProviderStatsRequest\x1a..prairie.plugin.v1.MarkerProviderStatsResponseBYZWgithub.com/prairie-server/prairie-plugin-sdk/pkg/pluginproto/prairie/plugin/v1;pluginv1b\x06proto3" var ( file_prairie_plugin_v1_marker_provider_proto_rawDescOnce sync.Once diff --git a/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go b/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go index 18f8881..113c881 100644 --- a/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go +++ b/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go @@ -1521,13 +1521,13 @@ var File_prairie_plugin_v1_request_router_proto protoreflect.FileDescriptor const file_prairie_plugin_v1_request_router_proto_rawDesc = "" + "\n" + - "#prairie/plugin/v1/request_router.proto\x12\x0eprairie.plugin.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x03\n" + + "&prairie/plugin/v1/request_router.proto\x12\x11prairie.plugin.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe1\x03\n" + "\x11RequestDescriptor\x12\x1d\n" + "\n" + "media_type\x18\x01 \x01(\tR\tmediaType\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + - "\x04year\x18\x03 \x01(\x05R\x04year\x12U\n" + - "\fexternal_ids\x18\x04 \x03(\v22.prairie.plugin.v1.RequestDescriptor.ExternalIdsEntryR\vexternalIds\x12\x19\n" + + "\x04year\x18\x03 \x01(\x05R\x04year\x12X\n" + + "\fexternal_ids\x18\x04 \x03(\v25.prairie.plugin.v1.RequestDescriptor.ExternalIdsEntryR\vexternalIds\x12\x19\n" + "\bis_anime\x18\x05 \x01(\bR\aisAnime\x12*\n" + "\x11requester_user_id\x18\x06 \x01(\x03R\x0frequesterUserId\x120\n" + "\x14requester_profile_id\x18\a \x01(\tR\x12requesterProfileId\x12'\n" + @@ -1537,17 +1537,17 @@ const file_prairie_plugin_v1_request_router_proto_rawDesc = "" + " \x03(\x05R\aseasons\x1a>\n" + "\x10ExternalIdsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x96\x02\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x99\x02\n" + "\x17RequestRouterDescriptor\x12)\n" + "\x10supports_seasons\x18\x01 \x01(\bR\x0fsupportsSeasons\x12:\n" + - "\x19reports_download_progress\x18\x02 \x01(\bR\x17reportsDownloadProgress\x12>\n" + - "\awording\x18\x03 \x01(\v2$.prairie.plugin.v1.RequestRouterWordingR\awording\x12(\n" + + "\x19reports_download_progress\x18\x02 \x01(\bR\x17reportsDownloadProgress\x12A\n" + + "\awording\x18\x03 \x01(\v2'.prairie.plugin.v1.RequestRouterWordingR\awording\x12(\n" + "\x10api_key_optional\x18\x04 \x01(\bR\x0eapiKeyOptional\x12*\n" + - "\x11base_url_optional\x18\x05 \x01(\bR\x0fbaseUrlOptional\"\xb0\x01\n" + + "\x11base_url_optional\x18\x05 \x01(\bR\x0fbaseUrlOptional\"\xb6\x01\n" + "\x14RequestRouterWording\x12\x12\n" + - "\x04step\x18\x01 \x01(\tR\x04step\x12<\n" + - "\x06queued\x18\x02 \x01(\v2$.prairie.plugin.v1.RequestStatusWordingR\x06queued\x12F\n" + - "\vdownloading\x18\x03 \x01(\v2$.prairie.plugin.v1.RequestStatusWordingR\vdownloading\"D\n" + + "\x04step\x18\x01 \x01(\tR\x04step\x12?\n" + + "\x06queued\x18\x02 \x01(\v2'.prairie.plugin.v1.RequestStatusWordingR\x06queued\x12I\n" + + "\vdownloading\x18\x03 \x01(\v2'.prairie.plugin.v1.RequestStatusWordingR\vdownloading\"D\n" + "\x14RequestStatusWording\x12\x14\n" + "\x05label\x18\x01 \x01(\tR\x05label\x12\x16\n" + "\x06detail\x18\x02 \x01(\tR\x06detail\"\x87\x01\n" + @@ -1558,12 +1558,12 @@ const file_prairie_plugin_v1_request_router_proto_rawDesc = "" + "\x06config\x18\x04 \x01(\v2\x17.google.protobuf.StructR\x06config\"6\n" + "\x10RequestedQuality\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + - "\x04is4k\x18\x02 \x01(\bR\x04is4k\"\xf6\x01\n" + + "\x04is4k\x18\x02 \x01(\bR\x04is4k\"\xff\x01\n" + "\x0eFulfillRequest\x12#\n" + - "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12;\n" + - "\arequest\x18\x02 \x01(\v2!.prairie.plugin.v1.RequestDescriptorR\arequest\x12>\n" + - "\tqualities\x18\x03 \x03(\v2 .prairie.plugin.v1.RequestedQualityR\tqualities\x12B\n" + - "\vconnections\x18\x04 \x03(\v2 .prairie.plugin.v1.RouterConnectionR\vconnections\"\x8e\x02\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12>\n" + + "\arequest\x18\x02 \x01(\v2$.prairie.plugin.v1.RequestDescriptorR\arequest\x12A\n" + + "\tqualities\x18\x03 \x03(\v2#.prairie.plugin.v1.RequestedQualityR\tqualities\x12E\n" + + "\vconnections\x18\x04 \x03(\v2#.prairie.plugin.v1.RouterConnectionR\vconnections\"\x91\x02\n" + "\x11FulfillmentTarget\x12\x18\n" + "\aquality\x18\x01 \x01(\tR\aquality\x12#\n" + "\rconnection_id\x18\x02 \x01(\tR\fconnectionId\x12\x1f\n" + @@ -1571,21 +1571,21 @@ const file_prairie_plugin_v1_request_router_proto_rawDesc = "" + "externalId\x12'\n" + "\x0fexternal_status\x18\x04 \x01(\tR\x0eexternalStatus\x12\x16\n" + "\x06status\x18\x05 \x01(\tR\x06status\x12\x18\n" + - "\amessage\x18\x06 \x01(\tR\amessage\x12>\n" + - "\awording\x18\a \x01(\v2$.prairie.plugin.v1.RequestStatusWordingR\awording\"h\n" + - "\x0fFulfillResponse\x12;\n" + - "\atargets\x18\x01 \x03(\v2!.prairie.plugin.v1.FulfillmentTargetR\atargets\x12\x18\n" + + "\amessage\x18\x06 \x01(\tR\amessage\x12A\n" + + "\awording\x18\a \x01(\v2'.prairie.plugin.v1.RequestStatusWordingR\awording\"k\n" + + "\x0fFulfillResponse\x12>\n" + + "\atargets\x18\x01 \x03(\v2$.prairie.plugin.v1.FulfillmentTargetR\atargets\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\"k\n" + "\tTargetRef\x12\x18\n" + "\aquality\x18\x01 \x01(\tR\aquality\x12#\n" + "\rconnection_id\x18\x02 \x01(\tR\fconnectionId\x12\x1f\n" + "\vexternal_id\x18\x03 \x01(\tR\n" + - "externalId\"\xef\x01\n" + + "externalId\"\xf8\x01\n" + "\x12CheckStatusRequest\x12#\n" + - "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12;\n" + - "\arequest\x18\x02 \x01(\v2!.prairie.plugin.v1.RequestDescriptorR\arequest\x123\n" + - "\atargets\x18\x03 \x03(\v2\x19.prairie.plugin.v1.TargetRefR\atargets\x12B\n" + - "\vconnections\x18\x04 \x03(\v2 .prairie.plugin.v1.RouterConnectionR\vconnections\"\xd5\x01\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12>\n" + + "\arequest\x18\x02 \x01(\v2$.prairie.plugin.v1.RequestDescriptorR\arequest\x126\n" + + "\atargets\x18\x03 \x03(\v2\x1c.prairie.plugin.v1.TargetRefR\atargets\x12E\n" + + "\vconnections\x18\x04 \x03(\v2#.prairie.plugin.v1.RouterConnectionR\vconnections\"\xd5\x01\n" + "\x10DownloadProgress\x12\x14\n" + "\x05phase\x18\x01 \x01(\tR\x05phase\x12\x1f\n" + "\vbytes_total\x18\x02 \x01(\x03R\n" + @@ -1593,59 +1593,59 @@ const file_prairie_plugin_v1_request_router_proto_rawDesc = "" + "\n" + "bytes_left\x18\x03 \x01(\x03R\tbytesLeft\x12M\n" + "\x14estimated_completion\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x13estimatedCompletion\x12\x1c\n" + - "\tdownloads\x18\x05 \x01(\x05R\tdownloads\"\xa6\x02\n" + + "\tdownloads\x18\x05 \x01(\x05R\tdownloads\"\xac\x02\n" + "\fTargetStatus\x12\x18\n" + "\aquality\x18\x01 \x01(\tR\aquality\x12#\n" + "\rconnection_id\x18\x02 \x01(\tR\fconnectionId\x12\x16\n" + "\x06status\x18\x03 \x01(\tR\x06status\x12'\n" + "\x0fexternal_status\x18\x04 \x01(\tR\x0eexternalStatus\x12\x18\n" + - "\amessage\x18\x05 \x01(\tR\amessage\x12<\n" + - "\bprogress\x18\x06 \x01(\v2 .prairie.plugin.v1.DownloadProgressR\bprogress\x12>\n" + - "\awording\x18\a \x01(\v2$.prairie.plugin.v1.RequestStatusWordingR\awording\"O\n" + - "\x13CheckStatusResponse\x128\n" + - "\bstatuses\x18\x01 \x03(\v2\x1c.prairie.plugin.v1.TargetStatusR\bstatuses\":\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12?\n" + + "\bprogress\x18\x06 \x01(\v2#.prairie.plugin.v1.DownloadProgressR\bprogress\x12A\n" + + "\awording\x18\a \x01(\v2'.prairie.plugin.v1.RequestStatusWordingR\awording\"R\n" + + "\x13CheckStatusResponse\x12;\n" + + "\bstatuses\x18\x01 \x03(\v2\x1f.prairie.plugin.v1.TargetStatusR\bstatuses\":\n" + "\fConfigOption\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + - "\x05label\x18\x02 \x01(\tR\x05label\"J\n" + - "\x10ConfigOptionList\x126\n" + - "\aoptions\x18\x01 \x03(\v2\x1c.prairie.plugin.v1.ConfigOptionR\aoptions\"\x81\x01\n" + + "\x05label\x18\x02 \x01(\tR\x05label\"M\n" + + "\x10ConfigOptionList\x129\n" + + "\aoptions\x18\x01 \x03(\v2\x1f.prairie.plugin.v1.ConfigOptionR\aoptions\"\x84\x01\n" + "\x18ListConfigOptionsRequest\x12#\n" + - "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12@\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12C\n" + "\n" + - "connection\x18\x02 \x01(\v2 .prairie.plugin.v1.RouterConnectionR\n" + - "connection\"\xe9\x01\n" + - "\x19ListConfigOptionsResponse\x12g\n" + - "\x10options_by_field\x18\x01 \x03(\v2=.prairie.plugin.v1.ListConfigOptionsResponse.OptionsByFieldEntryR\x0eoptionsByField\x1ac\n" + + "connection\x18\x02 \x01(\v2#.prairie.plugin.v1.RouterConnectionR\n" + + "connection\"\xef\x01\n" + + "\x19ListConfigOptionsResponse\x12j\n" + + "\x10options_by_field\x18\x01 \x03(\v2@.prairie.plugin.v1.ListConfigOptionsResponse.OptionsByFieldEntryR\x0eoptionsByField\x1af\n" + "\x13OptionsByFieldEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x126\n" + - "\x05value\x18\x02 \x01(\v2 .prairie.plugin.v1.ConfigOptionListR\x05value:\x028\x01\"~\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x129\n" + + "\x05value\x18\x02 \x01(\v2#.prairie.plugin.v1.ConfigOptionListR\x05value:\x028\x01\"\x81\x01\n" + "\x15TestConnectionRequest\x12#\n" + - "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12@\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12C\n" + "\n" + - "connection\x18\x02 \x01(\v2 .prairie.plugin.v1.RouterConnectionR\n" + + "connection\x18\x02 \x01(\v2#.prairie.plugin.v1.RouterConnectionR\n" + "connection\"B\n" + "\x16TestConnectionResponse\x12\x0e\n" + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\xb6\x01\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\xbc\x01\n" + "\x0fValidateRequest\x12#\n" + - "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12@\n" + + "\rcapability_id\x18\x01 \x01(\tR\fcapabilityId\x12C\n" + "\n" + - "connection\x18\x02 \x01(\v2 .prairie.plugin.v1.RouterConnectionR\n" + - "connection\x12<\n" + - "\bsiblings\x18\x03 \x03(\v2 .prairie.plugin.v1.RouterConnectionR\bsiblings\"\xc7\x01\n" + - "\x10ValidateResponse\x12T\n" + - "\ffield_errors\x18\x01 \x03(\v21.prairie.plugin.v1.ValidateResponse.FieldErrorsEntryR\vfieldErrors\x12\x1d\n" + + "connection\x18\x02 \x01(\v2#.prairie.plugin.v1.RouterConnectionR\n" + + "connection\x12?\n" + + "\bsiblings\x18\x03 \x03(\v2#.prairie.plugin.v1.RouterConnectionR\bsiblings\"\xca\x01\n" + + "\x10ValidateResponse\x12W\n" + + "\ffield_errors\x18\x01 \x03(\v24.prairie.plugin.v1.ValidateResponse.FieldErrorsEntryR\vfieldErrors\x12\x1d\n" + "\n" + "form_error\x18\x02 \x01(\tR\tformError\x1a>\n" + "\x10FieldErrorsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x012\xcd\x03\n" + - "\rRequestRouter\x12J\n" + - "\aFulfill\x12\x1e.prairie.plugin.v1.FulfillRequest\x1a\x1f.prairie.plugin.v1.FulfillResponse\x12V\n" + - "\vCheckStatus\x12\".prairie.plugin.v1.CheckStatusRequest\x1a#.prairie.plugin.v1.CheckStatusResponse\x12h\n" + - "\x11ListConfigOptions\x12(.prairie.plugin.v1.ListConfigOptionsRequest\x1a).prairie.plugin.v1.ListConfigOptionsResponse\x12_\n" + - "\x0eTestConnection\x12%.prairie.plugin.v1.TestConnectionRequest\x1a&.prairie.plugin.v1.TestConnectionResponse\x12M\n" + - "\bValidate\x12\x1f.prairie.plugin.v1.ValidateRequest\x1a .prairie.plugin.v1.ValidateResponseBPZNgithub.com/prairie-server/prairie-plugin-sdk/pkg/pluginproto/prairie/plugin/v1;pluginv1b\x06proto3" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x012\xeb\x03\n" + + "\rRequestRouter\x12P\n" + + "\aFulfill\x12!.prairie.plugin.v1.FulfillRequest\x1a\".prairie.plugin.v1.FulfillResponse\x12\\\n" + + "\vCheckStatus\x12%.prairie.plugin.v1.CheckStatusRequest\x1a&.prairie.plugin.v1.CheckStatusResponse\x12n\n" + + "\x11ListConfigOptions\x12+.prairie.plugin.v1.ListConfigOptionsRequest\x1a,.prairie.plugin.v1.ListConfigOptionsResponse\x12e\n" + + "\x0eTestConnection\x12(.prairie.plugin.v1.TestConnectionRequest\x1a).prairie.plugin.v1.TestConnectionResponse\x12S\n" + + "\bValidate\x12\".prairie.plugin.v1.ValidateRequest\x1a#.prairie.plugin.v1.ValidateResponseBYZWgithub.com/prairie-server/prairie-plugin-sdk/pkg/pluginproto/prairie/plugin/v1;pluginv1b\x06proto3" var ( file_prairie_plugin_v1_request_router_proto_rawDescOnce sync.Once