diff --git a/docs/compatibility.md b/docs/compatibility.md index fcc1736..fb8c37b 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 @@ -160,6 +165,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. Prairie reads a bare +plugin binary's manifest strictly, so a Prairie host that predates the flags +refuses to install a binary that declares them; install it from an archive +there (Prairie-Server/prairie-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 @@ -172,6 +191,17 @@ verify images by the per-image field rather than assume a filtered response. - A plugin binary should return the same manifest shape that Prairie 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/prairie/plugin/v1/marker_provider.pb.go b/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go index 4c3bea5..1809979 100644 --- a/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go +++ b/pkg/pluginproto/prairie/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_prairie_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\"\xa7\x02\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\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\"\xb9\x02\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" + "\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_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\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\"\xb3\x03\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\x12G\n" + "\fexternal_ids\x18\x02 \x01(\v2$.prairie.plugin.v1.MarkerExternalIDsR\vexternalIds\x12#\n" + @@ -673,7 +736,11 @@ 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\bmetadataB\x10\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" + + "\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_prairie_plugin_v1_marker_provider_proto_depIdxs = []int32{ 8, // 0: prairie.plugin.v1.MarkerExternalIDs.provider_ids:type_name -> google.protobuf.Struct 0, // 1: prairie.plugin.v1.FetchMarkersRequest.external_ids:type_name -> prairie.plugin.v1.MarkerExternalIDs 8, // 2: prairie.plugin.v1.FetchMarkersRequest.metadata:type_name -> google.protobuf.Struct - 8, // 3: prairie.plugin.v1.MarkerSegment.metadata:type_name -> google.protobuf.Struct - 2, // 4: prairie.plugin.v1.FetchMarkersResponse.markers:type_name -> prairie.plugin.v1.MarkerSegment - 8, // 5: prairie.plugin.v1.FetchMarkersResponse.metadata:type_name -> google.protobuf.Struct - 0, // 6: prairie.plugin.v1.SubmitMarkerRequest.external_ids:type_name -> prairie.plugin.v1.MarkerExternalIDs - 8, // 7: prairie.plugin.v1.SubmitMarkerRequest.metadata:type_name -> google.protobuf.Struct - 8, // 8: prairie.plugin.v1.SubmitMarkerResponse.metadata:type_name -> google.protobuf.Struct - 8, // 9: prairie.plugin.v1.MarkerProviderStatsResponse.metadata:type_name -> google.protobuf.Struct - 1, // 10: prairie.plugin.v1.MarkerProvider.FetchMarkers:input_type -> prairie.plugin.v1.FetchMarkersRequest - 4, // 11: prairie.plugin.v1.MarkerProvider.SubmitMarker:input_type -> prairie.plugin.v1.SubmitMarkerRequest - 6, // 12: prairie.plugin.v1.MarkerProvider.GetMarkerProviderStats:input_type -> prairie.plugin.v1.GetMarkerProviderStatsRequest - 3, // 13: prairie.plugin.v1.MarkerProvider.FetchMarkers:output_type -> prairie.plugin.v1.FetchMarkersResponse - 5, // 14: prairie.plugin.v1.MarkerProvider.SubmitMarker:output_type -> prairie.plugin.v1.SubmitMarkerResponse - 7, // 15: prairie.plugin.v1.MarkerProvider.GetMarkerProviderStats:output_type -> prairie.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: prairie.plugin.v1.FetchMarkersRequest.episode_external_ids:type_name -> prairie.plugin.v1.MarkerExternalIDs + 8, // 4: prairie.plugin.v1.MarkerSegment.metadata:type_name -> google.protobuf.Struct + 2, // 5: prairie.plugin.v1.FetchMarkersResponse.markers:type_name -> prairie.plugin.v1.MarkerSegment + 8, // 6: prairie.plugin.v1.FetchMarkersResponse.metadata:type_name -> google.protobuf.Struct + 0, // 7: prairie.plugin.v1.SubmitMarkerRequest.external_ids:type_name -> prairie.plugin.v1.MarkerExternalIDs + 8, // 8: prairie.plugin.v1.SubmitMarkerRequest.metadata:type_name -> google.protobuf.Struct + 0, // 9: prairie.plugin.v1.SubmitMarkerRequest.episode_external_ids:type_name -> prairie.plugin.v1.MarkerExternalIDs + 8, // 10: prairie.plugin.v1.SubmitMarkerResponse.metadata:type_name -> google.protobuf.Struct + 8, // 11: prairie.plugin.v1.MarkerProviderStatsResponse.metadata:type_name -> google.protobuf.Struct + 1, // 12: prairie.plugin.v1.MarkerProvider.FetchMarkers:input_type -> prairie.plugin.v1.FetchMarkersRequest + 4, // 13: prairie.plugin.v1.MarkerProvider.SubmitMarker:input_type -> prairie.plugin.v1.SubmitMarkerRequest + 6, // 14: prairie.plugin.v1.MarkerProvider.GetMarkerProviderStats:input_type -> prairie.plugin.v1.GetMarkerProviderStatsRequest + 3, // 15: prairie.plugin.v1.MarkerProvider.FetchMarkers:output_type -> prairie.plugin.v1.FetchMarkersResponse + 5, // 16: prairie.plugin.v1.MarkerProvider.SubmitMarker:output_type -> prairie.plugin.v1.SubmitMarkerResponse + 7, // 17: prairie.plugin.v1.MarkerProvider.GetMarkerProviderStats:output_type -> prairie.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_prairie_plugin_v1_marker_provider_proto_init() } diff --git a/pkg/pluginproto/prairie/plugin/v1/marker_provider_test.go b/pkg/pluginproto/prairie/plugin/v1/marker_provider_test.go new file mode 100644 index 0000000..f61962b --- /dev/null +++ b/pkg/pluginproto/prairie/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/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go b/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go index db62798..113c881 100644 --- a/pkg/pluginproto/prairie/plugin/v1/request_router.pb.go +++ b/pkg/pluginproto/prairie/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_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\"\xc3\x01\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\x12A\n" + - "\awording\x18\x03 \x01(\v2'.prairie.plugin.v1.RequestRouterWordingR\awording\"\xb6\x01\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\"\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\x12I\n" + diff --git a/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider.pb.go b/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider.pb.go index 76e0ff9..1cae990 100644 --- a/pkg/pluginproto/prairie/plugin/v1/watch_sync_provider.pb.go +++ b/pkg/pluginproto/prairie/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=prairie.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/convert/request_router_test.go b/pkg/pluginsdk/convert/request_router_test.go index 6f26f70..31030df 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/manifest.go b/pkg/pluginsdk/manifest/manifest.go index 64ccad5..888d105 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/request_router_test.go b/pkg/pluginsdk/manifest/request_router_test.go index 0986d02..c3801aa 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":"prairie.requests-local", "version":"1.0.0", "prairie_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":"prairie.requests-arr", "version":"1.0.0", "prairie_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/pkg/pluginsdk/manifest/watch_sync_provider_test.go b/pkg/pluginsdk/manifest/watch_sync_provider_test.go index 2cc9bd8..c28b7a5 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/prairie/plugin/v1/marker_provider.proto b/proto/prairie/plugin/v1/marker_provider.proto index fc429e2..413a9a1 100644 --- a/proto/prairie/plugin/v1/marker_provider.proto +++ b/proto/prairie/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 { diff --git a/proto/prairie/plugin/v1/request_router.proto b/proto/prairie/plugin/v1/request_router.proto index a808e65..1f401a3 100644 --- a/proto/prairie/plugin/v1/request_router.proto +++ b/proto/prairie/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) diff --git a/proto/prairie/plugin/v1/watch_sync_provider.proto b/proto/prairie/plugin/v1/watch_sync_provider.proto index ca57ffb..f32f421 100644 --- a/proto/prairie/plugin/v1/watch_sync_provider.proto +++ b/proto/prairie/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;