diff --git a/.woodpecker.star b/.woodpecker.star index a7616ea613..6ad1f79914 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -3408,6 +3408,17 @@ def tikaService(): # tika 4 discovers its plugins relative to the image working directory, # the workspace default would leave the pipes fetchers empty "directory": "/opt/tika-server", + # the thumbnail preset ships inert; the config is what activates it + "entrypoint": [ + "java", + "-cp", + "/opt/tika-server/*:/opt/tika-server/lib/*:/tika-extras/*", + "org.apache.tika.server.core.TikaServerCli", + "-h", + "0.0.0.0", + "-c", + "%s/tests/config/woodpecker/tika-config.json" % dirs["base"], + ], "detach": True, }, { "name": "wait-for-tika-service", diff --git a/devtools/deployments/opencloud_full/config/tika/tika-config.json b/devtools/deployments/opencloud_full/config/tika/tika-config.json new file mode 100644 index 0000000000..5e0ded5f0a --- /dev/null +++ b/devtools/deployments/opencloud_full/config/tika/tika-config.json @@ -0,0 +1,12 @@ +{ + // -h and -p still come from the command line; a config file has to carry the block + "server": { + "port": 9998, + "host": "0.0.0.0" + }, + // the thumbnail preset ships inert in tika-pipes-core; activating it is what serves + // PUT /unpack/preset/thumbnail, which the webdav thumbnailer asks for + "presets": { + "thumbnail": true + } +} diff --git a/devtools/deployments/opencloud_full/tika.yml b/devtools/deployments/opencloud_full/tika.yml index 081f2876b5..ea18063b9e 100644 --- a/devtools/deployments/opencloud_full/tika.yml +++ b/devtools/deployments/opencloud_full/tika.yml @@ -3,6 +3,10 @@ services: tika: image: ${TIKA_IMAGE:-apache/tika:latest-full} # release notes: https://tika.apache.org + # the thumbnail preset ships inert; the config is what activates it + command: ['-c', '/tika-config.json'] + volumes: + - ./config/tika/tika-config.json:/tika-config.json networks: opencloud-net: restart: always @@ -13,5 +17,5 @@ services: environment: # fulltext search SEARCH_EXTRACTOR_TYPE: tika - SEARCH_EXTRACTOR_TIKA_TIKA_URL: http://tika:9998 + OC_TIKA_URL: http://tika:9998 FRONTEND_FULL_TEXT_SEARCH_ENABLED: "true" diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index 63cc68f2a7..84891ce8c8 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -1,6 +1,7 @@ package conversions import ( + "strconv" "strings" ) @@ -14,3 +15,12 @@ func StringToSliceString(src string, sep string) []string { return parts } + +// StringToInt32 returns fallback when s is not a base-10 int32 +func StringToInt32(s string, fallback int32) int32 { + v, err := strconv.ParseInt(s, 10, 32) + if err != nil { + return fallback + } + return int32(v) +} diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 0a8bf9c1c2..1373dc8c48 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.go @@ -361,6 +361,72 @@ func (x *Image) GetHeight() int32 { return 0 } +// Preview describes an embedded preview (e.g. audio cover art): its dimensions +// and the type it is in. Its presence signals that a preview is available for +// the resource, its type says what a thumbnail of it is written in. +type Preview struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Width *int32 `protobuf:"varint,1,opt,name=width,proto3,oneof" json:"width,omitempty"` + Height *int32 `protobuf:"varint,2,opt,name=height,proto3,oneof" json:"height,omitempty"` + MimeType *string `protobuf:"bytes,3,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"` +} + +func (x *Preview) Reset() { + *x = Preview{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Preview) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Preview) ProtoMessage() {} + +func (x *Preview) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Preview.ProtoReflect.Descriptor instead. +func (*Preview) Descriptor() ([]byte, []int) { + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{4} +} + +func (x *Preview) GetWidth() int32 { + if x != nil && x.Width != nil { + return *x.Width + } + return 0 +} + +func (x *Preview) GetHeight() int32 { + if x != nil && x.Height != nil { + return *x.Height + } + return 0 +} + +func (x *Preview) GetMimeType() string { + if x != nil && x.MimeType != nil { + return *x.MimeType + } + return "" +} + type GeoCoordinates struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -374,7 +440,7 @@ type GeoCoordinates struct { func (x *GeoCoordinates) Reset() { *x = GeoCoordinates{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -387,7 +453,7 @@ func (x *GeoCoordinates) String() string { func (*GeoCoordinates) ProtoMessage() {} func (x *GeoCoordinates) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -400,7 +466,7 @@ func (x *GeoCoordinates) ProtoReflect() protoreflect.Message { // Deprecated: Use GeoCoordinates.ProtoReflect.Descriptor instead. func (*GeoCoordinates) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{4} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{5} } func (x *GeoCoordinates) GetAltitude() float64 { @@ -443,7 +509,7 @@ type Photo struct { func (x *Photo) Reset() { *x = Photo{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -456,7 +522,7 @@ func (x *Photo) String() string { func (*Photo) ProtoMessage() {} func (x *Photo) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -469,7 +535,7 @@ func (x *Photo) ProtoReflect() protoreflect.Message { // Deprecated: Use Photo.ProtoReflect.Descriptor instead. func (*Photo) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{5} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{6} } func (x *Photo) GetCameraMake() string { @@ -555,7 +621,7 @@ type Video struct { func (x *Video) Reset() { *x = Video{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -568,7 +634,7 @@ func (x *Video) String() string { func (*Video) ProtoMessage() {} func (x *Video) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -581,7 +647,7 @@ func (x *Video) ProtoReflect() protoreflect.Message { // Deprecated: Use Video.ProtoReflect.Descriptor instead. func (*Video) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{6} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{7} } func (x *Video) GetAudioBitsPerSample() int32 { @@ -667,7 +733,7 @@ type MotionPhoto struct { func (x *MotionPhoto) Reset() { *x = MotionPhoto{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -680,7 +746,7 @@ func (x *MotionPhoto) String() string { func (*MotionPhoto) ProtoMessage() {} func (x *MotionPhoto) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -693,7 +759,7 @@ func (x *MotionPhoto) ProtoReflect() protoreflect.Message { // Deprecated: Use MotionPhoto.ProtoReflect.Descriptor instead. func (*MotionPhoto) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{7} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{8} } func (x *MotionPhoto) GetVersion() int32 { @@ -732,7 +798,7 @@ type LivePhoto struct { func (x *LivePhoto) Reset() { *x = LivePhoto{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -745,7 +811,7 @@ func (x *LivePhoto) String() string { func (*LivePhoto) ProtoMessage() {} func (x *LivePhoto) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -758,7 +824,7 @@ func (x *LivePhoto) ProtoReflect() protoreflect.Message { // Deprecated: Use LivePhoto.ProtoReflect.Descriptor instead. func (*LivePhoto) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{8} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{9} } func (x *LivePhoto) GetContentId() string { @@ -824,12 +890,13 @@ type Entity struct { MotionPhoto *MotionPhoto `protobuf:"bytes,21,opt,name=motionPhoto,proto3" json:"motionPhoto,omitempty"` Video *Video `protobuf:"bytes,22,opt,name=video,proto3" json:"video,omitempty"` LivePhoto *LivePhoto `protobuf:"bytes,23,opt,name=livePhoto,proto3" json:"livePhoto,omitempty"` + Preview *Preview `protobuf:"bytes,24,opt,name=preview,proto3" json:"preview,omitempty"` } func (x *Entity) Reset() { *x = Entity{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -842,7 +909,7 @@ func (x *Entity) String() string { func (*Entity) ProtoMessage() {} func (x *Entity) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -855,7 +922,7 @@ func (x *Entity) ProtoReflect() protoreflect.Message { // Deprecated: Use Entity.ProtoReflect.Descriptor instead. func (*Entity) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{9} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{10} } func (x *Entity) GetRef() *Reference { @@ -1019,6 +1086,13 @@ func (x *Entity) GetLivePhoto() *LivePhoto { return nil } +func (x *Entity) GetPreview() *Preview { + if x != nil { + return x.Preview + } + return nil +} + type Match struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1033,7 +1107,7 @@ type Match struct { func (x *Match) Reset() { *x = Match{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1046,7 +1120,7 @@ func (x *Match) String() string { func (*Match) ProtoMessage() {} func (x *Match) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1059,7 +1133,7 @@ func (x *Match) ProtoReflect() protoreflect.Message { // Deprecated: Use Match.ProtoReflect.Descriptor instead. func (*Match) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{10} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{11} } func (x *Match) GetEntity() *Entity { @@ -1149,197 +1223,209 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{ 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, - 0x67, 0x68, 0x74, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, - 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, - 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, - 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, - 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, - 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, - 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, - 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, - 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, - 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, - 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, - 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, 0x52, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, - 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, - 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, - 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, - 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, - 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, - 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, - 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, - 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, 0x52, 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, - 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x45, 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, - 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, - 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, - 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, - 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, - 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, - 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, - 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, - 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, - 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, - 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, - 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, - 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, - 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, - 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, - 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, - 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, - 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, - 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, - 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, - 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, - 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, - 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, - 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, - 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, - 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, - 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, - 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, - 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, - 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, - 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, - 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, - 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, - 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, - 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, - 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, - 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, - 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, - 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, - 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, - 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, - 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, - 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, - 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, - 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, 0x02, 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, - 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, - 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, - 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, - 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, - 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x48, 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, - 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, - 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, - 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, - 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, - 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, - 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, - 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0xc9, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, - 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, - 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, - 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, - 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, - 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, - 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, - 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, - 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, - 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, - 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, - 0x67, 0x68, 0x74, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x69, 0x67, 0x68, - 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x39, 0x0a, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, - 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x67, 0x68, 0x74, 0x22, 0x84, 0x01, 0x0a, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, + 0x19, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, + 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, + 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, + 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x08, 0x6d, 0x69, 0x6d, + 0x65, 0x54, 0x79, 0x70, 0x65, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, + 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x0b, 0x0a, + 0x09, 0x5f, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x47, + 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, 0x0a, + 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x48, + 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, + 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, + 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, + 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, + 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, + 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, 0x0a, + 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x50, + 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, + 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, 0x65, + 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, 0x6d, + 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, + 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, 0x01, + 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, + 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, 0x52, + 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, + 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, + 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, + 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, 0x4e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, 0x66, + 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, 0x63, + 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, + 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, + 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, 0x52, + 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, 0x0b, + 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x45, + 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, + 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, + 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, + 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, + 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, 0x12, + 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, + 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x0e, + 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x06, + 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, 0x6e, + 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, 0x64, + 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, + 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, + 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, + 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, + 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x88, + 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, 0x64, + 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, + 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, 0x69, + 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, + 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, + 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, 0x01, + 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, + 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, + 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, + 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, + 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, + 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, + 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, + 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x42, + 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, + 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, 0x75, + 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, + 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, 0x06, + 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, + 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, + 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, 0x02, + 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, + 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, + 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, 0x6c, + 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, + 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, + 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, 0x61, + 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x48, + 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, + 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, + 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, + 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, + 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x13, + 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, + 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, + 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, + 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, + 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8a, 0x09, 0x0a, 0x06, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, + 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, + 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, + 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, + 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, - 0x6f, 0x12, 0x48, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x10, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, - 0x73, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x4e, 0x0a, 0x0e, 0x72, - 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x0c, 0x72, - 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x05, 0x69, - 0x6d, 0x61, 0x67, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, + 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, + 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x1e, 0x0a, + 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x39, 0x0a, + 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x75, 0x64, 0x69, + 0x6f, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x12, 0x48, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, - 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x39, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x18, - 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, - 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x74, - 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x14, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, - 0x4b, 0x0a, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x15, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, - 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x39, 0x0a, 0x05, - 0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, - 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, - 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x45, 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, - 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, + 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x4e, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x69, 0x74, 0x65, + 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, - 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x22, 0x5b, - 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, - 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x49, 0x44, 0x52, 0x0c, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x74, 0x65, 0x6d, + 0x49, 0x64, 0x12, 0x39, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x39, 0x0a, + 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x68, 0x6f, 0x74, + 0x6f, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x61, 0x76, 0x6f, + 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x66, 0x61, 0x76, + 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, + 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x6f, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, + 0x6f, 0x74, 0x6f, 0x12, 0x39, 0x0a, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x16, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, + 0x30, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x45, + 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, + 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x3f, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, + 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x07, 0x70, + 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, + 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, + 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, + 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, + 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1354,42 +1440,44 @@ func file_opencloud_messages_search_v0_search_proto_rawDescGZIP() []byte { return file_opencloud_messages_search_v0_search_proto_rawDescData } -var file_opencloud_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_opencloud_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_opencloud_messages_search_v0_search_proto_goTypes = []interface{}{ (*ResourceID)(nil), // 0: opencloud.messages.search.v0.ResourceID (*Reference)(nil), // 1: opencloud.messages.search.v0.Reference (*Audio)(nil), // 2: opencloud.messages.search.v0.Audio (*Image)(nil), // 3: opencloud.messages.search.v0.Image - (*GeoCoordinates)(nil), // 4: opencloud.messages.search.v0.GeoCoordinates - (*Photo)(nil), // 5: opencloud.messages.search.v0.Photo - (*Video)(nil), // 6: opencloud.messages.search.v0.Video - (*MotionPhoto)(nil), // 7: opencloud.messages.search.v0.MotionPhoto - (*LivePhoto)(nil), // 8: opencloud.messages.search.v0.LivePhoto - (*Entity)(nil), // 9: opencloud.messages.search.v0.Entity - (*Match)(nil), // 10: opencloud.messages.search.v0.Match - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*Preview)(nil), // 4: opencloud.messages.search.v0.Preview + (*GeoCoordinates)(nil), // 5: opencloud.messages.search.v0.GeoCoordinates + (*Photo)(nil), // 6: opencloud.messages.search.v0.Photo + (*Video)(nil), // 7: opencloud.messages.search.v0.Video + (*MotionPhoto)(nil), // 8: opencloud.messages.search.v0.MotionPhoto + (*LivePhoto)(nil), // 9: opencloud.messages.search.v0.LivePhoto + (*Entity)(nil), // 10: opencloud.messages.search.v0.Entity + (*Match)(nil), // 11: opencloud.messages.search.v0.Match + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp } var file_opencloud_messages_search_v0_search_proto_depIdxs = []int32{ 0, // 0: opencloud.messages.search.v0.Reference.resource_id:type_name -> opencloud.messages.search.v0.ResourceID - 11, // 1: opencloud.messages.search.v0.Photo.takenDateTime:type_name -> google.protobuf.Timestamp + 12, // 1: opencloud.messages.search.v0.Photo.takenDateTime:type_name -> google.protobuf.Timestamp 1, // 2: opencloud.messages.search.v0.Entity.ref:type_name -> opencloud.messages.search.v0.Reference 0, // 3: opencloud.messages.search.v0.Entity.id:type_name -> opencloud.messages.search.v0.ResourceID - 11, // 4: opencloud.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp + 12, // 4: opencloud.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp 0, // 5: opencloud.messages.search.v0.Entity.parent_id:type_name -> opencloud.messages.search.v0.ResourceID 2, // 6: opencloud.messages.search.v0.Entity.audio:type_name -> opencloud.messages.search.v0.Audio - 4, // 7: opencloud.messages.search.v0.Entity.location:type_name -> opencloud.messages.search.v0.GeoCoordinates + 5, // 7: opencloud.messages.search.v0.Entity.location:type_name -> opencloud.messages.search.v0.GeoCoordinates 0, // 8: opencloud.messages.search.v0.Entity.remote_item_id:type_name -> opencloud.messages.search.v0.ResourceID 3, // 9: opencloud.messages.search.v0.Entity.image:type_name -> opencloud.messages.search.v0.Image - 5, // 10: opencloud.messages.search.v0.Entity.photo:type_name -> opencloud.messages.search.v0.Photo - 7, // 11: opencloud.messages.search.v0.Entity.motionPhoto:type_name -> opencloud.messages.search.v0.MotionPhoto - 6, // 12: opencloud.messages.search.v0.Entity.video:type_name -> opencloud.messages.search.v0.Video - 8, // 13: opencloud.messages.search.v0.Entity.livePhoto:type_name -> opencloud.messages.search.v0.LivePhoto - 9, // 14: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 6, // 10: opencloud.messages.search.v0.Entity.photo:type_name -> opencloud.messages.search.v0.Photo + 8, // 11: opencloud.messages.search.v0.Entity.motionPhoto:type_name -> opencloud.messages.search.v0.MotionPhoto + 7, // 12: opencloud.messages.search.v0.Entity.video:type_name -> opencloud.messages.search.v0.Video + 9, // 13: opencloud.messages.search.v0.Entity.livePhoto:type_name -> opencloud.messages.search.v0.LivePhoto + 4, // 14: opencloud.messages.search.v0.Entity.preview:type_name -> opencloud.messages.search.v0.Preview + 10, // 15: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_opencloud_messages_search_v0_search_proto_init() } @@ -1447,7 +1535,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GeoCoordinates); i { + switch v := v.(*Preview); i { case 0: return &v.state case 1: @@ -1459,7 +1547,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Photo); i { + switch v := v.(*GeoCoordinates); i { case 0: return &v.state case 1: @@ -1471,7 +1559,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Video); i { + switch v := v.(*Photo); i { case 0: return &v.state case 1: @@ -1483,7 +1571,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MotionPhoto); i { + switch v := v.(*Video); i { case 0: return &v.state case 1: @@ -1495,7 +1583,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LivePhoto); i { + switch v := v.(*MotionPhoto); i { case 0: return &v.state case 1: @@ -1507,7 +1595,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Entity); i { + switch v := v.(*LivePhoto); i { case 0: return &v.state case 1: @@ -1519,6 +1607,18 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Entity); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_messages_search_v0_search_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Match); i { case 0: return &v.state @@ -1538,13 +1638,14 @@ func file_opencloud_messages_search_v0_search_proto_init() { file_opencloud_messages_search_v0_search_proto_msgTypes[6].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[7].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[8].OneofWrappers = []interface{}{} + file_opencloud_messages_search_v0_search_proto_msgTypes[9].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_opencloud_messages_search_v0_search_proto_rawDesc, NumEnums: 0, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index db24bd32b5..10a35e6dc6 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -307,6 +307,9 @@ }, "livePhoto": { "$ref": "#/definitions/v0LivePhoto" + }, + "preview": { + "$ref": "#/definitions/v0Preview" } } }, @@ -493,6 +496,23 @@ } } }, + "v0Preview": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "mimeType": { + "type": "string" + } + }, + "description": "Preview describes an embedded preview (e.g. audio cover art): its dimensions\nand the type it is in. Its presence signals that a preview is available for\nthe resource, its type says what a thumbnail of it is written in." + }, "v0Reference": { "type": "object", "properties": { diff --git a/protogen/proto/opencloud/messages/search/v0/search.proto b/protogen/proto/opencloud/messages/search/v0/search.proto index a2a25a8d5e..af83d1db88 100644 --- a/protogen/proto/opencloud/messages/search/v0/search.proto +++ b/protogen/proto/opencloud/messages/search/v0/search.proto @@ -41,6 +41,15 @@ message Image { optional int32 height = 2; } +// Preview describes an embedded preview (e.g. audio cover art): its dimensions +// and the type it is in. Its presence signals that a preview is available for +// the resource, its type says what a thumbnail of it is written in. +message Preview { + optional int32 width = 1; + optional int32 height = 2; + optional string mimeType = 3; +} + message GeoCoordinates { optional double altitude = 1; optional double latitude = 2; @@ -110,6 +119,7 @@ message Entity { MotionPhoto motionPhoto = 21; Video video = 22; LivePhoto livePhoto = 23; + Preview preview = 24; } message Match { diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index d5d877e740..8d3ef94841 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -34,26 +34,36 @@ func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceIn } } -// previewThumbnailSet returns nil when the thumbnailer cannot render the resource. +// previewThumbnailSet returns nil when no preview can be produced for the resource. func previewThumbnailSet(res *provider.ResourceInfo, baseURL string, sourceTypes thumbnail.SourceTypes) *libregraph.ThumbnailSet { - if !sourceTypes.Supports(res.GetMimeType()) { + if !sourceTypes.HasPreview(res) { return nil } - return thumbnailSetFor(baseURL, storagespace.FormatResourceID(res.GetId())) + + itemID := storagespace.FormatResourceID(res.GetId()) + set := thumbnailSetFor(baseURL, itemID) + if w, h := thumbnail.SourceDimensions(res); w > 0 && h > 0 { + set.Source = previewThumbnail(previewBaseURL(baseURL, itemID), w, h) + } + return set } // thumbnailSetFor builds the urls of the WebDAV preview endpoint. func thumbnailSetFor(baseURL, itemID string) *libregraph.ThumbnailSet { - base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, itemID) + base := previewBaseURL(baseURL, itemID) return &libregraph.ThumbnailSet{ - Small: previewThumbnail(base, thumbnailBoxSmall), - Medium: previewThumbnail(base, thumbnailBoxMedium), - Large: previewThumbnail(base, thumbnailBoxLarge), + Small: previewThumbnail(base, thumbnailBoxSmall, thumbnailBoxSmall), + Medium: previewThumbnail(base, thumbnailBoxMedium, thumbnailBoxMedium), + Large: previewThumbnail(base, thumbnailBoxLarge, thumbnailBoxLarge), } } -func previewThumbnail(base string, box int32) *libregraph.Thumbnail { - url := fmt.Sprintf("%s&x=%d&y=%d", base, box, box) +func previewBaseURL(baseURL, itemID string) string { + return fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, itemID) +} + +func previewThumbnail(base string, width, height int32) *libregraph.Thumbnail { + url := fmt.Sprintf("%s&x=%d&y=%d", base, width, height) return &libregraph.Thumbnail{Url: &url} } diff --git a/services/search/README.md b/services/search/README.md index d0f650e528..a5e178d557 100644 --- a/services/search/README.md +++ b/services/search/README.md @@ -107,7 +107,7 @@ As soon as Tika is installed and configured, the search service needs to be told The following settings must be set: * `SEARCH_EXTRACTOR_TYPE=tika` -* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` +* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` (or the shared `OC_TIKA_URL`, which the webdav service reads for thumbnails too) Additionally, the following optional settings can be set: diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index d8426e5435..e8968f7054 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -138,6 +138,7 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques Video: hitToFacet[searchMessage.Video](hit.Fields, "video"), MotionPhoto: hitToFacet[searchMessage.MotionPhoto](hit.Fields, "motionPhoto"), LivePhoto: hitToFacet[searchMessage.LivePhoto](hit.Fields, "livePhoto"), + Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), }, } diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index 73f6baa748..7e73d2d23c 100644 --- a/services/search/pkg/bleve/testdata/mapping.golden.json +++ b/services/search/pkg/bleve/testdata/mapping.golden.json @@ -1053,6 +1053,77 @@ } } }, + "preview": { + "enabled": true, + "dynamic": true, + "properties": { + "height": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + }, + "mimeType": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "include_in_all": true, + "docvalues": true + } + ] + }, + "mimeType_lowercase": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "index": true, + "include_term_vectors": true + } + ] + }, + "mimeType_words": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "words", + "index": true, + "include_term_vectors": true + } + ] + }, + "width": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + } + } + }, "video": { "enabled": true, "dynamic": true, diff --git a/services/search/pkg/config/content.go b/services/search/pkg/config/content.go index 515db03b63..bd9baa7a82 100644 --- a/services/search/pkg/config/content.go +++ b/services/search/pkg/config/content.go @@ -9,6 +9,6 @@ type Extractor struct { // ExtractorTika configures the Tika extractor type ExtractorTika struct { - TikaURL string `yaml:"tika_url" env:"SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"` + TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"` CleanStopWords bool `yaml:"clean_stop_words" env:"SEARCH_EXTRACTOR_TIKA_CLEAN_STOP_WORDS" desc:"Defines if stop words should be cleaned or not. See the documentation for more details." introductionVersion:"1.0.0"` } diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index 6a945b0fd3..e02eec6c0b 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -30,6 +30,29 @@ type Document struct { Video *libregraph.Video `json:"video,omitempty"` MotionPhoto *libregraph.MotionPhoto `json:"motionPhoto,omitempty"` LivePhoto *libregraph.LivePhoto `json:"livePhoto,omitempty"` + Preview *Preview `json:"preview,omitempty"` +} + +// Preview describes an embedded preview (e.g. audio cover art): its dimensions +// and the type it is in. Internal signal, not a Graph facet; its presence marks +// that a preview exists, and the type says what a thumbnail of it is written in. +type Preview struct { + Width int32 `json:"width"` + Height int32 `json:"height"` + MimeType string `json:"mimeType,omitempty"` +} + +// ToMap flows Preview through the shared facet flattening, under oc.preview. +func (p Preview) ToMap() (map[string]interface{}, error) { + m := map[string]interface{}{ + "width": p.Width, + "height": p.Height, + } + if p.MimeType != "" { + m["mimeType"] = p.MimeType + } + + return m, nil } func CleanString(content, langCode string) string { diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 1d941abf01..86612e461b 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -2,9 +2,11 @@ package content import ( "context" + "encoding/json" "fmt" "io" "net/http" + "reflect" "slices" "strings" @@ -17,6 +19,11 @@ import ( "github.com/opencloud-eu/opencloud/services/search/pkg/config" ) +// rmetaThumbnailPath asks for the recursive metadata with Tika's thumbnail +// preset, so Tika names the image a client would show and renders one where +// the format needs that, a pdf page for one. +const rmetaThumbnailPath = "/rmeta/preset/thumbnail/text" + // Tika is used to extract content from a resource, // it uses apache tika to retrieve all the data. type Tika struct { @@ -72,13 +79,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } - data, err := t.Retrieve(ctx, ri.Id) - if err != nil { - return doc, err - } - defer data.Close() - - metas, err := t.tika.MetaRecursive(ctx, data) + metas, err := t.metaRecursive(ctx, ri.Id) if err != nil { return doc, err } @@ -118,6 +119,8 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.MotionPhoto = t.getMotionPhoto(m0, metas[i+1]) } + doc.Preview = getPreview(metas) + if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { doc.Content = CleanString(doc.Content, langCode) } @@ -125,6 +128,70 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } +// metaRecursive is Tika's recursive metadata, asked for with the thumbnail +// preset so the index learns about the same image the thumbnailer serves. +func (t Tika) metaRecursive(ctx context.Context, id *provider.ResourceId) ([]map[string][]string, error) { + data, err := t.Retrieve(ctx, id) + if err != nil { + return nil, err + } + defer data.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, t.tikaURL+rmetaThumbnailPath, data) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, fmt.Errorf("tika serves no %s, its configuration has to activate the thumbnail preset", rmetaThumbnailPath) + default: + return nil, fmt.Errorf("tika %s: %s", rmetaThumbnailPath, res.Status) + } + + return decodeMetas(res.Body) +} + +// decodeMetas reads Tika's per-part metadata, whose values are a string or a +// list of them. +func decodeMetas(r io.Reader) ([]map[string][]string, error) { + var parts []map[string]any + if err := json.NewDecoder(r).Decode(&parts); err != nil { + return nil, err + } + + metas := make([]map[string][]string, 0, len(parts)) + for _, part := range parts { + meta := make(map[string][]string, len(part)) + for key, value := range part { + switch typed := value.(type) { + case string: + meta[key] = []string{typed} + case []any: + for _, entry := range typed { + s, ok := entry.(string) + if !ok { + return nil, fmt.Errorf("field %q holds %v of type %T, want a string", key, entry, entry) + } + meta[key] = append(meta[key], s) + } + default: + return nil, fmt.Errorf("field %q holds %v of type %v, want a string or a list of them", key, value, reflect.TypeOf(value)) + } + } + metas = append(metas, meta) + } + + return metas, nil +} + // detectLanguage asks tika for the language of content. Tika 4 moved the // endpoint from /language/string to /language, so try the new path first and // fall back for an older tika. diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go new file mode 100644 index 0000000000..4f7d68c344 --- /dev/null +++ b/services/search/pkg/content/tika_preview.go @@ -0,0 +1,52 @@ +package content + +import ( + "mime" + "slices" + "strings" + + "github.com/opencloud-eu/opencloud/pkg/conversions" +) + +// thumbnailResourceTypes are how Tika marks the image it considers the file's +// thumbnail: one stored inside the file, an audio cover or a document's own +// thumbnail, or one Tika rendered for the purpose, a pdf's first page. The +// unpacker's thumbnail preset extracts that same image, so the index reports +// what the thumbnailer serves. A file that needs no such image, an image +// itself for one, has no marked part. +var thumbnailResourceTypes = []string{"THUMBNAIL", "RENDERING"} + +// getPreview returns the size and type of the preview image Tika found inside +// the file, or nil when Tika marked none. +func getPreview(metas []map[string][]string) *Preview { + for _, meta := range metas { + resourceType, _ := getFirstValue(meta, "tk:embedded-resource-type") + if !slices.Contains(thumbnailResourceTypes, resourceType) { + continue + } + ct, err := getFirstValue(meta, "Content-Type") + if err != nil || !strings.HasPrefix(ct, "image/") { + continue + } + // Tika answers with a full header value, parameters included; what is + // stored is the media type alone, the way a file's own type is stored + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil { + continue + } + w, _ := getFirstValue(meta, "tiff:ImageWidth") + h, _ := getFirstValue(meta, "tiff:ImageLength") + preview := &Preview{ + Width: conversions.StringToInt32(w, 0), + Height: conversions.StringToInt32(h, 0), + MimeType: mediaType, + } + if preview.Width <= 0 || preview.Height <= 0 { + continue + } + + return preview + } + + return nil +} diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go new file mode 100644 index 0000000000..b8195fbf1a --- /dev/null +++ b/services/search/pkg/content/tika_preview_test.go @@ -0,0 +1,86 @@ +package content + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("getPreview", func() { + audio := map[string][]string{"Content-Type": {"audio/mpeg"}} + cover := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"500"}, + "tiff:ImageLength": {"400"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + coverNoDims := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + coverWithParams := map[string][]string{ + "Content-Type": {"image/jpeg; quality=90"}, + "tiff:ImageWidth": {"80"}, + "tiff:ImageLength": {"60"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + + It("returns the cover Tika marks as the thumbnail", func() { + p := getPreview([]map[string][]string{audio, cover}) + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 500, Height: 400, MimeType: "image/jpeg"})) + }) + + It("stores the media type without the parameters Tika sends", func() { + p := getPreview([]map[string][]string{audio, coverWithParams}) + Expect(p).ToNot(BeNil()) + Expect(p.MimeType).To(Equal("image/jpeg")) + }) + + It("returns nil when the audio has no cover", func() { + Expect(getPreview([]map[string][]string{audio})).To(BeNil()) + }) + + It("returns nil when the marked cover lacks dimensions", func() { + Expect(getPreview([]map[string][]string{audio, coverNoDims})).To(BeNil()) + }) + + It("leaves the selection to Tika, whatever the order", func() { + inline := map[string][]string{ + "Content-Type": {"image/jpeg"}, "dc:description": {"Other"}, + "tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"}, + "tk:embedded-resource-type": {"INLINE"}, + } + marked := map[string][]string{ + "Content-Type": {"image/png"}, "dc:description": {"Cover (front)"}, + "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + p := getPreview([]map[string][]string{audio, inline, marked}) + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 64, Height: 40, MimeType: "image/png"}), "the type decides what a thumbnail of the cover is written in") + }) + + It("ignores an embedded image Tika does not mark", func() { + unmarked := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"90"}, "tiff:ImageLength": {"90"}, + } + Expect(getPreview([]map[string][]string{audio, unmarked})).To(BeNil()) + }) + + It("takes a document's stored thumbnail the same way", func() { + odt := map[string][]string{"Content-Type": {"application/vnd.oasis.opendocument.text"}} + stored := map[string][]string{ + "Content-Type": {"image/png"}, + "tiff:ImageWidth": {"362"}, "tiff:ImageLength": {"512"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + p := getPreview([]map[string][]string{odt, stored}) + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 362, Height: 512, MimeType: "image/png"})) + }) + + It("returns nil for a file Tika marks nothing in, an image for one", func() { + Expect(getPreview([]map[string][]string{{"Content-Type": {"image/png"}, "tiff:ImageWidth": {"10"}, "tiff:ImageLength": {"10"}}})).To(BeNil()) + }) +}) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index f0b1850879..83a076751b 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -25,6 +25,7 @@ var _ = Describe("Tika", func() { var ( body string fullResponse string + presetCalls int language string tika4Language bool version string @@ -38,6 +39,7 @@ var _ = Describe("Tika", func() { tika4Language = false version = "" fullResponse = "" + presetCalls = 0 srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { out := "" switch req.URL.Path { @@ -52,7 +54,8 @@ var _ = Describe("Tika", func() { } case "/language/string": out = language - case "/rmeta/text": + case "/rmeta/preset/thumbnail/text": + presetCalls++ if fullResponse != "" { out = fullResponse } else { @@ -170,19 +173,36 @@ var _ = Describe("Tika", func() { Expect(doc.Content).To(Equal("body test stop words!!!")) }) - It("takes facets from the main document, not from an embedded resource", func() { - // metas[0] is the file (audio), metas[1] its embedded cover art. The - // cover must not give the track an image facet. - fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500"}]` + It("keeps the audio facet and captures the cover as preview", func() { + fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500", "tiff:ImageLength": "400", "tk:embedded-resource-type": "THUMBNAIL"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Size: 1, + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 1, + MimeType: "audio/mpeg", }) Expect(err).ToNot(HaveOccurred()) Expect(doc.Audio).ToNot(BeNil()) Expect(doc.Audio.Title).To(Equal(libregraph.PtrString("Sucker"))) Expect(doc.Image).To(BeNil()) + Expect(doc.Preview).ToNot(BeNil()) + Expect(doc.Preview.Width).To(Equal(int32(500))) + Expect(doc.Preview.Height).To(Equal(int32(400))) + }) + + It("asks for the thumbnail preset, so Tika names the image", func() { + fullResponse = `[{"Content-Type": "application/pdf", "tk:content": "text"}, {"Content-Type": "image/png", "tiff:ImageWidth": "181", "tiff:ImageLength": "255", "tk:embedded-resource-type": "RENDERING"}]` + + doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 1, + MimeType: "application/pdf", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(presetCalls).To(Equal(1)) + Expect(doc.Preview).ToNot(BeNil()) + Expect(doc.Preview.Width).To(Equal(int32(181))) + Expect(doc.Preview.MimeType).To(Equal("image/png")) }) It("adds no audio facet to non-audio documents", func() { diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 81d1b347e2..1fb04229d3 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -86,6 +86,7 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, Video: copyFacet[searchMessage.Video](resource.Video), MotionPhoto: copyFacet[searchMessage.MotionPhoto](resource.MotionPhoto), LivePhoto: copyFacet[searchMessage.LivePhoto](resource.LivePhoto), + Preview: copyFacet[searchMessage.Preview](resource.Preview), }, } diff --git a/services/search/pkg/opensearch/testdata/resource.golden.json b/services/search/pkg/opensearch/testdata/resource.golden.json index ab343eef11..6893b4aec8 100644 --- a/services/search/pkg/opensearch/testdata/resource.golden.json +++ b/services/search/pkg/opensearch/testdata/resource.golden.json @@ -283,6 +283,27 @@ } } }, + "preview": { + "properties": { + "height": { + "type": "integer" + }, + "mimeType": { + "type": "keyword" + }, + "mimeType_lowercase": { + "doc_values": false, + "type": "keyword" + }, + "mimeType_words": { + "analyzer": "words", + "type": "text" + }, + "width": { + "type": "integer" + } + } + }, "video": { "properties": { "audioBitsPerSample": { diff --git a/services/search/pkg/parity/README.md b/services/search/pkg/parity/README.md index 186a48badb..8930996565 100644 --- a/services/search/pkg/parity/README.md +++ b/services/search/pkg/parity/README.md @@ -715,3 +715,5 @@ Fixtures: | METADATA-01 | `*song*` reads `Audio` | all 16 fields unchanged | all 16 fields unchanged | all 16 fields unchanged | ✅ | | METADATA-02 | `*team*` reads `Location` | all 3 fields unchanged | all 3 fields unchanged | all 3 fields unchanged | ✅ | | METADATA-03 | `*team*` reads `Audio` | none | none | none | ✅ | +| METADATA-04 | `*song*` reads `Preview` | 500x400 | 500x400 | 500x400 | ✅ | +| METADATA-05 | `*team*` reads `Preview` | none | none | none | ✅ | diff --git a/services/search/pkg/parity/fixtures_test.go b/services/search/pkg/parity/fixtures_test.go index 7f1569ebbe..2b6fc39990 100644 --- a/services/search/pkg/parity/fixtures_test.go +++ b/services/search/pkg/parity/fixtures_test.go @@ -54,6 +54,10 @@ func withLocation(location *libregraph.GeoCoordinates) fixtureOption { return func(r *search.Resource) { r.Location = location } } +func withPreview(preview *content.Preview) fixtureOption { + return func(r *search.Resource) { r.Preview = preview } +} + func fixtureDoc(name string, opts ...fixtureOption) search.Resource { mtime := fixtureNow r := search.Resource{ diff --git a/services/search/pkg/parity/response_metadata_test.go b/services/search/pkg/parity/response_metadata_test.go index e4bbca5e34..7845e8060f 100644 --- a/services/search/pkg/parity/response_metadata_test.go +++ b/services/search/pkg/parity/response_metadata_test.go @@ -6,6 +6,7 @@ import ( libregraph "github.com/opencloud-eu/libre-graph-api-go" searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/content" "github.com/opencloud-eu/opencloud/services/search/pkg/search" ) @@ -31,6 +32,7 @@ func metadataGroup() responseGroup { TrackCount: libregraph.PtrInt32(99), Year: libregraph.PtrInt32(2004), }), + withPreview(&content.Preview{Width: 500, Height: 400}), ) team := fixtureDoc("team.jpg", @@ -116,6 +118,27 @@ func metadataGroup() responseGroup { return "none" } + return "set" + }), + }, + { + id: 4, query: `*song*`, reads: "Preview", want: []string{"500x400"}, + read: reads(func(m *searchMessage.Match) string { + preview := m.GetEntity().GetPreview() + if preview == nil { + return "none" + } + + return fmt.Sprintf("%dx%d", preview.GetWidth(), preview.GetHeight()) + }), + }, + { + id: 5, query: `*team*`, reads: "Preview", want: []string{"none"}, + read: reads(func(m *searchMessage.Match) string { + if m.GetEntity().GetPreview() == nil { + return "none" + } + return "set" }), }, diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index a45968197e..ec5e9a4413 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -701,6 +701,7 @@ func (s *Service) doUpsertItem(ctx context.Context, ref *provider.Reference, bat facetToMetadata(metadata, doc.Video, "libre.graph.video.") facetToMetadata(metadata, doc.MotionPhoto, "libre.graph.motionPhoto.") facetToMetadata(metadata, doc.LivePhoto, "libre.graph.livePhoto.") + facetToMetadata(metadata, doc.Preview, "oc.preview.") if len(metadata) == 0 { return } diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index 5db825bc9e..67aeda56fa 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -10,6 +10,7 @@ import ( typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + libregraph "github.com/opencloud-eu/libre-graph-api-go" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" @@ -25,6 +26,7 @@ import ( contentMocks "github.com/opencloud-eu/opencloud/services/search/pkg/content/mocks" "github.com/opencloud-eu/opencloud/services/search/pkg/search" engineMocks "github.com/opencloud-eu/opencloud/services/search/pkg/search/mocks" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) var _ = Describe("Searchprovider", func() { @@ -175,6 +177,47 @@ var _ = Describe("Searchprovider", func() { Entry("./", "./", false), Entry("/", "/", false), ) + + DescribeTable("stores the metadata the thumbnail service reads its source dimensions from", + func(doc content.Document, wantWidth, wantHeight int32) { + info := &sprovider.ResourceInfo{ + Id: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"}, + ParentId: &sprovider.ResourceId{StorageId: "storageid", OpaqueId: "parentopaqueid"}, + Path: "./file", + MimeType: doc.MimeType, + } + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{ + Status: status.NewOK(context.Background()), + Info: info, + }, nil) + gatewayClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{ + Status: status.NewOK(context.Background()), + Path: info.GetPath(), + }, nil) + extractor.On("Extract", mock.Anything, mock.Anything, mock.Anything).Return(doc, nil) + indexClient.On("Upsert", mock.Anything, mock.Anything).Return(nil) + + var stored *sprovider.ArbitraryMetadata + gatewayClient.On("SetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + stored = args.Get(1).(*sprovider.SetArbitraryMetadataRequest).GetArbitraryMetadata() + }).Return(&sprovider.SetArbitraryMetadataResponse{Status: status.NewOK(context.Background())}, nil) + + s.UpsertItem(&sprovider.Reference{ResourceId: info.GetId()}) + + info.ArbitraryMetadata = stored + Expect(thumbnail.NewSourceTypes(nil).HasPreview(info)).To(BeTrue()) + w, h := thumbnail.SourceDimensions(info) + Expect([]int32{w, h}).To(Equal([]int32{wantWidth, wantHeight})) + }, + Entry("audio cover", content.Document{ + MimeType: "audio/mpeg", + Preview: &content.Preview{Width: 500, Height: 400, MimeType: "image/jpeg"}, + }, int32(500), int32(400)), + Entry("image", content.Document{ + MimeType: "image/jpeg", + Image: &libregraph.Image{Width: libregraph.PtrInt32(300), Height: libregraph.PtrInt32(200)}, + }, int32(300), int32(200)), + ) }) DescribeTable("IsHidden", diff --git a/services/webdav/README.md b/services/webdav/README.md index 22ac474844..0a405dfa1f 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -10,6 +10,8 @@ Currently, the webdav service handles request for two functionalities, which are The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints. +When `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set, an Apache Tika server (4.1 or newer) supplies the preview image for documents, raw photos, audio cover art and other types. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. The webdav service calls `PUT /unpack/preset/thumbnail`, so the Tika server must activate the `thumbnail` catalog preset in its configuration (`"presets": {"thumbnail": true}`). Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. An image type is only sent to Tika when the list names it, because the generator behind the URL may read it itself, an imagor with ImageMagick reads heic where the built-in one does not. Naming a type also restricts Tika to the named ones, so a deployment that sends raw photos to Tika has to name the document types it wants from Tika as well. The thumbnail is written in the type the **file's** own type maps to (`OC_THUMBNAIL_TYPE_MAPPING`), not in whatever type Tika hands back, so the answer is the same before and after a cache hit. + Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. A backend nobody can serve, an unknown name or an `s3` backend without bucket, region and credentials, fails at startup instead of serving without a cache, as long as previews are enabled. A service that is not AWS needs `WEBDAV_THUMBNAIL_CACHE_S3_ENDPOINT` too, and it is always addressed over https. The `memory` backend is bounded and drops its oldest entries; `none` disables caching on purpose. #### Thumbnail Query String Parameters diff --git a/services/webdav/pkg/config/config.go b/services/webdav/pkg/config/config.go index 9b7b6d09e3..887a10b32a 100644 --- a/services/webdav/pkg/config/config.go +++ b/services/webdav/pkg/config/config.go @@ -47,7 +47,9 @@ type Config struct { ThumbnailTypeMapping []string `yaml:"thumbnail_type_mapping" env:"OC_THUMBNAIL_TYPE_MAPPING;WEBDAV_THUMBNAIL_TYPE_MAPPING" desc:"The type a thumbnail is returned in, per type of the image it is made from, as mime:mime entries, e.g. image/vnd.microsoft.icon:image/png. The target is asked for by the name WEBDAV_THUMBNAIL_GENERATOR_FORMATS gives it. A type that is not listed becomes a JPEG; a target this service has no format name for is asked for as a JPEG as well." introductionVersion:"%%NEXT%%"` // the yaml key stays unprefixed, a config file written against it has to keep working - ThumbnailFontMapFile string `yaml:"font_map_file" env:"THUMBNAILS_TXT_FONTMAP_FILE;WEBDAV_THUMBNAIL_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"%%NEXT%%"` + ThumbnailFontMapFile string `yaml:"font_map_file" env:"THUMBNAILS_TXT_FONTMAP_FILE;WEBDAV_THUMBNAIL_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"%%NEXT%%"` + TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;WEBDAV_TIKA_URL" desc:"URL of an Apache Tika server. When set, thumbnails are generated for documents, raw photos, audio cover art and other formats Tika can extract a preview from." introductionVersion:"%%NEXT%%"` + TikaThumbnailMimeTypes []string `yaml:"tika_thumbnail_mime_types" env:"OC_TIKA_THUMBNAIL_MIME_TYPES;WEBDAV_TIKA_THUMBNAIL_MIME_TYPES" desc:"Restrict thumbnail generation via Tika to these mime types. Empty means all types that are not handled directly. An entry can map a type to the one Tika knows the format by, e.g. image/x-raw-samsung:image/x-samsung-srw." introductionVersion:"%%NEXT%%"` Context context.Context `yaml:"-"` } diff --git a/services/webdav/pkg/preprocessor/errors.go b/services/webdav/pkg/preprocessor/errors.go index f8c22cfcc4..a0db070da5 100644 --- a/services/webdav/pkg/preprocessor/errors.go +++ b/services/webdav/pkg/preprocessor/errors.go @@ -7,4 +7,5 @@ var ( ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file") ErrNoConverterForExtractedImageFromAudioFile = errors.New("preprocessor: could not find converter for image extracted from audio file") ErrNoConverterForExtractedImageFromGgpFile = errors.New("preprocessor: could not find converter for image extracted from ggp file") + ErrNoThumbnail = errors.New("preprocessor: the document has no thumbnail") ) diff --git a/services/webdav/pkg/preprocessor/preprocessor.go b/services/webdav/pkg/preprocessor/preprocessor.go index 88f70e22a2..41953c9264 100644 --- a/services/webdav/pkg/preprocessor/preprocessor.go +++ b/services/webdav/pkg/preprocessor/preprocessor.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" "image" "image/draw" "image/png" @@ -36,12 +37,13 @@ type Image struct { Data []byte } -// GgsDecoder is a converter for the geogebra slides file +// GgsDecoder is a converter for the geogebra slides file. +// +// Deprecated: Tika provides the thumbnail; kept for setups without Tika. type GgsDecoder struct { thumbnailpath string - // maxSize bounds the extracted image, because a deflated entry expands by - // orders of magnitude and the container's own size says nothing about it. + // a deflated entry expands by orders of magnitude, so its own size says nothing maxSize uint64 } @@ -87,7 +89,9 @@ func (g GgsDecoder) Convert(r io.Reader) (*Image, error) { return nil, errors.Errorf("%s not found", g.thumbnailpath) } -// AudioDecoder is a converter for the audio file +// AudioDecoder is a converter for the audio file. +// +// Deprecated: Tika provides the cover art; kept for setups without Tika. type AudioDecoder struct{} // Convert reads the audio file and extracts the thumbnail image from the id3 tag. @@ -226,7 +230,9 @@ type GGPStruct struct { } } -// GgpDecoder is a converter for the geogebra pinboard file +// GgpDecoder is a converter for the geogebra pinboard file. +// +// Deprecated: see GgsDecoder. type GgpDecoder struct{} // Convert reads the ggp file and returns the first thumbnail image. @@ -355,6 +361,17 @@ type Options struct { FontMapFile string FontFace *opentype.FaceOptions MaxImageSize uint64 + Tika thumbnail.Tika + Filename string +} + +// tikaOr prefers a configured Tika server, else the fallback +func tikaOr(opts Options, mimeType string, fallback FileConverter) FileConverter { + if !opts.Tika.Supports(mimeType) { + return fallback + } + + return TikaThumbnail{tikaURL: opts.Tika.URL, filename: opts.Filename, contentType: opts.Tika.ContentType(mimeType)} } // ForType returns the converter for the specified mimeType @@ -383,19 +400,24 @@ func ForType(mimeType string, opts Options) FileConverter { fontLoader: fontLoader, } case "application/vnd.geogebra.slides": - return GgsDecoder{thumbnailpath: "_slide0/geogebra_thumbnail.png", maxSize: maxExtractedSize(opts)} + return tikaOr(opts, mimeType, GgsDecoder{thumbnailpath: "_slide0/geogebra_thumbnail.png", maxSize: maxExtractedSize(opts)}) case "application/vnd.geogebra.pinboard": - return GgpDecoder{} + return tikaOr(opts, mimeType, GgpDecoder{}) case "audio/flac": fallthrough case "audio/mpeg": fallthrough case "audio/ogg": - return AudioDecoder{} + return tikaOr(opts, mimeType, AudioDecoder{}) default: - // webdav decodes no image: an image/* source is streamed to the - // generator untouched, so a type without a converter here is one - // nobody can make a preview of. - return nil + // an image reaches the generator as it is: it reads the common types and + // may read more, an imagor with ImageMagick reads heic where we do not. + // OC_TIKA_THUMBNAIL_MIME_TYPES is how a deployment hands an image type + // to Tika instead. + if strings.HasPrefix(mimeType, "image/") && !opts.Tika.ConfiguredFor(mimeType) { + return nil + } + + return tikaOr(opts, mimeType, nil) } } diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go new file mode 100644 index 0000000000..58947275fa --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika.go @@ -0,0 +1,125 @@ +package preprocessor + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + "time" +) + +// maxTikaResponse bounds what is read back from Tika +const maxTikaResponse = 100 * 1024 * 1024 + +var tikaHTTPClient = &http.Client{Timeout: 60 * time.Second} + +// TikaThumbnail asks a Tika server for the document's thumbnail. +type TikaThumbnail struct { + tikaURL string + // detection hints + filename string + contentType string +} + +// Convert hands the thumbnail on undecoded: the generator reads formats webdav +// cannot, an .ico icon group out of a PE binary for one. +func (t TikaThumbnail) Convert(r io.Reader) (*Image, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + contentType, img, err := tikaThumbnail(t.tikaURL, t.filename, t.contentType, data) + if err != nil { + return nil, err + } + + return &Image{ContentType: contentType, Data: img}, nil +} + +// tikaThumbnail returns the thumbnail Tika found in the document and its +// content type. The thumbnail catalog preset (Tika 4.1, TIKA-4856) unpacks +// exactly the raster image a client would show for the document — a stored +// thumbnail such as a raw photo's embedded preview or an audio file's cover, +// the rendering of an office document's metafile thumbnail, or the rendered +// first page of a PDF — so the zip carries that one image and nothing else. +func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, []byte, error) { + if contentType == "" { + contentType = "application/octet-stream" + } + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/preset/thumbnail", bytes.NewReader(data)) + if err != nil { + return "", nil, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/zip") + // the extension is what routes a raw image to its parser + if filename != "" { + req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + } + + resp, err := tikaHTTPClient.Do(req) + if err != nil { + return "", nil, fmt.Errorf("tika request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return "", nil, ErrNoThumbnail + case http.StatusNotFound: + // the route exists once the catalog preset is active + return "", nil, fmt.Errorf(`tika unpack returned %s: the "thumbnail" preset needs Tika >= 4.1 and "presets": {"thumbnail": true} in its config`, resp.Status) + default: + return "", nil, fmt.Errorf("tika unpack returned %s", resp.Status) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTikaResponse)) + if err != nil { + return "", nil, fmt.Errorf("tika unpack response: %w", err) + } + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return "", nil, fmt.Errorf("tika unpack response: %w", err) + } + return thumbnailFromZip(zr) +} + +// thumbnailFromZip takes the one image the preset unpacked. Lenient about the +// shape: the first regular file that is not a metadata sidecar wins. +func thumbnailFromZip(zr *zip.Reader) (string, []byte, error) { + for _, f := range zr.File { + if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, ".metadata.json") { + continue + } + img, err := readZipEntry(zr, f.Name) + if err != nil || len(img) == 0 { + return "", nil, ErrNoThumbnail + } + return entryContentType(f.Name, img), img, nil + } + return "", nil, ErrNoThumbnail +} + +// entryContentType names the image's type. The preset zip carries no metadata +// entries, so the extension decides and the bytes break the tie. +func entryContentType(name string, data []byte) string { + if byExt := mime.TypeByExtension(filepath.Ext(name)); byExt != "" { + return byExt + } + return http.DetectContentType(data) +} + +func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { + rc, err := zr.Open(name) + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, maxTikaResponse)) +} diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go new file mode 100644 index 0000000000..8fa8fe1e9a --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -0,0 +1,145 @@ +package preprocessor + +import ( + "archive/zip" + "bytes" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +// presetZip builds what /unpack/preset/thumbnail returns: the one image the +// preset selected, and nothing else. +func presetZip(entries map[string][]byte) []byte { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, data := range entries { + w, err := zw.Create(name) + Expect(err).ToNot(HaveOccurred()) + _, err = w.Write(data) + Expect(err).ToNot(HaveOccurred()) + } + Expect(zw.Close()).To(Succeed()) + return buf.Bytes() +} + +func pngBytes() []byte { + img := image.NewRGBA(image.Rect(0, 0, 4, 3)) + img.Set(1, 1, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return buf.Bytes() +} + +var _ = Describe("TikaThumbnail", func() { + var ( + server *httptest.Server + requests []*http.Request + thumbnail func(w http.ResponseWriter, r *http.Request) + ) + + BeforeEach(func() { + requests = nil + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r) + if r.URL.Path != "/unpack/preset/thumbnail" { + w.WriteHeader(http.StatusNotFound) + return + } + thumbnail(w, r) + })) + }) + + AfterEach(func() { + server.Close() + }) + + It("takes the image the preset unpacked, in one request", func() { + thumbnail = func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPut)) + Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring(`filename="shot.nef"`)) + Expect(r.Header.Get("Content-Type")).To(Equal("image/x-nikon-nef"), "the file's type travels as the detection hint") + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(presetZip(map[string][]byte{"1.png": pngBytes()})) + } + + img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil()) + Expect(requests).To(HaveLen(1)) + }) + + It("decodes an image whose entry name has no telling extension", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(presetZip(map[string][]byte{"thumbnail": pngBytes()})) + } + + img, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil(), "the bytes name the type when the entry does not") + }) + + It("reports a document without a thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip"))) + Expect(err).To(MatchError(ErrNoThumbnail)) + }) + + It("reports an empty zip as no thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(presetZip(nil)) + } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip"))) + Expect(err).To(MatchError(ErrNoThumbnail)) + }) + + It("reports a failing server", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("500"))) + }) + + It("points at the preset config when the route is missing", func() { + _, err := TikaThumbnail{tikaURL: server.URL + "/nowhere"}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("404"))) + Expect(err).To(MatchError(ContainSubstring("preset")), "a 404 means the catalog preset is not active") + }) +}) + +var _ = Describe("ForType with a Tika server", func() { + It("routes everything Tika is asked for to Tika, text and gif stay native", func() { + opts := Options{Tika: thumbnail.NewTika("http://tika:9998", nil), Filename: "song.mp3"} + Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("application/vnd.geogebra.slides", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("text/plain", opts)).To(BeAssignableToTypeOf(TxtToImageConverter{})) + Expect(ForType("image/png", opts)).To(BeNil(), "the generator reads a png itself") + Expect(ForType("image/x-nikon-nef", opts)).To(BeNil(), "an image type the catch-all does not take, the generator may read it") + }) + + It("keeps the in-process converters without a Tika server", func() { + Expect(ForType("audio/mpeg", Options{})).To(BeAssignableToTypeOf(AudioDecoder{})) + Expect(ForType("application/vnd.geogebra.slides", Options{})).To(BeAssignableToTypeOf(GgsDecoder{})) + Expect(ForType("image/x-nikon-nef", Options{})).To(BeNil(), "an image reaches the generator undecoded") + }) + + It("follows a configured list and hands the mapped type to Tika", func() { + opts := Options{Tika: thumbnail.NewTika("http://tika:9998", []string{"application/pdf", "image/x-raw-samsung:image/x-samsung-srw"})} + Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(AudioDecoder{}), "not in the configured list") + Expect(ForType("image/x-raw-samsung", opts).(TikaThumbnail).contentType).To(Equal("image/x-samsung-srw"), "a named image type does go to Tika") + }) +}) diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index 7412d21f4e..0ca5606c7b 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -222,8 +222,9 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType)) - isSupportedMimeType := sourceTypes.Supports(match.Entity.MimeType) - if isSupportedMimeType { + preview := match.Entity.GetPreview() + hasPreview := sourceTypes.HasPreviewForMimeType(match.Entity.MimeType, thumbnail.HasIndexedPreview(preview.GetWidth(), preview.GetHeight(), preview.GetMimeType())) + if hasPreview { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "0")) diff --git a/services/webdav/pkg/service/v0/service.go b/services/webdav/pkg/service/v0/service.go index a992753a5f..d26e41570a 100644 --- a/services/webdav/pkg/service/v0/service.go +++ b/services/webdav/pkg/service/v0/service.go @@ -212,6 +212,7 @@ func newThumbnailWorkflow(conf *config.Config, sourceTypes thumbnail.SourceTypes workflow.WithMaxInputSize(maxInputSize), workflow.WithSizes(sizes), workflow.WithSourceTypes(sourceTypes), + workflow.WithTika(thumbnail.NewTika(conf.TikaURL, conf.TikaThumbnailMimeTypes)), workflow.WithFormats(formats), workflow.WithTypeMapping(typeMapping), workflow.WithWebdavNamespace(conf.WebdavNamespace), @@ -421,7 +422,6 @@ func setAspectIgnoredHeader(w http.ResponseWriter, ignored bool) { // was attempted, for the log line. func (g Webdav) handleWorkflowError(w http.ResponseWriter, r *http.Request, err error, tr *requests.ThumbnailRequest, logger log.Logger, step string) { var busy *workflow.GeneratorBusyError - logger = log.Logger{Logger: logger.With().Str("step", step).Logger()} switch { @@ -444,6 +444,9 @@ func (g Webdav) handleWorkflowError(w http.ResponseWriter, r *http.Request, err case errors.Is(err, workflow.ErrNotFound): logger.Debug().Err(err).Msg("thumbnail source could not be located") renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) + case errors.Is(err, workflow.ErrNoThumbnail): + logger.Debug().Err(err).Msg("the file carries no thumbnail") + renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) case errors.Is(err, workflow.ErrUnsupportedFileType): logger.Debug().Err(err).Msg("requested for an unsupported file type") renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) diff --git a/services/webdav/pkg/service/v0/thumbnail_errors_test.go b/services/webdav/pkg/service/v0/thumbnail_errors_test.go new file mode 100644 index 0000000000..cc79fa72b5 --- /dev/null +++ b/services/webdav/pkg/service/v0/thumbnail_errors_test.go @@ -0,0 +1,39 @@ +package svc + +import ( + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/dav/requests" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail/workflow" +) + +var _ = Describe("thumbnail error mapping", func() { + var ( + g Webdav + tr *requests.ThumbnailRequest + logger log.Logger + ) + + BeforeEach(func() { + logger = log.NopLogger() + g = Webdav{log: logger} + tr = &requests.ThumbnailRequest{Filename: "song.mp3"} + }) + + DescribeTable("a file without a thumbnail is not a server error", + func(step string) { + // clients cache a 404 as "no preview"; a 500 makes them retry forever + rr := httptest.NewRecorder() + g.handleWorkflowError(rr, httptest.NewRequest(http.MethodGet, "/song.mp3", nil), workflow.ErrNoThumbnail, tr, logger, step) + + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }, + Entry("on GET", "thumbnail"), + Entry("on HEAD", "thumbnail head check"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go new file mode 100644 index 0000000000..c05ae82496 --- /dev/null +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -0,0 +1,104 @@ +package thumbnail + +import ( + "mime" + "slices" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + + "github.com/opencloud-eu/opencloud/pkg/conversions" +) + +// Arbitrary-metadata keys holding a preview's dimensions and type, written at +// index time. Their presence signals a preview exists. +const ( + PreviewWidthKey = "oc.preview.width" + PreviewHeightKey = "oc.preview.height" + // the type of the preview image, which decides what a thumbnail of it is + // written in + PreviewMimeTypeKey = "oc.preview.mimeType" +) + +// the image facet an image writes about itself at index time +const ( + imageWidthKey = "libre.graph.image.width" + imageHeightKey = "libre.graph.image.height" +) + +// EmbeddedSourceTypes carry their preview inside the file, audio cover art for +// one, so whether a preview exists is a property of the file and not of its +// type. The index answers that, through the dimensions it stored. +var EmbeddedSourceTypes = []string{ + "audio/flac", + "audio/mpeg", + "audio/ogg", +} + +// IsEmbeddedSourceType reports whether the index has to answer for this type, +// because its preview sits inside the file. +func IsEmbeddedSourceType(mimeType string) bool { + parsed, _, err := mime.ParseMediaType(mimeType) + if err != nil { + return false + } + + return slices.Contains(EmbeddedSourceTypes, parsed) +} + +// HasPreview reports whether a preview can be produced for the resource: a type +// that always has one says so by itself, a type that carries its preview inside +// the file only when the index saw one. +func (t SourceTypes) HasPreview(md *provider.ResourceInfo) bool { + if md == nil { + return false + } + w, h := PreviewDimensions(md) + + return t.HasPreviewForMimeType(md.GetMimeType(), HasIndexedPreview(w, h, PreviewMimeType(md))) +} + +// HasPreviewForMimeType is HasPreview for callers that only have the mimetype +// and a presence signal (e.g. search results) rather than a full ResourceInfo. +func (t SourceTypes) HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { + if !t.Supports(mimeType) { + return false + } + if IsEmbeddedSourceType(mimeType) { + return hasEmbeddedPreview + } + + return true +} + +// SourceDimensions are the dimensions of the image the preview is rendered from, +// or (0, 0) when they are unknown. +func SourceDimensions(md *provider.ResourceInfo) (int32, int32) { + // a preview extracted at index time, an audio cover for one, has its own size + if w, h := PreviewDimensions(md); w > 0 && h > 0 { + return w, h + } + + // an image renders itself, so the image facet holds the size + meta := md.GetArbitraryMetadata().GetMetadata() + + return conversions.StringToInt32(meta[imageWidthKey], 0), conversions.StringToInt32(meta[imageHeightKey], 0) +} + +// PreviewMimeType is the type of the preview image the index saw, empty when +// nothing is stored. +func PreviewMimeType(md *provider.ResourceInfo) string { + return md.GetArbitraryMetadata().GetMetadata()[PreviewMimeTypeKey] +} + +// HasIndexedPreview reports whether the index stored a usable preview: without +// its size and its type, nothing here could say what a thumbnail of it is. +func HasIndexedPreview(width, height int32, mimeType string) bool { + return width > 0 && height > 0 && mimeType != "" +} + +// PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). +func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { + meta := md.GetArbitraryMetadata().GetMetadata() + + return conversions.StringToInt32(meta[PreviewWidthKey], 0), conversions.StringToInt32(meta[PreviewHeightKey], 0) +} diff --git a/services/webdav/pkg/thumbnail/haspreview_test.go b/services/webdav/pkg/thumbnail/haspreview_test.go new file mode 100644 index 0000000000..e6c7f7f110 --- /dev/null +++ b/services/webdav/pkg/thumbnail/haspreview_test.go @@ -0,0 +1,52 @@ +package thumbnail_test + +import ( + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +func resource(mimeType string, meta map[string]string) *provider.ResourceInfo { + return &provider.ResourceInfo{ + MimeType: mimeType, + ArbitraryMetadata: &provider.ArbitraryMetadata{Metadata: meta}, + } +} + +var _ = Describe("HasPreview", func() { + types := thumbnail.NewSourceTypes(nil) + + DescribeTable("answers from the type, or from what the index saw", + func(md *provider.ResourceInfo, want bool) { + Expect(types.HasPreview(md)).To(Equal(want)) + }, + Entry("an image always has one", resource("image/png", nil), true), + Entry("a type nobody makes a preview of has none", resource("application/zip", nil), false), + Entry("audio without indexed dimensions has none", resource("audio/mpeg", nil), false), + Entry("audio the index saw, with its type, has one", + resource("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + thumbnail.PreviewMimeTypeKey: "image/png", + }), true), + Entry("audio the index measured but could not type has none", + resource("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + }), false), + Entry("audio with zero dimensions has none", + resource("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "0", + thumbnail.PreviewHeightKey: "0", + }), false), + Entry("no resource at all", nil, false), + ) + + It("answers the same for a caller that only has the mimetype", func() { + Expect(types.HasPreviewForMimeType("audio/mpeg", true)).To(BeTrue()) + Expect(types.HasPreviewForMimeType("audio/mpeg", false)).To(BeFalse()) + Expect(types.HasPreviewForMimeType("image/png", false)).To(BeTrue()) + }) +}) diff --git a/services/webdav/pkg/thumbnail/sourcetypes.go b/services/webdav/pkg/thumbnail/sourcetypes.go index 7a085e00b7..63d1ba0128 100644 --- a/services/webdav/pkg/thumbnail/sourcetypes.go +++ b/services/webdav/pkg/thumbnail/sourcetypes.go @@ -6,11 +6,10 @@ import ( "strings" ) -// DefaultSourceTypes are the types a preview is announced and attempted for -// without further configuration. The image types are what both resize backends -// read; the others are converted to an image here, by a preprocessor, before a -// generator ever sees them. -var DefaultSourceTypes = []string{ +// GeneratorImageTypes are the image types both resize backends read, so webdav +// hands the file to the generator as it is. Every other type needs something to +// make an image of it first, a preprocessor here or a Tika server. +var GeneratorImageTypes = []string{ "image/png", "image/jpg", "image/jpeg", @@ -19,13 +18,18 @@ var DefaultSourceTypes = []string{ "image/x-ms-bmp", "image/tiff", "image/webp", +} + +// DefaultSourceTypes are the types a preview is announced and attempted for +// without further configuration. +var DefaultSourceTypes = slices.Concat(GeneratorImageTypes, []string{ "text/plain", "audio/flac", "audio/mpeg", "audio/ogg", "application/vnd.geogebra.slides", "application/vnd.geogebra.pinboard", -} +}) // SourceTypes are the types a preview is announced and attempted for: the // defaults plus what a deployment configured, which is also the list the diff --git a/services/webdav/pkg/thumbnail/tika.go b/services/webdav/pkg/thumbnail/tika.go new file mode 100644 index 0000000000..27e822c580 --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika.go @@ -0,0 +1,75 @@ +package thumbnail + +import ( + "mime" + "strings" +) + +// Tika is the configured Tika server: its URL and the types it is asked for. +// It only decides which file goes there, the call lives in the preprocessor. +// By default every non-image file is sent and Tika decides. +type Tika struct { + URL string + // our mime type -> Tika's, "" when equal; "*" means every type + mimeTypes map[string]string +} + +// NewTika parses "mime[:tika-mime]" entries; empty means every type. +func NewTika(url string, mimeTypes []string) Tika { + t := Tika{URL: url, mimeTypes: map[string]string{}} + for _, entry := range mimeTypes { + ours, theirs, _ := strings.Cut(strings.ToLower(strings.TrimSpace(entry)), ":") + if ours = strings.TrimSpace(ours); ours != "" { + t.mimeTypes[ours] = strings.TrimSpace(theirs) + } + } + if len(t.mimeTypes) == 0 { + t.mimeTypes["*"] = "" + } + return t +} + +// Supports reports whether the type is sent to Tika for its thumbnail. +func (t Tika) Supports(mimeType string) bool { + if t.URL == "" { + return false + } + m, _, err := mime.ParseMediaType(mimeType) + if err != nil || m == "httpd/unix-directory" { + return false + } + if _, ok := t.mimeTypes[m]; ok { + return true + } + _, all := t.mimeTypes["*"] + return all +} + +// ConfiguredFor reports whether OC_TIKA_THUMBNAIL_MIME_TYPES lists this type, +// as opposed to the catch-all taking it. An image is only taken away from the +// generator when the configuration asks for it, because the generator may well +// read the type itself. +func (t Tika) ConfiguredFor(mimeType string) bool { + if t.URL == "" { + return false + } + m, _, err := mime.ParseMediaType(mimeType) + if err != nil { + return false + } + _, named := t.mimeTypes[m] + + return named +} + +// ContentType is the type Tika is told, mapped where configured. +func (t Tika) ContentType(mimeType string) string { + m, _, err := mime.ParseMediaType(mimeType) + if err != nil { + return "" + } + if theirs := t.mimeTypes[m]; theirs != "" { + return theirs + } + return m +} diff --git a/services/webdav/pkg/thumbnail/tika_test.go b/services/webdav/pkg/thumbnail/tika_test.go new file mode 100644 index 0000000000..9b5c2ad23d --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika_test.go @@ -0,0 +1,37 @@ +package thumbnail_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +var _ = Describe("Tika", func() { + none := thumbnail.NewTika("", nil) + all := thumbnail.NewTika("http://tika:9998", nil) + some := thumbnail.NewTika("http://tika:9998", []string{" Application/PDF ", "audio/mpeg", "image/x-raw-samsung:image/x-samsung-srw"}) + + DescribeTable("Supports", + func(tika thumbnail.Tika, mimeType string, want bool) { + Expect(tika.Supports(mimeType)).To(Equal(want)) + }, + Entry("nothing without a server", none, "application/pdf", false), + Entry("everything by default", all, "application/x-anything; charset=binary", true), + Entry("but no directories", all, "httpd/unix-directory", false), + Entry("nor an unparsable type", all, "not a mime", false), + Entry("a listed type", some, "application/pdf", true), + Entry("a listed type with parameters", some, "audio/mpeg; charset=binary", true), + Entry("a mapped type", some, "image/x-raw-samsung", true), + Entry("an unlisted type", some, "application/zip", false), + ) + + DescribeTable("ContentType", + func(tika thumbnail.Tika, mimeType, want string) { + Expect(tika.ContentType(mimeType)).To(Equal(want)) + }, + Entry("maps a type to the one Tika knows", some, "image/x-raw-samsung", "image/x-samsung-srw"), + Entry("passes an unmapped type through, without parameters", some, "application/pdf; charset=binary", "application/pdf"), + Entry("passes everything through by default", all, "audio/mpeg", "audio/mpeg"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/typemapping.go b/services/webdav/pkg/thumbnail/typemapping.go index dc7eefbd1a..f833ac5591 100644 --- a/services/webdav/pkg/thumbnail/typemapping.go +++ b/services/webdav/pkg/thumbnail/typemapping.go @@ -24,9 +24,6 @@ var DefaultTypeMapping = []string{ "image/vnd.microsoft.icon:image/png", "image/x-icon:image/png", "text/plain:image/jpeg", - "audio/flac:image/jpeg", - "audio/mpeg:image/jpeg", - "audio/ogg:image/jpeg", "application/vnd.geogebra.slides:image/jpeg", "application/vnd.geogebra.pinboard:image/jpeg", } diff --git a/services/webdav/pkg/thumbnail/typemapping_test.go b/services/webdav/pkg/thumbnail/typemapping_test.go index 4557b75fb1..cfeee4ebad 100644 --- a/services/webdav/pkg/thumbnail/typemapping_test.go +++ b/services/webdav/pkg/thumbnail/typemapping_test.go @@ -11,12 +11,22 @@ import ( var _ = Describe("TypeMapping", func() { m := thumbnail.NewTypeMapping(nil) - It("answers for every type a preview is made of", func() { + It("answers for every type whose output does not depend on the file", func() { for _, mimeType := range thumbnail.NewSourceTypes(nil).List() { + if thumbnail.IsEmbeddedSourceType(mimeType) { + continue + } Expect(m.TargetFor(mimeType)).ToNot(BeEmpty(), "%s has no output type", mimeType) } }) + It("says nothing about a type that carries its preview inside", func() { + // the embedded image's own type decides, and the index knows it + for _, mimeType := range thumbnail.EmbeddedSourceTypes { + Expect(m.TargetFor(mimeType)).To(BeEmpty(), "%s should follow the embedded type", mimeType) + } + }) + It("keeps a format that carries alpha or animation", func() { Expect(m.TargetFor("image/png")).To(Equal("image/png")) Expect(m.TargetFor("image/gif")).To(Equal("image/gif")) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index 4f39a04f0f..1db90a4485 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -71,6 +71,10 @@ type UserResolver interface { // surface this as HTTP 425 Too Early with a Retry-After header. var ErrFileProcessing = fmt.Errorf("file is processing") +// ErrNoThumbnail is the answer for a file that carries none at all. It is +// cached as an empty entry, so the next request does not ask Tika again. +var ErrNoThumbnail = errors.New("thumbnails: the file has no thumbnail") + // ErrImageTooLarge is returned when the input image exceeds the configured // maximum width/height. Callers should surface this as HTTP 403 Forbidden, // matching the legacy thumbnail service error message. @@ -142,6 +146,7 @@ type ThumbnailWorkflow struct { sizes *thumbnail.Sizes typeMapping thumbnail.TypeMapping sourceTypes thumbnail.SourceTypes + tika thumbnail.Tika formats generator.Formats webdavNS string fontMapFile string @@ -227,6 +232,11 @@ func WithTypeMapping(m thumbnail.TypeMapping) Option { return func(w *ThumbnailWorkflow) { w.typeMapping = m } } +// WithTika sets the Tika server that thumbnails what webdav cannot read itself. +func WithTika(t thumbnail.Tika) Option { + return func(w *ThumbnailWorkflow) { w.tika = t } +} + // WithFontMapFile sets the font map file used for text thumbnail rendering. func WithFontMapFile(file string) Option { return func(w *ThumbnailWorkflow) { w.fontMapFile = file } @@ -311,7 +321,7 @@ func (w *ThumbnailWorkflow) statSource(ctx context.Context, ref *providerv1beta1 return nil, ErrPermissionDenied } - if !w.sourceTypes.Supports(info.GetMimeType()) { + if !w.canPreview(info) { return nil, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -418,15 +428,17 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R } mimeType := info.GetMimeType() - // The thumbnail is returned in the type the source type maps to, asked for by - // the name the generator's filter uses. A type nobody mapped, and one whose - // target the generator cannot write, becomes a JPEG. - outputType, outputFormat := "image/jpeg", "jpg" - if target := w.typeMapping.TargetFor(mimeType); target != "" { - if format := w.formats.For(target); format != "" { - outputType, outputFormat = target, format - } + // The thumbnail is returned in the type the source type maps to, asked for + // by the name the generator's filter uses. For a file that carries its + // preview inside, the type that counts is the embedded image's own, which + // the index stored when it saw it: that way a png cover stays a png, and + // the type is still known before the cache key is built. Without it, the + // container's type decides and the cover is re-encoded. + typeForOutput := mimeType + if embedded := thumbnail.PreviewMimeType(info); embedded != "" { + typeForOutput = embedded } + outputType, outputFormat := w.outputFor(typeForOutput) // The operation is resolved up front so it can be part of the cache key: a // fill and a fit-in request with the same dimensions produce different images @@ -452,6 +464,11 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R if w.cache != nil { if cached, err := w.cache.Get(cacheKey); err == nil { + // an empty entry is a file we know carries no thumbnail + if len(cached) == 0 { + return Thumbnail{}, ErrNoThumbnail + } + return Thumbnail{Data: cached, ContentType: outputType, AspectIgnored: aIgnored}, nil } } @@ -460,19 +477,30 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R // generator fill that box. Only fit-in suppresses upscaling (noUpscale); for // fill/thumbnail/resize the generator may enlarge a small source (e.g. a text // file rendered at a fixed canvas) to fill the snapped box, matching main. - genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputFormat, noUpscale) - // Produce the source image to send to the generator, never decoding an image // in webdav: real images (incl. gif) are streamed straight through undecoded; // non-image sources are converted to image bytes here so the generator always // receives an image. imgStream, srcType, cleanup, err := w.sourceImage(ctx, ref, auth, mimeType, tr.Filename, logger) if err != nil { + if errors.Is(err, ErrNoThumbnail) { + // a file that carries no thumbnail is an answer, not a failure, and + // the empty entry keeps the next request from asking again + logger.Debug().Err(err).Msg("no source image for thumbnail") + if w.cache != nil { + _ = w.cache.Put(cacheKey, nil) + } + + return Thumbnail{}, err + } + logger.Error().Err(err).Msg("could not obtain source image for thumbnail") + return Thumbnail{}, err } defer cleanup() + genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputFormat, noUpscale) thumbBytes, thumbType, err := w.postToGenerator(ctx, genURL, imgStream, tr.Filename, srcType) if err != nil { logger.Error().Err(err).Msg("could not generate thumbnail") @@ -506,18 +534,24 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta1.Reference, auth, mimeType, filename string, logger log.Logger) (io.Reader, string, func(), error) { m, _, _ := mime.ParseMediaType(mimeType) - if strings.HasPrefix(m, "image/") { - body, err := w.fileDownloader.DownloadStream(ctx, ref, auth) - if err != nil { - return nil, "", nil, fmt.Errorf("download: %w", err) - } - return body, m, func() { _ = body.Close() }, nil - } + // the preprocessor decides what a type needs. No converter means the file + // is handed to the generator as it is, which is the case for every type it + // reads itself. + converter := preprocessor.ForType(mimeType, preprocessor.Options{ + FontMapFile: w.fontMapFile, + MaxImageSize: w.maxInputSize, + Tika: w.tika, + Filename: filename, + }) body, err := w.fileDownloader.DownloadStream(ctx, ref, auth) if err != nil { return nil, "", nil, fmt.Errorf("download: %w", err) } + + if converter == nil { + return body, m, func() { _ = body.Close() }, nil + } defer body.Close() fileBytes, err := io.ReadAll(body) @@ -525,13 +559,10 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return nil, "", nil, fmt.Errorf("read source: %w", err) } - converter := preprocessor.ForType(mimeType, preprocessor.Options{FontMapFile: w.fontMapFile, MaxImageSize: w.maxInputSize}) - if converter == nil { - logger.Debug().Str("mimetype", mimeType).Msg("no converter for this type") - return nil, "", nil, fmt.Errorf("%w: no converter for %s", ErrNotFound, mimeType) - } - img, err := converter.Convert(bytes.NewReader(fileBytes)) + if errors.Is(err, preprocessor.ErrNoThumbnail) { + return nil, "", nil, ErrNoThumbnail + } if err != nil || img == nil { logger.Debug().Err(err).Msg("could not convert file to image") return nil, "", nil, fmt.Errorf("%w: could not get image", ErrNotFound) @@ -540,6 +571,34 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return bytes.NewReader(img.Data), img.ContentType, func() {}, nil } +// canPreview reports whether a preview is attempted for the resource. A type +// that carries its preview inside the file needs the index to have seen it, +// with the type it is in, because nothing else here could say what a thumbnail +// of it would be. Everything else is answered by its type alone, the announced +// source types plus whatever a configured Tika takes. +func (w *ThumbnailWorkflow) canPreview(info *providerv1beta1.ResourceInfo) bool { + mimeType := info.GetMimeType() + if thumbnail.IsEmbeddedSourceType(mimeType) { + return w.sourceTypes.HasPreview(info) + } + + return w.sourceTypes.Supports(mimeType) || w.tika.Supports(mimeType) +} + +// outputFor is the type a thumbnail of this source is written in and the name +// the generator's format filter has for it. A type nobody mapped, and one whose +// target the generator cannot write, becomes a JPEG. +func (w *ThumbnailWorkflow) outputFor(mimeType string) (outputType, outputFormat string) { + outputType, outputFormat = "image/jpeg", "jpg" + if target := w.typeMapping.TargetFor(mimeType); target != "" { + if format := w.formats.For(target); format != "" { + outputType, outputFormat = target, format + } + } + + return outputType, outputFormat +} + // matchOperation picks how to fit within the box webdav already snapped. A // processor the client named wins, "thumbnail" and "fit" being legacy aliases for // fill and fit-in; without one, an unconstrained axis, then "a", then the source diff --git a/services/webdav/pkg/thumbnail/workflow/workflow_test.go b/services/webdav/pkg/thumbnail/workflow/workflow_test.go index 61560f33b7..318f33a631 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow_test.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow_test.go @@ -192,6 +192,7 @@ func setupPreprocessTest( mimeType string, filePath string, downloadBody []byte, + metadata ...map[string]string, ) (*ThumbnailWorkflow, *capturedUpload) { wf, err := NewWorkflow( WithGeneratorURL("http://generator-test"), @@ -236,6 +237,15 @@ func setupPreprocessTest( MimeType: mimeType, Size: uint64(len(downloadBody)), Checksum: &providerv1beta1.ResourceChecksum{Sum: "1f0c9f1b6e4b4b2a9c1d7f3e8a5b6c7d8e9f0a1b"}, + ArbitraryMetadata: &providerv1beta1.ArbitraryMetadata{ + Metadata: func() map[string]string { + if len(metadata) == 0 { + return nil + } + + return metadata[0] + }(), + }, }, }, nil) @@ -275,6 +285,15 @@ func setupPreprocessTest( return wf, captured } +// indexedCover is what the search extraction leaves on a file whose cover it saw. +func indexedCover(contentType string) map[string]string { + return map[string]string{ + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + thumbnail.PreviewMimeTypeKey: contentType, + } +} + type capturedUpload struct { path string filename string @@ -824,7 +843,7 @@ var _ = Describe("ThumbnailWorkflow", func() { mp3Bytes, err := os.ReadFile(filepath.Join("..", "..", "preprocessor", "test_assets", "empty.mp3")) Expect(err).ToNot(HaveOccurred()) - wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "song.mp3", mp3Bytes) + wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "song.mp3", mp3Bytes, indexedCover("image/jpeg")) tr := &requests.ThumbnailRequest{ Ref: spaceRef("storageid$spaceid!opaqueid", "song.mp3"), @@ -897,7 +916,7 @@ var _ = Describe("ThumbnailWorkflow", func() { }) It("returns an error when the file cannot be converted to an image", func() { - wf2, _ := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "bad.mp3", []byte("not a real audio file")) + wf2, _ := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "bad.mp3", []byte("not a real audio file"), indexedCover("image/jpeg")) tr := &requests.ThumbnailRequest{ Ref: spaceRef("storageid$spaceid!opaqueid", "bad.mp3"), @@ -945,6 +964,27 @@ var _ = Describe("ThumbnailWorkflow", func() { "1f/0c/9f1b6e4b4b2a9c1d7f3e8a5b6c7d8e9f0a1b/64x64-fill.jpg"), ) + It("writes a cover in the type the index saw it in", func() { + // an mp3 says nothing about its cover; the index does, so the png + // stays a png instead of being re-encoded to the container's jpeg + mp3Bytes, err := os.ReadFile(filepath.Join("..", "..", "preprocessor", "test_assets", "empty.mp3")) + Expect(err).ToNot(HaveOccurred()) + + wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "cover.mp3", mp3Bytes, indexedCover("image/png")) + + tr := &requests.ThumbnailRequest{ + Ref: spaceRef("storageid$spaceid!opaqueid", "cover.mp3"), + Filename: "cover.mp3", + Width: 64, + Height: 64, + } + + result, err := wf2.Execute(context.Background(), tr, testToken, logger) + Expect(err).ToNot(HaveOccurred()) + Expect(captured.path).To(ContainSubstring("filters:format(png)")) + Expect(result.ContentType).To(Equal("image/png")) + }) + It("follows a configured chain into a format the built-in generator cannot write", func() { wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "image/png", "img.png", []byte("source-bytes")) wf2.typeMapping = thumbnail.NewTypeMapping([]string{"image/png:image/webp"}) diff --git a/tests/acceptance/docker/src/tika.yml b/tests/acceptance/docker/src/tika.yml index 36bc0a905e..467001dbcc 100644 --- a/tests/acceptance/docker/src/tika.yml +++ b/tests/acceptance/docker/src/tika.yml @@ -6,3 +6,6 @@ services: command: tika:9998 tika: image: apache/tika:4.1.0-full + command: ['-c', '/tika-config.json'] + volumes: + - ../../../config/woodpecker/tika-config.json:/tika-config.json diff --git a/tests/config/woodpecker/tika-config.json b/tests/config/woodpecker/tika-config.json new file mode 100644 index 0000000000..5e0ded5f0a --- /dev/null +++ b/tests/config/woodpecker/tika-config.json @@ -0,0 +1,12 @@ +{ + // -h and -p still come from the command line; a config file has to carry the block + "server": { + "port": 9998, + "host": "0.0.0.0" + }, + // the thumbnail preset ships inert in tika-pipes-core; activating it is what serves + // PUT /unpack/preset/thumbnail, which the webdav thumbnailer asks for + "presets": { + "thumbnail": true + } +}