diff --git a/docs/compatibility.md b/docs/compatibility.md index fb8c37b..8f94eb7 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -179,6 +179,25 @@ 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. +`MetadataItem.overview_language` and the `title_language` and +`overview_language` fields on `SeasonRecord` and `EpisodeRecord` (added in +v0.29.0) are plain strings, like the older `MetadataItem.title_language`. +Empty means the text is in the requested language, or the plugin cannot tell; +hosts read every plugin built before the fields existed that way. A plugin +sets one when the provider had no text in the requested language and it +returned another language's text instead, such as a record's original-language +overview. Hosts rank that text below another provider's text in the requested +language and may replace it with text fetched in a fallback language. Hosts +that predate the fields ignore them. + +The marker fields added in v0.30.0 are plain scalars, so they have no +presence. A host that predates them sends an empty `file_hash`, a `file_size` +of 0, `accepts_absent` false, and submissions without `source`, `algorithm`, +`confidence`, or `absent`. A marker provider must not return a +`MarkerSegment` with `absent` set unless the request's `accepts_absent` is +true: an older host reads a segment without bounds as covering the whole +file. See [marker-provider.md](marker-provider.md). + 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/docs/marker-provider.md b/docs/marker-provider.md new file mode 100644 index 0000000..988170d --- /dev/null +++ b/docs/marker-provider.md @@ -0,0 +1,99 @@ +# Marker providers (`marker_provider.v1`) + +A marker provider supplies intro, credits, recap, and preview markers for +movies and episodes, and can accept markers the host submits. The host owns +local detection, provider order, the contribution settings, and when to fetch +or submit. The plugin talks to its marker service. + +## Manifest + +Declare the capability with free-form `metadata`. The host reads the keys +below from it; there is no typed descriptor. + +```json +{ + "type": "marker_provider.v1", + "id": "introdb", + "display_name": "TheIntroDB", + "metadata": { + "required_external_ids": ["tmdb"], + "default_fetch_priority": 100 + } +} +``` + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `default_fetch_priority` | number | next free | Fetch priority the provider starts with when first configured. The host asks providers in ascending order. | +| `supports_contribution` | bool | true | `false` makes the provider fetch-only, and the host never calls `SubmitMarker`. | +| `required_external_ids` | list of strings | none | External ID keys (`tmdb`, `tvdb`, `imdb`) the title must have before the host submits to the provider. All of them are required. | +| `default_contribute_enabled` | bool | false | Contribution starts on when the provider is first configured. | +| `default_contribute_auto_local` | bool | false | Automatic sharing of local detections starts on. | +| `default_contribute_min_confidence` | number, 0–1 | 0.95 | Starting minimum confidence for automatic sharing. | +| `supports_amend` | bool | false | The provider replaces a server's earlier submission for the same file and kind, so the host may resubmit when the marker changes. | +| `supports_absent` | bool | false | The provider accepts `absent` submissions. | + +The defaults apply only when the host first creates the provider's +configuration, and an administrator's later changes win. A host may ignore +`default_contribute_enabled` and `default_contribute_auto_local` when its +administrator has chosen not to share by default. Hosts that predate a key +ignore it, so declaring one does not raise the plugin's minimum host version. +The SDK does not read these keys, so declaring one needs no particular SDK +version. Only hosts that implement a key honour it. + +## Fetching + +`FetchMarkersRequest` names the item by its title IDs (`external_ids`) and, +for an episode, by season and episode number, `episode_external_ids`, +`episode_title`, and `episode_numbering`. The comments in +[`marker_provider.proto`](../proto/prairie/plugin/v1/marker_provider.proto) give +the exact rules. + +Since v0.30.0 the request also carries: + +- `file_hash`: the file's OpenSubtitles hash (16 lowercase hex characters), + or empty when the host has none. The hash covers the file size and the + first and last 64 KiB, so a remux or re-encode gets a new hash. It is a + fingerprint, not proof that two files hold the same bytes: different files + can share one. +- `file_size`: the file size in bytes, or 0 when unknown. +- `accepts_absent`: the host understands `MarkerSegment.absent`. + +`file_hash` is the raw OpenSubtitles hash, and it identifies a file across +every server that holds the same bytes. A plugin must not send it off the +machine unhashed. Hash it before sending, for example +`hex(sha256(file_hash))[0:16]` as Prairie's marker service does. An unsalted +hash like this hides the raw value, but anyone who holds the OpenSubtitles +hash can compute it, and every service that uses the same scheme gets the +same identifier. Use a service-specific salt or HMAC key when the service +must not be linkable to others. + +A `MarkerSegment` with `absent` set asserts that the item has no segment of +that kind. Its `start_seconds` and `end_seconds` are unset, and `confidence` +applies to the absence. A provider must not return absent segments unless +`accepts_absent` is true: a host older than the field reads a segment without +bounds as covering the whole file. + +## Submitting + +`SubmitMarkerRequest` names the item the same way as `FetchMarkersRequest` +and carries one segment with its bounds. Since v0.30.0 it also carries: + +- `file_hash` and `file_size`, as in `FetchMarkersRequest`, with the same + rule about `file_hash`. +- `source`: `scanner` for a local detection, including chapters, or `manual` + for a marker a person edited. Empty from older hosts. +- `algorithm`: the host's algorithm ID for the marker, such as + `chromaprint:dialogue:v4`, or `manual:v1`. +- `confidence`: the host's confidence in the marker, 0–1. +- `absent`: the submission says the item has no segment of this kind, and + the bounds are unset. Hosts send it only to providers that declare + `supports_absent`. + +## Compatibility + +All the v0.30.0 fields are additive. A host older than v0.30.0 leaves them +empty: no file facts, `accepts_absent` false, and no `source`, `algorithm`, +`confidence`, or `absent` on submissions. Providers must handle an empty +`file_hash` and a `file_size` of 0, and treat a submission with an empty +`source` as one whose origin is unknown. diff --git a/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go b/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go index 1809979..585dc38 100644 --- a/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go +++ b/pkg/pluginproto/prairie/plugin/v1/marker_provider.pb.go @@ -112,8 +112,19 @@ type FetchMarkersRequest struct { 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 + // OpenSubtitles hash of the file (16 lowercase hex), or empty when the host + // has none. It covers the file size and the first and last 64 KiB, so a + // remux or re-encode gets a new hash, but two different files can share + // one. A provider must not send it off the machine unhashed. + FileHash string `protobuf:"bytes,10,opt,name=file_hash,json=fileHash,proto3" json:"file_hash,omitempty"` + // File size in bytes, or 0 when unknown. + FileSize int64 `protobuf:"varint,11,opt,name=file_size,json=fileSize,proto3" json:"file_size,omitempty"` + // The host understands MarkerSegment.absent. A provider must not return + // absent segments when this is false: older hosts read a segment without + // bounds as covering the whole file. + AcceptsAbsent bool `protobuf:"varint,12,opt,name=accepts_absent,json=acceptsAbsent,proto3" json:"accepts_absent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *FetchMarkersRequest) Reset() { @@ -209,6 +220,27 @@ func (x *FetchMarkersRequest) GetEpisodeNumbering() string { return "" } +func (x *FetchMarkersRequest) GetFileHash() string { + if x != nil { + return x.FileHash + } + return "" +} + +func (x *FetchMarkersRequest) GetFileSize() int64 { + if x != nil { + return x.FileSize + } + return 0 +} + +func (x *FetchMarkersRequest) GetAcceptsAbsent() bool { + if x != nil { + return x.AcceptsAbsent + } + return false +} + 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" @@ -218,8 +250,11 @@ type MarkerSegment struct { SubmissionCount int32 `protobuf:"varint,5,opt,name=submission_count,json=submissionCount,proto3" json:"submission_count,omitempty"` Algorithm string `protobuf:"bytes,6,opt,name=algorithm,proto3" json:"algorithm,omitempty"` Metadata *structpb.Struct `protobuf:"bytes,7,opt,name=metadata,proto3" json:"metadata,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The provider asserts the item has no segment of this kind. start_seconds + // and end_seconds are unset; confidence applies to the absence. + Absent bool `protobuf:"varint,8,opt,name=absent,proto3" json:"absent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MarkerSegment) Reset() { @@ -301,6 +336,13 @@ func (x *MarkerSegment) GetMetadata() *structpb.Struct { return nil } +func (x *MarkerSegment) GetAbsent() bool { + if x != nil { + return x.Absent + } + return false +} + type FetchMarkersResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Markers []*MarkerSegment `protobuf:"bytes,1,rep,name=markers,proto3" json:"markers,omitempty"` @@ -368,8 +410,22 @@ type SubmitMarkerRequest struct { 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 + FileHash string `protobuf:"bytes,13,opt,name=file_hash,json=fileHash,proto3" json:"file_hash,omitempty"` // as FetchMarkersRequest.file_hash + FileSize int64 `protobuf:"varint,14,opt,name=file_size,json=fileSize,proto3" json:"file_size,omitempty"` + // Where the host's marker came from: "scanner" (local detection, including + // chapters) or "manual" (edited by a person). Empty from hosts older than + // this field. + Source string `protobuf:"bytes,15,opt,name=source,proto3" json:"source,omitempty"` + // The host's algorithm ID for the marker, e.g. "chromaprint:dialogue:v4", + // or "manual:v1". + Algorithm string `protobuf:"bytes,16,opt,name=algorithm,proto3" json:"algorithm,omitempty"` + // The host's confidence in the marker, 0-1. + Confidence float64 `protobuf:"fixed64,17,opt,name=confidence,proto3" json:"confidence,omitempty"` + // The submission says the item has no segment of this kind. start_seconds + // and end_seconds are unset. + Absent bool `protobuf:"varint,18,opt,name=absent,proto3" json:"absent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SubmitMarkerRequest) Reset() { @@ -486,6 +542,48 @@ func (x *SubmitMarkerRequest) GetEpisodeNumbering() string { return "" } +func (x *SubmitMarkerRequest) GetFileHash() string { + if x != nil { + return x.FileHash + } + return "" +} + +func (x *SubmitMarkerRequest) GetFileSize() int64 { + if x != nil { + return x.FileSize + } + return 0 +} + +func (x *SubmitMarkerRequest) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *SubmitMarkerRequest) GetAlgorithm() string { + if x != nil { + return x.Algorithm + } + return "" +} + +func (x *SubmitMarkerRequest) GetConfidence() float64 { + if x != nil { + return x.Confidence + } + return 0 +} + +func (x *SubmitMarkerRequest) GetAbsent() bool { + if x != nil { + return x.Absent + } + return false +} + 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"` @@ -699,7 +797,7 @@ 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\"\xd1\x03\n" + + "\fprovider_ids\x18\x04 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"\xb2\x04\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" + @@ -709,7 +807,11 @@ const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\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" + + "\x11episode_numbering\x18\t \x01(\tR\x10episodeNumbering\x12\x1b\n" + + "\tfile_hash\x18\n" + + " \x01(\tR\bfileHash\x12\x1b\n" + + "\tfile_size\x18\v \x01(\x03R\bfileSize\x12%\n" + + "\x0eaccepts_absent\x18\f \x01(\bR\racceptsAbsent\"\xd1\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" + @@ -720,12 +822,13 @@ const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "confidence\x12)\n" + "\x10submission_count\x18\x05 \x01(\x05R\x0fsubmissionCount\x12\x1c\n" + "\talgorithm\x18\x06 \x01(\tR\talgorithm\x123\n" + - "\bmetadata\x18\a \x01(\v2\x17.google.protobuf.StructR\bmetadataB\x10\n" + + "\bmetadata\x18\a \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12\x16\n" + + "\x06absent\x18\b \x01(\bR\x06absentB\x10\n" + "\x0e_start_secondsB\x0e\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" + + "\bmetadata\x18\x02 \x01(\v2\x17.google.protobuf.StructR\bmetadata\"\x85\x06\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" + @@ -740,7 +843,15 @@ const file_prairie_plugin_v1_marker_provider_proto_rawDesc = "" + "\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" + + "\x11episode_numbering\x18\f \x01(\tR\x10episodeNumbering\x12\x1b\n" + + "\tfile_hash\x18\r \x01(\tR\bfileHash\x12\x1b\n" + + "\tfile_size\x18\x0e \x01(\x03R\bfileSize\x12\x16\n" + + "\x06source\x18\x0f \x01(\tR\x06source\x12\x1c\n" + + "\talgorithm\x18\x10 \x01(\tR\talgorithm\x12\x1e\n" + + "\n" + + "confidence\x18\x11 \x01(\x01R\n" + + "confidence\x12\x16\n" + + "\x06absent\x18\x12 \x01(\bR\x06absentB\x10\n" + "\x0e_start_secondsB\x0e\n" + "\f_end_seconds\"\xa0\x01\n" + "\x14SubmitMarkerResponse\x12#\n" + diff --git a/pkg/pluginproto/prairie/plugin/v1/marker_provider_test.go b/pkg/pluginproto/prairie/plugin/v1/marker_provider_test.go index f61962b..e3e6e75 100644 --- a/pkg/pluginproto/prairie/plugin/v1/marker_provider_test.go +++ b/pkg/pluginproto/prairie/plugin/v1/marker_provider_test.go @@ -15,6 +15,19 @@ func TestMarkerRequests_EpisodeIdentityFieldNumbers(t *testing.T) { assertFieldNumber(t, &SubmitMarkerRequest{}, "episode_numbering", 12) } +func TestMarkerMessages_FileFactsAndAbsentFieldNumbers(t *testing.T) { + assertFieldNumber(t, &FetchMarkersRequest{}, "file_hash", 10) + assertFieldNumber(t, &FetchMarkersRequest{}, "file_size", 11) + assertFieldNumber(t, &FetchMarkersRequest{}, "accepts_absent", 12) + assertFieldNumber(t, &MarkerSegment{}, "absent", 8) + assertFieldNumber(t, &SubmitMarkerRequest{}, "file_hash", 13) + assertFieldNumber(t, &SubmitMarkerRequest{}, "file_size", 14) + assertFieldNumber(t, &SubmitMarkerRequest{}, "source", 15) + assertFieldNumber(t, &SubmitMarkerRequest{}, "algorithm", 16) + assertFieldNumber(t, &SubmitMarkerRequest{}, "confidence", 17) + assertFieldNumber(t, &SubmitMarkerRequest{}, "absent", 18) +} + // 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) { @@ -39,6 +52,48 @@ func TestFetchMarkersRequest_OldPayloadDecodesWithoutEpisodeIdentity(t *testing. } } +// A host that predates the file facts sends requests without them, and an +// absent segment reaches any host with no bounds, which an older host reads +// as covering the whole file. +func TestMarkerMessages_OldPayloadsAndAbsentSegmentPresence(t *testing.T) { + fetchBytes, err := proto.Marshal(&FetchMarkersRequest{ItemType: "movie", ExternalIds: &MarkerExternalIDs{TmdbId: "603"}}) + if err != nil { + t.Fatalf("marshal fetch: %v", err) + } + var fetch FetchMarkersRequest + if err := proto.Unmarshal(fetchBytes, &fetch); err != nil { + t.Fatalf("unmarshal fetch: %v", err) + } + if fetch.GetFileHash() != "" || fetch.GetFileSize() != 0 || fetch.GetAcceptsAbsent() { + t.Fatalf("fetch file facts = %q/%d/%t, want empty", fetch.GetFileHash(), fetch.GetFileSize(), fetch.GetAcceptsAbsent()) + } + + submitBytes, err := proto.Marshal(&SubmitMarkerRequest{ItemType: "movie", Segment: "intro", StartSeconds: proto.Float64(0), EndSeconds: proto.Float64(30)}) + if err != nil { + t.Fatalf("marshal submit: %v", err) + } + var submit SubmitMarkerRequest + if err := proto.Unmarshal(submitBytes, &submit); err != nil { + t.Fatalf("unmarshal submit: %v", err) + } + if submit.GetFileHash() != "" || submit.GetFileSize() != 0 || submit.GetSource() != "" || + submit.GetAlgorithm() != "" || submit.GetConfidence() != 0 || submit.GetAbsent() { + t.Fatalf("submit new fields not empty: %+v", &submit) + } + + segBytes, err := proto.Marshal(&MarkerSegment{Segment: "intro", Absent: true, Confidence: 0.8}) + if err != nil { + t.Fatalf("marshal segment: %v", err) + } + var seg MarkerSegment + if err := proto.Unmarshal(segBytes, &seg); err != nil { + t.Fatalf("unmarshal segment: %v", err) + } + if !seg.GetAbsent() || seg.StartSeconds != nil || seg.EndSeconds != nil { + t.Fatalf("absent segment = absent %t start %v end %v, want absent with no bounds", seg.GetAbsent(), seg.StartSeconds, seg.EndSeconds) + } +} + func TestSubmitMarkerRequest_EpisodeIdentityRoundTrips(t *testing.T) { data, err := proto.Marshal(&SubmitMarkerRequest{ ItemType: "episode", diff --git a/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go b/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go index 7e4d19b..986fef6 100644 --- a/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go +++ b/pkg/pluginproto/prairie/plugin/v1/metadata_provider.pb.go @@ -620,8 +620,12 @@ type MetadataItem struct { // this provider response. Older plugins decode as false, so hosts must merge // rather than delete aliases when completeness is unknown. TitleAliasesComplete bool `protobuf:"varint,36,opt,name=title_aliases_complete,json=titleAliasesComplete,proto3" json:"title_aliases_complete,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // ISO 639-1 language of overview and tagline. Empty means the text is in the + // requested language or the plugin cannot tell; set it when the provider + // had no text in the requested language and returned another language's. + OverviewLanguage string `protobuf:"bytes,37,opt,name=overview_language,json=overviewLanguage,proto3" json:"overview_language,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MetadataItem) Reset() { @@ -907,6 +911,13 @@ func (x *MetadataItem) GetTitleAliasesComplete() bool { return false } +func (x *MetadataItem) GetOverviewLanguage() string { + if x != nil { + return x.OverviewLanguage + } + return "" +} + type GetMetadataRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ProviderId string `protobuf:"bytes,1,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` @@ -1240,17 +1251,21 @@ func (x *GetPersonDetailResponse) GetPerson() *PersonDetailRecord { } type SeasonRecord struct { - state protoimpl.MessageState `protogen:"open.v1"` - SeasonNumber int32 `protobuf:"varint,1,opt,name=season_number,json=seasonNumber,proto3" json:"season_number,omitempty"` - Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - Overview string `protobuf:"bytes,3,opt,name=overview,proto3" json:"overview,omitempty"` - AirDate string `protobuf:"bytes,4,opt,name=air_date,json=airDate,proto3" json:"air_date,omitempty"` - PosterPath string `protobuf:"bytes,5,opt,name=poster_path,json=posterPath,proto3" json:"poster_path,omitempty"` - Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` - ProviderId string `protobuf:"bytes,7,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - ProviderIds *structpb.Struct `protobuf:"bytes,8,opt,name=provider_ids,json=providerIds,proto3" json:"provider_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + SeasonNumber int32 `protobuf:"varint,1,opt,name=season_number,json=seasonNumber,proto3" json:"season_number,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Overview string `protobuf:"bytes,3,opt,name=overview,proto3" json:"overview,omitempty"` + AirDate string `protobuf:"bytes,4,opt,name=air_date,json=airDate,proto3" json:"air_date,omitempty"` + PosterPath string `protobuf:"bytes,5,opt,name=poster_path,json=posterPath,proto3" json:"poster_path,omitempty"` + Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` + ProviderId string `protobuf:"bytes,7,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + ProviderIds *structpb.Struct `protobuf:"bytes,8,opt,name=provider_ids,json=providerIds,proto3" json:"provider_ids,omitempty"` + // ISO 639-1 languages of title and overview, with MetadataItem's + // overview_language semantics: empty means the requested language or unknown. + TitleLanguage string `protobuf:"bytes,9,opt,name=title_language,json=titleLanguage,proto3" json:"title_language,omitempty"` + OverviewLanguage string `protobuf:"bytes,10,opt,name=overview_language,json=overviewLanguage,proto3" json:"overview_language,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SeasonRecord) Reset() { @@ -1339,6 +1354,20 @@ func (x *SeasonRecord) GetProviderIds() *structpb.Struct { return nil } +func (x *SeasonRecord) GetTitleLanguage() string { + if x != nil { + return x.TitleLanguage + } + return "" +} + +func (x *SeasonRecord) GetOverviewLanguage() string { + if x != nil { + return x.OverviewLanguage + } + return "" +} + type GetSeasonsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SeriesProviderId string `protobuf:"bytes,1,opt,name=series_provider_id,json=seriesProviderId,proto3" json:"series_provider_id,omitempty"` @@ -1456,8 +1485,12 @@ type EpisodeRecord struct { Ratings *structpb.Struct `protobuf:"bytes,9,opt,name=ratings,proto3" json:"ratings,omitempty"` Metadata *structpb.Struct `protobuf:"bytes,10,opt,name=metadata,proto3" json:"metadata,omitempty"` ProviderId string `protobuf:"bytes,11,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // ISO 639-1 languages of title and overview, with MetadataItem's + // overview_language semantics: empty means the requested language or unknown. + TitleLanguage string `protobuf:"bytes,12,opt,name=title_language,json=titleLanguage,proto3" json:"title_language,omitempty"` + OverviewLanguage string `protobuf:"bytes,13,opt,name=overview_language,json=overviewLanguage,proto3" json:"overview_language,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EpisodeRecord) Reset() { @@ -1567,6 +1600,20 @@ func (x *EpisodeRecord) GetProviderId() string { return "" } +func (x *EpisodeRecord) GetTitleLanguage() string { + if x != nil { + return x.TitleLanguage + } + return "" +} + +func (x *EpisodeRecord) GetOverviewLanguage() string { + if x != nil { + return x.OverviewLanguage + } + return "" +} + type GetEpisodesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` SeriesProviderId string `protobuf:"bytes,1,opt,name=series_provider_id,json=seriesProviderId,proto3" json:"series_provider_id,omitempty"` @@ -2161,8 +2208,7 @@ const file_prairie_plugin_v1_metadata_provider_proto_rawDesc = "" + "\vis_official\x18\a \x01(\bR\n" + "isOfficial\x12\x1b\n" + "\tsize_hint\x18\b \x01(\x05R\bsizeHint\x12!\n" + - "\fpublished_at\x18\t \x01(\tR\vpublishedAt\"\xf0\n" + - "\n" + + "\fpublished_at\x18\t \x01(\tR\vpublishedAt\"\x9d\v\n" + "\fMetadataItem\x12\x1f\n" + "\vprovider_id\x18\x01 \x01(\tR\n" + "providerId\x12\x1b\n" + @@ -2203,7 +2249,8 @@ const file_prairie_plugin_v1_metadata_provider_proto_rawDesc = "" + "\rtitle_aliases\x18! \x03(\v2\x1d.prairie.plugin.v1.TitleAliasR\ftitleAliases\x12%\n" + "\x0etitle_language\x18\" \x01(\tR\rtitleLanguage\x12*\n" + "\x11title_is_fallback\x18# \x01(\bR\x0ftitleIsFallback\x124\n" + - "\x16title_aliases_complete\x18$ \x01(\bR\x14titleAliasesComplete\"\xc7\x01\n" + + "\x16title_aliases_complete\x18$ \x01(\bR\x14titleAliasesComplete\x12+\n" + + "\x11overview_language\x18% \x01(\tR\x10overviewLanguage\"\xc7\x01\n" + "\x12GetMetadataRequest\x12\x1f\n" + "\vprovider_id\x18\x01 \x01(\tR\n" + "providerId\x12\x1b\n" + @@ -2234,7 +2281,7 @@ const file_prairie_plugin_v1_metadata_provider_proto_rawDesc = "" + "\fprovider_ids\x18\n" + " \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"X\n" + "\x17GetPersonDetailResponse\x12=\n" + - "\x06person\x18\x01 \x01(\v2%.prairie.plugin.v1.PersonDetailRecordR\x06person\"\xb3\x02\n" + + "\x06person\x18\x01 \x01(\v2%.prairie.plugin.v1.PersonDetailRecordR\x06person\"\x87\x03\n" + "\fSeasonRecord\x12#\n" + "\rseason_number\x18\x01 \x01(\x05R\fseasonNumber\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x1a\n" + @@ -2245,13 +2292,16 @@ const file_prairie_plugin_v1_metadata_provider_proto_rawDesc = "" + "\bmetadata\x18\x06 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12\x1f\n" + "\vprovider_id\x18\a \x01(\tR\n" + "providerId\x12:\n" + - "\fprovider_ids\x18\b \x01(\v2\x17.google.protobuf.StructR\vproviderIds\"\x99\x01\n" + + "\fprovider_ids\x18\b \x01(\v2\x17.google.protobuf.StructR\vproviderIds\x12%\n" + + "\x0etitle_language\x18\t \x01(\tR\rtitleLanguage\x12+\n" + + "\x11overview_language\x18\n" + + " \x01(\tR\x10overviewLanguage\"\x99\x01\n" + "\x11GetSeasonsRequest\x12,\n" + "\x12series_provider_id\x18\x01 \x01(\tR\x10seriesProviderId\x12:\n" + "\fprovider_ids\x18\x02 \x01(\v2\x17.google.protobuf.StructR\vproviderIds\x12\x1a\n" + "\blanguage\x18\x03 \x01(\tR\blanguage\"O\n" + "\x12GetSeasonsResponse\x129\n" + - "\aseasons\x18\x01 \x03(\v2\x1f.prairie.plugin.v1.SeasonRecordR\aseasons\"\xa6\x03\n" + + "\aseasons\x18\x01 \x03(\v2\x1f.prairie.plugin.v1.SeasonRecordR\aseasons\"\xfa\x03\n" + "\rEpisodeRecord\x12#\n" + "\rseason_number\x18\x01 \x01(\x05R\fseasonNumber\x12%\n" + "\x0eepisode_number\x18\x02 \x01(\x05R\repisodeNumber\x12\x14\n" + @@ -2266,7 +2316,9 @@ const file_prairie_plugin_v1_metadata_provider_proto_rawDesc = "" + "\bmetadata\x18\n" + " \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12\x1f\n" + "\vprovider_id\x18\v \x01(\tR\n" + - "providerId\"\xbf\x01\n" + + "providerId\x12%\n" + + "\x0etitle_language\x18\f \x01(\tR\rtitleLanguage\x12+\n" + + "\x11overview_language\x18\r \x01(\tR\x10overviewLanguage\"\xbf\x01\n" + "\x12GetEpisodesRequest\x12,\n" + "\x12series_provider_id\x18\x01 \x01(\tR\x10seriesProviderId\x12#\n" + "\rseason_number\x18\x02 \x01(\x05R\fseasonNumber\x12:\n" + diff --git a/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go b/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go index 7ec89dc..3d82e16 100644 --- a/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go +++ b/pkg/pluginproto/prairie/plugin/v1/metadata_provider_test.go @@ -56,6 +56,14 @@ func TestMetadataTitleDescriptors_IncludeAliasAndLanguageFields(t *testing.T) { assertFieldNumber(t, &MetadataItem{}, "title_aliases_complete", 36) } +func TestMetadataTextLanguageFieldNumbers(t *testing.T) { + assertFieldNumber(t, &MetadataItem{}, "overview_language", 37) + assertFieldNumber(t, &SeasonRecord{}, "title_language", 9) + assertFieldNumber(t, &SeasonRecord{}, "overview_language", 10) + assertFieldNumber(t, &EpisodeRecord{}, "title_language", 12) + assertFieldNumber(t, &EpisodeRecord{}, "overview_language", 13) +} + func TestMetadataItem_OldPayloadDecodesWithOptionalTitleFields(t *testing.T) { oldPayload, err := proto.Marshal(&MetadataItem{ ProviderId: "603", ItemType: "movie", Title: "The Matrix", OriginalTitle: "The Matrix", Year: 1999, diff --git a/proto/prairie/plugin/v1/marker_provider.proto b/proto/prairie/plugin/v1/marker_provider.proto index 413a9a1..768edfa 100644 --- a/proto/prairie/plugin/v1/marker_provider.proto +++ b/proto/prairie/plugin/v1/marker_provider.proto @@ -40,6 +40,17 @@ message FetchMarkersRequest { MarkerExternalIDs episode_external_ids = 7; string episode_title = 8; string episode_numbering = 9; + // OpenSubtitles hash of the file (16 lowercase hex), or empty when the host + // has none. It covers the file size and the first and last 64 KiB, so a + // remux or re-encode gets a new hash, but two different files can share + // one. A provider must not send it off the machine unhashed. + string file_hash = 10; + // File size in bytes, or 0 when unknown. + int64 file_size = 11; + // The host understands MarkerSegment.absent. A provider must not return + // absent segments when this is false: older hosts read a segment without + // bounds as covering the whole file. + bool accepts_absent = 12; } message MarkerSegment { @@ -50,6 +61,9 @@ message MarkerSegment { int32 submission_count = 5; string algorithm = 6; google.protobuf.Struct metadata = 7; + // The provider asserts the item has no segment of this kind. start_seconds + // and end_seconds are unset; confidence applies to the absence. + bool absent = 8; } message FetchMarkersResponse { @@ -71,6 +85,20 @@ message SubmitMarkerRequest { MarkerExternalIDs episode_external_ids = 10; string episode_title = 11; string episode_numbering = 12; + string file_hash = 13; // as FetchMarkersRequest.file_hash + int64 file_size = 14; + // Where the host's marker came from: "scanner" (local detection, including + // chapters) or "manual" (edited by a person). Empty from hosts older than + // this field. + string source = 15; + // The host's algorithm ID for the marker, e.g. "chromaprint:dialogue:v4", + // or "manual:v1". + string algorithm = 16; + // The host's confidence in the marker, 0-1. + double confidence = 17; + // The submission says the item has no segment of this kind. start_seconds + // and end_seconds are unset. + bool absent = 18; } message SubmitMarkerResponse { diff --git a/proto/prairie/plugin/v1/metadata_provider.proto b/proto/prairie/plugin/v1/metadata_provider.proto index e5f0b07..907d3bb 100644 --- a/proto/prairie/plugin/v1/metadata_provider.proto +++ b/proto/prairie/plugin/v1/metadata_provider.proto @@ -140,6 +140,10 @@ message MetadataItem { // this provider response. Older plugins decode as false, so hosts must merge // rather than delete aliases when completeness is unknown. bool title_aliases_complete = 36; + // ISO 639-1 language of overview and tagline. Empty means the text is in the + // requested language or the plugin cannot tell; set it when the provider + // had no text in the requested language and returned another language's. + string overview_language = 37; } message GetMetadataRequest { @@ -185,6 +189,10 @@ message SeasonRecord { google.protobuf.Struct metadata = 6; string provider_id = 7; google.protobuf.Struct provider_ids = 8; + // ISO 639-1 languages of title and overview, with MetadataItem's + // overview_language semantics: empty means the requested language or unknown. + string title_language = 9; + string overview_language = 10; } message GetSeasonsRequest { @@ -209,6 +217,10 @@ message EpisodeRecord { google.protobuf.Struct ratings = 9; google.protobuf.Struct metadata = 10; string provider_id = 11; + // ISO 639-1 languages of title and overview, with MetadataItem's + // overview_language semantics: empty means the requested language or unknown. + string title_language = 12; + string overview_language = 13; } message GetEpisodesRequest {