From 42876ca61cc698f01a37b3e337da88501cd658cb Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 00:30:38 +0200 Subject: [PATCH 01/10] refactor(search): let getFirstValue try multiple metadata keys --- services/search/pkg/content/extractor.go | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/services/search/pkg/content/extractor.go b/services/search/pkg/content/extractor.go index 3d4e457288..a76548ffe3 100644 --- a/services/search/pkg/content/extractor.go +++ b/services/search/pkg/content/extractor.go @@ -2,7 +2,6 @@ package content import ( "context" - "errors" "fmt" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" @@ -13,19 +12,13 @@ type Extractor interface { Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, error) } -func getFirstValue(m map[string][]string, key string) (string, error) { - if m == nil { - return "", errors.New("undefined map") +// getFirstValue returns the first metadata value present among keys, trying them +// in order. It errors when the map is nil or none of the keys holds a value. +func getFirstValue(m map[string][]string, keys ...string) (string, error) { + for _, key := range keys { + if v, ok := m[key]; ok && len(v) > 0 { + return v[0], nil + } } - - v, ok := m[key] - if !ok { - return "", fmt.Errorf("unknown key: %v", key) - } - - if len(v) == 0 { - return "", fmt.Errorf("no values for: %v", key) - } - - return v[0], nil + return "", fmt.Errorf("no value for keys: %v", keys) } From 0f4b95b9ae872920407bf5a8373b4f8d69ce4cf4 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 00:32:26 +0200 Subject: [PATCH 02/10] feat(search): add RetrieveRange to the content retriever --- services/search/pkg/content/cs3.go | 95 ++++++++++++++++--- .../search/pkg/content/mocks/retriever.go | 80 ++++++++++++++++ services/search/pkg/content/retriever.go | 4 + 3 files changed, 166 insertions(+), 13 deletions(-) diff --git a/services/search/pkg/content/cs3.go b/services/search/pkg/content/cs3.go index e21dcd94ec..56b504cc35 100644 --- a/services/search/pkg/content/cs3.go +++ b/services/search/pkg/content/cs3.go @@ -33,37 +33,45 @@ func newCS3Retriever(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], } } -// Retrieve downloads the file from a cs3 service -// The caller MUST make sure to close the returned ReadCloser -func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) { - at, ok := contextGet(ctx, revactx.TokenHeader) +// initiateDownload resolves the download endpoint, transfer token and auth token +// for rID through the cs3 gateway. +func (s cs3) initiateDownload(ctx context.Context, rID *provider.ResourceId) (endpoint, transferToken, authToken string, err error) { + authToken, ok := contextGet(ctx, revactx.TokenHeader) if !ok { - return nil, fmt.Errorf("context without %s", revactx.TokenHeader) + return "", "", "", fmt.Errorf("context without %s", revactx.TokenHeader) } gatewayClient, err := s.gatewaySelector.Next() if err != nil { s.logger.Error().Err(err).Msg("could not get reva gatewayClient") - return nil, err + return "", "", "", err } res, err := gatewayClient.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{Ref: &provider.Reference{ResourceId: rID, Path: "."}}) if err != nil { - return nil, err + return "", "", "", err } if res.Status.Code != rpc.Code_CODE_OK { - return nil, fmt.Errorf("could not load resoure: %s", res.Status.Message) + return "", "", "", fmt.Errorf("could not load resoure: %s", res.Status.Message) } - var ep, tt string for _, p := range res.Protocols { if p.Protocol == "spaces" { - ep, tt = p.DownloadEndpoint, p.Token - break + return p.DownloadEndpoint, p.Token, authToken, nil } } - if (ep == "" || tt == "") && len(res.Protocols) > 0 { - ep, tt = res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token + if len(res.Protocols) > 0 { + return res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token, authToken, nil + } + return "", "", "", fmt.Errorf("no download protocol found") +} + +// Retrieve downloads the file from a cs3 service +// The caller MUST make sure to close the returned ReadCloser +func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) { + ep, tt, at, err := s.initiateDownload(ctx, rID) + if err != nil { + return nil, err } req, err := http.NewRequest(http.MethodGet, ep, nil) @@ -85,3 +93,64 @@ func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadClo return cres.Body, nil } + +// RetrieveRange downloads length bytes starting at offset from a cs3 service. +// The caller MUST make sure to close the returned ReadCloser. +// It relies on HTTP range support of the download endpoint. If the endpoint +// ignores the Range header and returns the full file (200 instead of 206), the +// leading offset bytes are discarded so the returned reader is always positioned +// at offset. +func (s cs3) RetrieveRange(ctx context.Context, rID *provider.ResourceId, offset, length int64) (io.ReadCloser, error) { + if offset < 0 || length <= 0 { + return nil, fmt.Errorf("invalid range: offset %d, length %d", offset, length) + } + + ep, tt, at, err := s.initiateDownload(ctx, rID) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, ep, nil) + if err != nil { + return nil, err + } + + req.Header.Set(revactx.TokenHeader, at) + req.Header.Set("X-Reva-Transfer", tt) + // A single range keeps the response a plain 206 with a Content-Range header, + // never a multipart/byteranges body. + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+length-1)) + + cres, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + + switch cres.StatusCode { + case http.StatusPartialContent: + // Range honored: the body already starts at offset. + return capped(cres.Body, length), nil + case http.StatusOK: + // Range ignored: the body is the whole file. Skip to offset so the + // caller always reads from the requested position. + if _, err := io.CopyN(io.Discard, cres.Body, offset); err != nil { + _ = cres.Body.Close() + return nil, fmt.Errorf("could not skip to offset %d: %w", offset, err) + } + return capped(cres.Body, length), nil + default: + _ = cres.Body.Close() + return nil, fmt.Errorf("could not download range. Request returned with statuscode %d ", cres.StatusCode) + } +} + +type cappedReadCloser struct { + io.Reader + io.Closer +} + +// capped limits rc to length bytes, keeping the contract even when the server +// returns more than the requested range. +func capped(rc io.ReadCloser, length int64) io.ReadCloser { + return cappedReadCloser{io.LimitReader(rc, length), rc} +} diff --git a/services/search/pkg/content/mocks/retriever.go b/services/search/pkg/content/mocks/retriever.go index 5060f071a3..4fb4b5607e 100644 --- a/services/search/pkg/content/mocks/retriever.go +++ b/services/search/pkg/content/mocks/retriever.go @@ -106,3 +106,83 @@ func (_c *Retriever_Retrieve_Call) RunAndReturn(run func(ctx context.Context, rI _c.Call.Return(run) return _c } + +// RetrieveRange provides a mock function for the type Retriever +func (_mock *Retriever) RetrieveRange(ctx context.Context, rID *providerv1beta1.ResourceId, offset int64, length int64) (io.ReadCloser, error) { + ret := _mock.Called(ctx, rID, offset, length) + + if len(ret) == 0 { + panic("no return value specified for RetrieveRange") + } + + var r0 io.ReadCloser + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, int64, int64) (io.ReadCloser, error)); ok { + return returnFunc(ctx, rID, offset, length) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, int64, int64) io.ReadCloser); ok { + r0 = returnFunc(ctx, rID, offset, length) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(io.ReadCloser) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId, int64, int64) error); ok { + r1 = returnFunc(ctx, rID, offset, length) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Retriever_RetrieveRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveRange' +type Retriever_RetrieveRange_Call struct { + *mock.Call +} + +// RetrieveRange is a helper method to define mock.On call +// - ctx context.Context +// - rID *providerv1beta1.ResourceId +// - offset int64 +// - length int64 +func (_e *Retriever_Expecter) RetrieveRange(ctx interface{}, rID interface{}, offset interface{}, length interface{}) *Retriever_RetrieveRange_Call { + return &Retriever_RetrieveRange_Call{Call: _e.mock.On("RetrieveRange", ctx, rID, offset, length)} +} + +func (_c *Retriever_RetrieveRange_Call) Run(run func(ctx context.Context, rID *providerv1beta1.ResourceId, offset int64, length int64)) *Retriever_RetrieveRange_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *providerv1beta1.ResourceId + if args[1] != nil { + arg1 = args[1].(*providerv1beta1.ResourceId) + } + var arg2 int64 + if args[2] != nil { + arg2 = args[2].(int64) + } + var arg3 int64 + if args[3] != nil { + arg3 = args[3].(int64) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *Retriever_RetrieveRange_Call) Return(readCloser io.ReadCloser, err error) *Retriever_RetrieveRange_Call { + _c.Call.Return(readCloser, err) + return _c +} + +func (_c *Retriever_RetrieveRange_Call) RunAndReturn(run func(ctx context.Context, rID *providerv1beta1.ResourceId, offset int64, length int64) (io.ReadCloser, error)) *Retriever_RetrieveRange_Call { + _c.Call.Return(run) + return _c +} diff --git a/services/search/pkg/content/retriever.go b/services/search/pkg/content/retriever.go index bb64584d6f..586e42790f 100644 --- a/services/search/pkg/content/retriever.go +++ b/services/search/pkg/content/retriever.go @@ -12,6 +12,10 @@ import ( // It requests and then returns a resource from the underlying storage. type Retriever interface { Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) + // RetrieveRange returns a reader positioned at offset for up to length bytes + // of the resource. Implementations must ensure the reader starts at offset + // even when the storage does not honor HTTP range requests. + RetrieveRange(ctx context.Context, rID *provider.ResourceId, offset, length int64) (io.ReadCloser, error) } func contextGet(ctx context.Context, k string) (string, bool) { From a2050d1fb97a478cbe2f8b31e5639d48557edb5c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 00:43:51 +0200 Subject: [PATCH 03/10] feat(search): index and expose the motion photo facet --- .../opencloud/messages/search/v0/search.pb.go | 283 ++++++++++++------ .../messages/search/v0/search.pb.web.go | 36 +++ .../services/search/v0/search.swagger.json | 20 ++ .../opencloud/messages/search/v0/search.proto | 7 + services/graph/pkg/service/v0/driveitems.go | 1 + services/search/pkg/bleve/backend.go | 31 +- .../pkg/bleve/testdata/mapping.golden.json | 45 +++ services/search/pkg/content/content.go | 27 +- services/search/pkg/content/tika.go | 9 + .../search/pkg/content/tika_motion_photo.go | 119 ++++++++ .../pkg/content/tika_motion_photo_test.go | 157 ++++++++++ services/search/pkg/content/tika_test.go | 33 ++ .../opensearch/internal/convert/opensearch.go | 11 +- .../opensearch/testdata/resource.golden.json | 13 + services/search/pkg/search/service.go | 1 + 15 files changed, 670 insertions(+), 123 deletions(-) create mode 100644 services/search/pkg/content/tika_motion_photo.go create mode 100644 services/search/pkg/content/tika_motion_photo_test.go diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 4e25412eac..7039ff1813 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.go @@ -654,6 +654,69 @@ func (x *Video) GetWidth() int32 { return 0 } +type MotionPhoto struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version *int32 `protobuf:"varint,1,opt,name=version,proto3,oneof" json:"version,omitempty"` + PresentationTimestampUs *int64 `protobuf:"varint,2,opt,name=presentationTimestampUs,proto3,oneof" json:"presentationTimestampUs,omitempty"` + VideoSize *int64 `protobuf:"varint,3,opt,name=videoSize,proto3,oneof" json:"videoSize,omitempty"` +} + +func (x *MotionPhoto) Reset() { + *x = MotionPhoto{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MotionPhoto) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MotionPhoto) ProtoMessage() {} + +func (x *MotionPhoto) ProtoReflect() protoreflect.Message { + 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 { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MotionPhoto.ProtoReflect.Descriptor instead. +func (*MotionPhoto) Descriptor() ([]byte, []int) { + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{7} +} + +func (x *MotionPhoto) GetVersion() int32 { + if x != nil && x.Version != nil { + return *x.Version + } + return 0 +} + +func (x *MotionPhoto) GetPresentationTimestampUs() int64 { + if x != nil && x.PresentationTimestampUs != nil { + return *x.PresentationTimestampUs + } + return 0 +} + +func (x *MotionPhoto) GetVideoSize() int64 { + if x != nil && x.VideoSize != nil { + return *x.VideoSize + } + return 0 +} + type Entity struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -679,13 +742,14 @@ type Entity struct { Image *Image `protobuf:"bytes,18,opt,name=image,proto3" json:"image,omitempty"` Photo *Photo `protobuf:"bytes,19,opt,name=photo,proto3" json:"photo,omitempty"` Favorites []string `protobuf:"bytes,20,rep,name=favorites,proto3" json:"favorites,omitempty"` + MotionPhoto *MotionPhoto `protobuf:"bytes,21,opt,name=motionPhoto,proto3" json:"motionPhoto,omitempty"` Video *Video `protobuf:"bytes,22,opt,name=video,proto3" json:"video,omitempty"` } func (x *Entity) Reset() { *x = Entity{} 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) } @@ -698,7 +762,7 @@ func (x *Entity) String() string { func (*Entity) ProtoMessage() {} func (x *Entity) 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 { @@ -711,7 +775,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{7} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{8} } func (x *Entity) GetRef() *Reference { @@ -854,6 +918,13 @@ func (x *Entity) GetFavorites() []string { return nil } +func (x *Entity) GetMotionPhoto() *MotionPhoto { + if x != nil { + return x.MotionPhoto + } + return nil +} + func (x *Entity) GetVideo() *Video { if x != nil { return x.Video @@ -875,7 +946,7 @@ type Match struct { func (x *Match) Reset() { *x = Match{} 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) } @@ -888,7 +959,7 @@ func (x *Match) String() string { func (*Match) ProtoMessage() {} func (x *Match) 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 { @@ -901,7 +972,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{8} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{9} } func (x *Match) GetEntity() *Entity { @@ -1069,77 +1140,94 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{ 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, - 0xb5, 0x07, 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, 0x64, 0x2e, 0x6d, + 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, 0x82, 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, 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, 0x6e, 0x63, 0x6c, + 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, 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, 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, + 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, 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, 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 ( @@ -1154,7 +1242,7 @@ 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, 9) +var file_opencloud_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 10) 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 @@ -1163,29 +1251,31 @@ var file_opencloud_messages_search_v0_search_proto_goTypes = []interface{}{ (*GeoCoordinates)(nil), // 4: opencloud.messages.search.v0.GeoCoordinates (*Photo)(nil), // 5: opencloud.messages.search.v0.Photo (*Video)(nil), // 6: opencloud.messages.search.v0.Video - (*Entity)(nil), // 7: opencloud.messages.search.v0.Entity - (*Match)(nil), // 8: opencloud.messages.search.v0.Match - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp + (*MotionPhoto)(nil), // 7: opencloud.messages.search.v0.MotionPhoto + (*Entity)(nil), // 8: opencloud.messages.search.v0.Entity + (*Match)(nil), // 9: opencloud.messages.search.v0.Match + (*timestamppb.Timestamp)(nil), // 10: 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 - 9, // 1: opencloud.messages.search.v0.Photo.takenDateTime:type_name -> google.protobuf.Timestamp + 10, // 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 - 9, // 4: opencloud.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp + 10, // 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 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 - 6, // 11: opencloud.messages.search.v0.Entity.video:type_name -> opencloud.messages.search.v0.Video - 7, // 12: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity - 13, // [13:13] is the sub-list for method output_type - 13, // [13:13] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 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.Match.entity:type_name -> opencloud.messages.search.v0.Entity + 14, // [14:14] is the sub-list for method output_type + 14, // [14:14] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_opencloud_messages_search_v0_search_proto_init() } @@ -1279,7 +1369,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.(*Entity); i { + switch v := v.(*MotionPhoto); i { case 0: return &v.state case 1: @@ -1291,6 +1381,18 @@ 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.(*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[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Match); i { case 0: return &v.state @@ -1308,13 +1410,14 @@ func file_opencloud_messages_search_v0_search_proto_init() { file_opencloud_messages_search_v0_search_proto_msgTypes[4].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[5].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[6].OneofWrappers = []interface{}{} + file_opencloud_messages_search_v0_search_proto_msgTypes[7].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: 9, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.web.go b/protogen/gen/opencloud/messages/search/v0/search.pb.web.go index 1c4c124331..8bb3efa233 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.web.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.web.go @@ -262,6 +262,42 @@ func (m *Video) UnmarshalJSON(b []byte) error { var _ json.Unmarshaler = (*Video)(nil) +// MotionPhotoJSONMarshaler describes the default jsonpb.Marshaler used by all +// instances of MotionPhoto. This struct is safe to replace or modify but +// should not be done so concurrently. +var MotionPhotoJSONMarshaler = new(jsonpb.Marshaler) + +// MarshalJSON satisfies the encoding/json Marshaler interface. This method +// uses the more correct jsonpb package to correctly marshal the message. +func (m *MotionPhoto) MarshalJSON() ([]byte, error) { + if m == nil { + return json.Marshal(nil) + } + + buf := &bytes.Buffer{} + + if err := MotionPhotoJSONMarshaler.Marshal(buf, m); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +var _ json.Marshaler = (*MotionPhoto)(nil) + +// MotionPhotoJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all +// instances of MotionPhoto. This struct is safe to replace or modify but +// should not be done so concurrently. +var MotionPhotoJSONUnmarshaler = new(jsonpb.Unmarshaler) + +// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method +// uses the more correct jsonpb package to correctly unmarshal the message. +func (m *MotionPhoto) UnmarshalJSON(b []byte) error { + return MotionPhotoJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m) +} + +var _ json.Unmarshaler = (*MotionPhoto)(nil) + // EntityJSONMarshaler describes the default jsonpb.Marshaler used by all // instances of Entity. This struct is safe to replace or modify but // should not be done so concurrently. diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index a87a508183..3b70f96a75 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -299,6 +299,9 @@ "type": "string" } }, + "motionPhoto": { + "$ref": "#/definitions/v0MotionPhoto" + }, "video": { "$ref": "#/definitions/v0Video" } @@ -393,6 +396,23 @@ } } }, + "v0MotionPhoto": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "format": "int32" + }, + "presentationTimestampUs": { + "type": "string", + "format": "int64" + }, + "videoSize": { + "type": "string", + "format": "int64" + } + } + }, "v0Photo": { "type": "object", "properties": { diff --git a/protogen/proto/opencloud/messages/search/v0/search.proto b/protogen/proto/opencloud/messages/search/v0/search.proto index 3041f2c4f7..76cea9f1ef 100644 --- a/protogen/proto/opencloud/messages/search/v0/search.proto +++ b/protogen/proto/opencloud/messages/search/v0/search.proto @@ -72,6 +72,12 @@ message Video { optional int32 width = 10; } +message MotionPhoto { + optional int32 version = 1; + optional int64 presentationTimestampUs = 2; + optional int64 videoSize = 3; +} + message Entity { Reference ref = 1; ResourceID id = 2; @@ -93,6 +99,7 @@ message Entity { Image image = 18; Photo photo = 19; repeated string favorites = 20; + MotionPhoto motionPhoto = 21; Video video = 22; } diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 107018fff2..900a511c33 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -485,6 +485,7 @@ func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *sto driveItem.Location = metadataToFacet[libregraph.GeoCoordinates](metadata, "location") driveItem.Photo = metadataToFacet[libregraph.Photo](metadata, "photo") driveItem.Video = metadataToFacet[libregraph.Video](metadata, "video") + driveItem.LibreGraphMotionPhoto = metadataToFacet[libregraph.MotionPhoto](metadata, "motionPhoto") driveItem.LibreGraphMeFollowing = libregraph.PtrBool(metadata[_favoriteMetadataKey] == "1") if t := metadata["tags"]; t != "" { driveItem.LibreGraphTags = tags.New(t).AsSlice() diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index 60dce22d15..eab7b6bf44 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -125,21 +125,22 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques ResourceId: resourceIDtoSearchID(rootID), Path: getFieldValue[string](hit.Fields, "Path"), }, - Id: resourceIDtoSearchID(rID), - Name: getFieldValue[string](hit.Fields, "Name"), - ParentId: resourceIDtoSearchID(pID), - Size: uint64(getFieldValue[float64](hit.Fields, "Size")), - Type: uint64(getFieldValue[float64](hit.Fields, "Type")), - MimeType: getFieldValue[string](hit.Fields, "MimeType"), - Deleted: getFieldValue[bool](hit.Fields, "Deleted"), - Tags: getFieldSliceValue[string](hit.Fields, "Tags"), - Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"), - Highlights: getFragmentValue(hit.Fragments, "Content", 0), - Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"), - Image: hitToFacet[searchMessage.Image](hit.Fields, "image"), - Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"), - Photo: hitToFacet[searchMessage.Photo](hit.Fields, "photo"), - Video: hitToFacet[searchMessage.Video](hit.Fields, "video"), + Id: resourceIDtoSearchID(rID), + Name: getFieldValue[string](hit.Fields, "Name"), + ParentId: resourceIDtoSearchID(pID), + Size: uint64(getFieldValue[float64](hit.Fields, "Size")), + Type: uint64(getFieldValue[float64](hit.Fields, "Type")), + MimeType: getFieldValue[string](hit.Fields, "MimeType"), + Deleted: getFieldValue[bool](hit.Fields, "Deleted"), + Tags: getFieldSliceValue[string](hit.Fields, "Tags"), + Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"), + Highlights: getFragmentValue(hit.Fragments, "Content", 0), + Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"), + Image: hitToFacet[searchMessage.Image](hit.Fields, "image"), + Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"), + Photo: hitToFacet[searchMessage.Photo](hit.Fields, "photo"), + Video: hitToFacet[searchMessage.Video](hit.Fields, "video"), + MotionPhoto: hitToFacet[searchMessage.MotionPhoto](hit.Fields, "motionPhoto"), }, } diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index d00d1365cd..13908b5be0 100644 --- a/services/search/pkg/bleve/testdata/mapping.golden.json +++ b/services/search/pkg/bleve/testdata/mapping.golden.json @@ -760,6 +760,51 @@ } ] }, + "motionPhoto": { + "enabled": true, + "dynamic": true, + "properties": { + "presentationTimestampUs": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + }, + "version": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + }, + "videoSize": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + } + } + }, "photo": { "enabled": true, "dynamic": true, diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index 28a05a8a7e..7dc481fd23 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -15,19 +15,20 @@ func init() { // Document wraps all resource meta fields, // it is used as a content extraction result. type Document struct { - Title string `json:"Title"` - Name string `json:"Name"` - Content string `json:"Content"` - Size uint64 `json:"Size"` - Mtime *time.Time `json:"Mtime,omitempty"` - MimeType string `json:"MimeType"` - Tags []string `json:"Tags"` - Favorites []string `json:"Favorites"` - Audio *libregraph.Audio `json:"audio,omitempty"` - Image *libregraph.Image `json:"image,omitempty"` - Location *libregraph.GeoCoordinates `json:"location,omitempty"` - Photo *libregraph.Photo `json:"photo,omitempty"` - Video *libregraph.Video `json:"video,omitempty"` + Title string `json:"Title"` + Name string `json:"Name"` + Content string `json:"Content"` + Size uint64 `json:"Size"` + Mtime *time.Time `json:"Mtime,omitempty"` + MimeType string `json:"MimeType"` + Tags []string `json:"Tags"` + Favorites []string `json:"Favorites"` + Audio *libregraph.Audio `json:"audio,omitempty"` + Image *libregraph.Image `json:"image,omitempty"` + Location *libregraph.GeoCoordinates `json:"location,omitempty"` + Photo *libregraph.Photo `json:"photo,omitempty"` + Video *libregraph.Video `json:"video,omitempty"` + MotionPhoto *libregraph.MotionPhoto `json:"motionPhoto,omitempty"` } func CleanString(content, langCode string) string { diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index ee94c7da4d..89f368a831 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -115,6 +115,15 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if v := t.getVideo(meta); v != nil { doc.Video = v } + if v := t.getMotionPhoto(meta); v != nil { + doc.MotionPhoto = v + } + } + + // verify against the file itself: a shared motion photo can keep the XMP but + // lose the appended video, which would leave an unplayable facet. + if doc.MotionPhoto != nil && !t.motionPhotoHasVideo(ctx, ri, doc.MotionPhoto.GetVideoSize()) { + doc.MotionPhoto = nil } if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { diff --git a/services/search/pkg/content/tika_motion_photo.go b/services/search/pkg/content/tika_motion_photo.go new file mode 100644 index 0000000000..b02a84e21f --- /dev/null +++ b/services/search/pkg/content/tika_motion_photo.go @@ -0,0 +1,119 @@ +package content + +import ( + "context" + "io" + "sort" + "strconv" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + libregraph "github.com/opencloud-eu/libre-graph-api-go" +) + +// motionPhotoVideoSignatureLen is the number of trailing bytes we read to confirm +// an actual video is present. Enough to cover the ISO base media (MP4) box size +// and the "ftyp" box type at bytes [4:8]. +const motionPhotoVideoSignatureLen = 12 + +// getMotionPhoto reads Google Motion Photo XMP, which Tika exposes under the +// canonical Camera/Container prefixes. It covers both the current MotionPhoto +// scheme and the legacy MicroVideo scheme. videoSize (the embedded video's byte +// length, needed to range-fetch it) is required, so the facet is dropped without it. +func (t Tika) getMotionPhoto(meta map[string][]string) *libregraph.MotionPhoto { + // per the spec only a MotionPhoto/MicroVideo marker of 1 means motion + // photo, every other value is "treat as a still image". An absent marker + // is tolerated on purpose, the byte-level video check below decides. + if v, err := getFirstValue(meta, "Camera:MotionPhoto", "Camera:MicroVideo"); err == nil && v != "1" { + return nil + } + + var motionPhoto *libregraph.MotionPhoto + initMotionPhoto := func() { + if motionPhoto == nil { + motionPhoto = libregraph.NewMotionPhoto() + } + } + + if v, err := getFirstValue(meta, "Camera:MotionPhotoVersion", "Camera:MicroVideoVersion"); err == nil { + if i, err := strconv.ParseInt(v, 10, 32); err == nil { + initMotionPhoto() + motionPhoto.SetVersion(int32(i)) + } + } + + if v, err := getFirstValue(meta, "Camera:MotionPhotoPresentationTimestampUs", "Camera:MicroVideoPresentationTimestampUs"); err == nil { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + initMotionPhoto() + motionPhoto.SetPresentationTimestampUs(i) + } + } + + if size, ok := motionPhotoVideoSize(meta); ok { + initMotionPhoto() + motionPhoto.SetVideoSize(size) + } + + if motionPhoto == nil || !motionPhoto.HasVideoSize() { + return nil + } + return motionPhoto +} + +// motionPhotoVideoSize returns the embedded video's byte length: the length of +// the Container item whose semantic is "MotionPhoto", or for legacy files the +// MicroVideo offset (bytes from EOF to the video start, which equals its +// length). The current scheme wins when both are present, like everywhere else. +func motionPhotoVideoSize(meta map[string][]string) (int64, bool) { + keys := make([]string, 0, len(meta)) + for k := range meta { + keys = append(keys, k) + } + // map order is random, the first matching container item must be stable + sort.Strings(keys) + for _, k := range keys { + if vals := meta[k]; !strings.HasSuffix(k, "/Item:Semantic") || len(vals) == 0 || vals[0] != "MotionPhoto" { + continue + } + if v, err := getFirstValue(meta, strings.TrimSuffix(k, "/Item:Semantic")+"/Item:Length"); err == nil { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i, true + } + } + } + if v, err := getFirstValue(meta, "Camera:MicroVideoOffset"); err == nil { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i, true + } + } + return 0, false +} + +// looksLikeMP4 reports whether buf begins with an ISO base media (MP4/QuickTime) +// "ftyp" box, which Google Motion Photo and legacy MicroVideo clips start with. +func looksLikeMP4(buf []byte) bool { + return len(buf) >= 8 && string(buf[4:8]) == "ftyp" +} + +// motionPhotoHasVideo confirms that the file actually contains the embedded video +// the XMP advertises. A photos.google.com share strips the appended video but +// keeps the XMP, which would otherwise make us expose an unplayable facet. The +// video is appended at the end, so it starts at fileSize-videoSize; we read a few +// bytes there and require an MP4 signature. +func (t Tika) motionPhotoHasVideo(ctx context.Context, ri *provider.ResourceInfo, videoSize int64) bool { + size := int64(ri.GetSize()) + if videoSize <= 0 || videoSize >= size { + return false + } + + rc, err := t.RetrieveRange(ctx, ri.GetId(), size-videoSize, motionPhotoVideoSignatureLen) + if err != nil { + t.logger.Debug().Err(err).Interface("ResourceID", ri.GetId()).Msg("could not read motion photo video header, dropping facet") + return false + } + defer rc.Close() + + buf := make([]byte, motionPhotoVideoSignatureLen) + n, _ := io.ReadFull(rc, buf) + return looksLikeMP4(buf[:n]) +} diff --git a/services/search/pkg/content/tika_motion_photo_test.go b/services/search/pkg/content/tika_motion_photo_test.go new file mode 100644 index 0000000000..02dd8793ce --- /dev/null +++ b/services/search/pkg/content/tika_motion_photo_test.go @@ -0,0 +1,157 @@ +package content + +import ( + "context" + "errors" + "io" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + + "github.com/opencloud-eu/opencloud/pkg/log" +) + +var _ = Describe("getMotionPhoto", func() { + It("maps the current MotionPhoto XMP scheme (container item length)", func() { + mp := Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MotionPhotoVersion": {"1"}, + "Camera:MotionPhotoPresentationTimestampUs": {"1500000"}, + "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, + "Container:Directory/Item[2]/Item:Length": {"1048576"}, + }) + Expect(mp).ToNot(BeNil()) + Expect(mp.Version).To(Equal(libregraph.PtrInt32(1))) + Expect(mp.PresentationTimestampUs).To(Equal(libregraph.PtrInt64(1500000))) + Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(1048576))) + }) + + It("maps the legacy MicroVideo XMP scheme (offset is the length)", func() { + mp := Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MicroVideoVersion": {"1"}, + "Camera:MicroVideoPresentationTimestampUs": {"1500000"}, + "Camera:MicroVideoOffset": {"2097152"}, + }) + Expect(mp).ToNot(BeNil()) + Expect(mp.Version).To(Equal(libregraph.PtrInt32(1))) + Expect(mp.PresentationTimestampUs).To(Equal(libregraph.PtrInt64(1500000))) + Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(2097152))) + }) + + It("drops the facet without a video size", func() { + Expect(Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MotionPhotoVersion": {"1"}, + })).To(BeNil()) + }) + + It("returns nil when no motion photo metadata is present", func() { + Expect(Tika{}.getMotionPhoto(map[string][]string{})).To(BeNil()) + }) + + It("treats a zero MotionPhoto marker as a still image", func() { + Expect(Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MotionPhoto": {"0"}, + "Camera:MotionPhotoVersion": {"1"}, + "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, + "Container:Directory/Item[2]/Item:Length": {"1048576"}, + })).To(BeNil()) + Expect(Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MicroVideo": {"0"}, + "Camera:MicroVideoOffset": {"2097152"}, + })).To(BeNil()) + }) + + It("treats undefined marker values as a still image", func() { + Expect(Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MotionPhoto": {"2"}, + "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, + "Container:Directory/Item[2]/Item:Length": {"1048576"}, + })).To(BeNil()) + }) + + It("prefers the current scheme when both are present", func() { + mp := Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MotionPhotoVersion": {"2"}, + "Camera:MicroVideoVersion": {"1"}, + "Camera:MicroVideoOffset": {"2097152"}, + "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, + "Container:Directory/Item[2]/Item:Length": {"1048576"}, + }) + Expect(mp).ToNot(BeNil()) + Expect(mp.Version).To(Equal(libregraph.PtrInt32(2))) + Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(1048576))) + }) +}) + +var _ = Describe("looksLikeMP4", func() { + It("recognizes an ftyp box and rejects everything else", func() { + Expect(looksLikeMP4([]byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'})).To(BeTrue()) + Expect(looksLikeMP4([]byte("JFIF garbage"))).To(BeFalse()) + Expect(looksLikeMP4([]byte{0, 0, 0})).To(BeFalse()) + }) +}) + +// rangeStub serves RetrieveRange from a string and records the requested range. +type rangeStub struct { + data string + err error + gotOffset int64 + gotLength int64 + calls int +} + +func (r *rangeStub) Retrieve(context.Context, *provider.ResourceId) (io.ReadCloser, error) { + return nil, errors.New("unused") +} + +func (r *rangeStub) RetrieveRange(_ context.Context, _ *provider.ResourceId, offset, length int64) (io.ReadCloser, error) { + r.calls++ + r.gotOffset, r.gotLength = offset, length + if r.err != nil { + return nil, r.err + } + return io.NopCloser(strings.NewReader(r.data)), nil +} + +var _ = Describe("motionPhotoHasVideo", func() { + var ( + retriever *rangeStub + tika Tika + ri *provider.ResourceInfo + ) + + BeforeEach(func() { + retriever = &rangeStub{} + basic, err := NewBasicExtractor(log.NewLogger()) + Expect(err).ToNot(HaveOccurred()) + tika = Tika{Basic: basic, Retriever: retriever} + ri = &provider.ResourceInfo{Size: 100} + }) + + It("confirms a video that starts with an ftyp box", func() { + retriever.data = "\x00\x00\x00\x18ftypisom" + Expect(tika.motionPhotoHasVideo(context.Background(), ri, 40)).To(BeTrue()) + Expect(retriever.gotOffset).To(Equal(int64(60)), "the video starts at size-videoSize") + Expect(retriever.gotLength).To(Equal(int64(motionPhotoVideoSignatureLen))) + }) + + It("rejects trailing bytes without an MP4 signature", func() { + retriever.data = "not a video " + Expect(tika.motionPhotoHasVideo(context.Background(), ri, 40)).To(BeFalse()) + }) + + It("rejects degenerate video sizes without reading", func() { + Expect(tika.motionPhotoHasVideo(context.Background(), ri, 0)).To(BeFalse()) + Expect(tika.motionPhotoHasVideo(context.Background(), ri, -1)).To(BeFalse()) + Expect(tika.motionPhotoHasVideo(context.Background(), ri, 100)).To(BeFalse()) + Expect(tika.motionPhotoHasVideo(context.Background(), ri, 101)).To(BeFalse()) + Expect(retriever.calls).To(BeZero()) + }) + + It("drops the facet when the range read fails", func() { + retriever.err = errors.New("nope") + Expect(tika.motionPhotoHasVideo(context.Background(), ri, 40)).To(BeFalse()) + }) +}) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index ddb4e85320..1e4d64feef 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -217,6 +217,39 @@ var _ = Describe("Tika", func() { Expect(doc.Content).To(Equal("one two")) }) + It("verifies the motion photo video against the file", func() { + fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}]` + retriever := &contentMocks.Retriever{} + retriever.On("Retrieve", mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("")), nil) + retriever.On("RetrieveRange", mock.Anything, mock.Anything, int64(60), mock.Anything). + Return(io.NopCloser(strings.NewReader("\x00\x00\x00\x18ftypisom")), nil) + tika.Retriever = retriever + + doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 100, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(doc.MotionPhoto).ToNot(BeNil()) + Expect(doc.MotionPhoto.VideoSize).To(Equal(libregraph.PtrInt64(40))) + }) + + It("drops the motion photo facet when the advertised video is gone", func() { + fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}]` + retriever := &contentMocks.Retriever{} + retriever.On("Retrieve", mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("")), nil) + retriever.On("RetrieveRange", mock.Anything, mock.Anything, int64(60), mock.Anything). + Return(io.NopCloser(strings.NewReader("JFIF leftovers")), nil) + tika.Retriever = retriever + + doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 100, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(doc.MotionPhoto).To(BeNil()) + }) + It("keeps stop words", func() { body = "body to test stop words!!! against almost everyone" language = "en" diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 7ed7804452..f1e242688b 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -79,11 +79,12 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, return strings.Join(contentHighlights[:], "; ") }(), - Audio: copyFacet[searchMessage.Audio](resource.Audio), - Image: copyFacet[searchMessage.Image](resource.Image), - Location: copyFacet[searchMessage.GeoCoordinates](resource.Location), - Photo: copyFacet[searchMessage.Photo](resource.Photo), - Video: copyFacet[searchMessage.Video](resource.Video), + Audio: copyFacet[searchMessage.Audio](resource.Audio), + Image: copyFacet[searchMessage.Image](resource.Image), + Location: copyFacet[searchMessage.GeoCoordinates](resource.Location), + Photo: copyFacet[searchMessage.Photo](resource.Photo), + Video: copyFacet[searchMessage.Video](resource.Video), + MotionPhoto: copyFacet[searchMessage.MotionPhoto](resource.MotionPhoto), }, } diff --git a/services/search/pkg/opensearch/testdata/resource.golden.json b/services/search/pkg/opensearch/testdata/resource.golden.json index 78d2de011b..a1c1aadc56 100644 --- a/services/search/pkg/opensearch/testdata/resource.golden.json +++ b/services/search/pkg/opensearch/testdata/resource.golden.json @@ -204,6 +204,19 @@ "location_geopoint": { "type": "geo_point" }, + "motionPhoto": { + "properties": { + "presentationTimestampUs": { + "type": "long" + }, + "version": { + "type": "integer" + }, + "videoSize": { + "type": "long" + } + } + }, "photo": { "properties": { "cameraMake": { diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index 379b74ee17..ef6bec8a5f 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -686,6 +686,7 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) { facetToMetadata(metadata, doc.Location, "libre.graph.location.") facetToMetadata(metadata, doc.Photo, "libre.graph.photo.") facetToMetadata(metadata, doc.Video, "libre.graph.video.") + facetToMetadata(metadata, doc.MotionPhoto, "libre.graph.motionPhoto.") if len(metadata) == 0 { return } From 709962b61609da99bb9109ef2c82c06409ea2c96 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 2 Sep 2026 19:01:56 +0200 Subject: [PATCH 04/10] feat(search): confirm the motion photo video via tika instead of reading bytes --- services/search/pkg/content/cs3.go | 61 ------------- .../search/pkg/content/mocks/retriever.go | 80 ----------------- services/search/pkg/content/retriever.go | 4 - services/search/pkg/content/tika.go | 10 ++- .../search/pkg/content/tika_motion_photo.go | 46 +++------- .../pkg/content/tika_motion_photo_test.go | 87 +++---------------- services/search/pkg/content/tika_test.go | 14 +-- 7 files changed, 33 insertions(+), 269 deletions(-) diff --git a/services/search/pkg/content/cs3.go b/services/search/pkg/content/cs3.go index 56b504cc35..a458b68f48 100644 --- a/services/search/pkg/content/cs3.go +++ b/services/search/pkg/content/cs3.go @@ -93,64 +93,3 @@ func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadClo return cres.Body, nil } - -// RetrieveRange downloads length bytes starting at offset from a cs3 service. -// The caller MUST make sure to close the returned ReadCloser. -// It relies on HTTP range support of the download endpoint. If the endpoint -// ignores the Range header and returns the full file (200 instead of 206), the -// leading offset bytes are discarded so the returned reader is always positioned -// at offset. -func (s cs3) RetrieveRange(ctx context.Context, rID *provider.ResourceId, offset, length int64) (io.ReadCloser, error) { - if offset < 0 || length <= 0 { - return nil, fmt.Errorf("invalid range: offset %d, length %d", offset, length) - } - - ep, tt, at, err := s.initiateDownload(ctx, rID) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodGet, ep, nil) - if err != nil { - return nil, err - } - - req.Header.Set(revactx.TokenHeader, at) - req.Header.Set("X-Reva-Transfer", tt) - // A single range keeps the response a plain 206 with a Content-Range header, - // never a multipart/byteranges body. - req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, offset+length-1)) - - cres, err := s.httpClient.Do(req) - if err != nil { - return nil, err - } - - switch cres.StatusCode { - case http.StatusPartialContent: - // Range honored: the body already starts at offset. - return capped(cres.Body, length), nil - case http.StatusOK: - // Range ignored: the body is the whole file. Skip to offset so the - // caller always reads from the requested position. - if _, err := io.CopyN(io.Discard, cres.Body, offset); err != nil { - _ = cres.Body.Close() - return nil, fmt.Errorf("could not skip to offset %d: %w", offset, err) - } - return capped(cres.Body, length), nil - default: - _ = cres.Body.Close() - return nil, fmt.Errorf("could not download range. Request returned with statuscode %d ", cres.StatusCode) - } -} - -type cappedReadCloser struct { - io.Reader - io.Closer -} - -// capped limits rc to length bytes, keeping the contract even when the server -// returns more than the requested range. -func capped(rc io.ReadCloser, length int64) io.ReadCloser { - return cappedReadCloser{io.LimitReader(rc, length), rc} -} diff --git a/services/search/pkg/content/mocks/retriever.go b/services/search/pkg/content/mocks/retriever.go index 4fb4b5607e..5060f071a3 100644 --- a/services/search/pkg/content/mocks/retriever.go +++ b/services/search/pkg/content/mocks/retriever.go @@ -106,83 +106,3 @@ func (_c *Retriever_Retrieve_Call) RunAndReturn(run func(ctx context.Context, rI _c.Call.Return(run) return _c } - -// RetrieveRange provides a mock function for the type Retriever -func (_mock *Retriever) RetrieveRange(ctx context.Context, rID *providerv1beta1.ResourceId, offset int64, length int64) (io.ReadCloser, error) { - ret := _mock.Called(ctx, rID, offset, length) - - if len(ret) == 0 { - panic("no return value specified for RetrieveRange") - } - - var r0 io.ReadCloser - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, int64, int64) (io.ReadCloser, error)); ok { - return returnFunc(ctx, rID, offset, length) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ResourceId, int64, int64) io.ReadCloser); ok { - r0 = returnFunc(ctx, rID, offset, length) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(io.ReadCloser) - } - } - if returnFunc, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ResourceId, int64, int64) error); ok { - r1 = returnFunc(ctx, rID, offset, length) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// Retriever_RetrieveRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveRange' -type Retriever_RetrieveRange_Call struct { - *mock.Call -} - -// RetrieveRange is a helper method to define mock.On call -// - ctx context.Context -// - rID *providerv1beta1.ResourceId -// - offset int64 -// - length int64 -func (_e *Retriever_Expecter) RetrieveRange(ctx interface{}, rID interface{}, offset interface{}, length interface{}) *Retriever_RetrieveRange_Call { - return &Retriever_RetrieveRange_Call{Call: _e.mock.On("RetrieveRange", ctx, rID, offset, length)} -} - -func (_c *Retriever_RetrieveRange_Call) Run(run func(ctx context.Context, rID *providerv1beta1.ResourceId, offset int64, length int64)) *Retriever_RetrieveRange_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 *providerv1beta1.ResourceId - if args[1] != nil { - arg1 = args[1].(*providerv1beta1.ResourceId) - } - var arg2 int64 - if args[2] != nil { - arg2 = args[2].(int64) - } - var arg3 int64 - if args[3] != nil { - arg3 = args[3].(int64) - } - run( - arg0, - arg1, - arg2, - arg3, - ) - }) - return _c -} - -func (_c *Retriever_RetrieveRange_Call) Return(readCloser io.ReadCloser, err error) *Retriever_RetrieveRange_Call { - _c.Call.Return(readCloser, err) - return _c -} - -func (_c *Retriever_RetrieveRange_Call) RunAndReturn(run func(ctx context.Context, rID *providerv1beta1.ResourceId, offset int64, length int64) (io.ReadCloser, error)) *Retriever_RetrieveRange_Call { - _c.Call.Return(run) - return _c -} diff --git a/services/search/pkg/content/retriever.go b/services/search/pkg/content/retriever.go index 586e42790f..bb64584d6f 100644 --- a/services/search/pkg/content/retriever.go +++ b/services/search/pkg/content/retriever.go @@ -12,10 +12,6 @@ import ( // It requests and then returns a resource from the underlying storage. type Retriever interface { Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) - // RetrieveRange returns a reader positioned at offset for up to length bytes - // of the resource. Implementations must ensure the reader starts at offset - // even when the storage does not honor HTTP range requests. - RetrieveRange(ctx context.Context, rID *provider.ResourceId, offset, length int64) (io.ReadCloser, error) } func contextGet(ctx context.Context, k string) (string, bool) { diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 89f368a831..7bcdae426e 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -82,6 +82,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, err } + var motionPhotoVideo bool for _, meta := range metas { title, err := getFirstValue(meta, "dc:title") if err != nil { @@ -118,11 +119,14 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if v := t.getMotionPhoto(meta); v != nil { doc.MotionPhoto = v } + if isMotionPhotoVideo(meta) { + motionPhotoVideo = true + } } - // verify against the file itself: a shared motion photo can keep the XMP but - // lose the appended video, which would leave an unplayable facet. - if doc.MotionPhoto != nil && !t.motionPhotoHasVideo(ctx, ri, doc.MotionPhoto.GetVideoSize()) { + // the xmp alone does not prove the video is there, tika emitting it as an + // embedded attachment does + if !motionPhotoVideo { doc.MotionPhoto = nil } diff --git a/services/search/pkg/content/tika_motion_photo.go b/services/search/pkg/content/tika_motion_photo.go index b02a84e21f..5115f13a38 100644 --- a/services/search/pkg/content/tika_motion_photo.go +++ b/services/search/pkg/content/tika_motion_photo.go @@ -1,20 +1,16 @@ package content import ( - "context" - "io" + libregraph "github.com/opencloud-eu/libre-graph-api-go" "sort" "strconv" "strings" - - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - libregraph "github.com/opencloud-eu/libre-graph-api-go" ) -// motionPhotoVideoSignatureLen is the number of trailing bytes we read to confirm -// an actual video is present. Enough to cover the ISO base media (MP4) box size -// and the "ftyp" box type at bytes [4:8]. -const motionPhotoVideoSignatureLen = 12 +// motionPhotoVideoName is the resource name tika gives the appended video when +// it emits it as an embedded attachment. The extension follows the detected +// type and is absent for legacy MicroVideo, which declares no mime type. +const motionPhotoVideoName = "motion-photo" // getMotionPhoto reads Google Motion Photo XMP, which Tika exposes under the // canonical Camera/Container prefixes. It covers both the current MotionPhoto @@ -89,31 +85,15 @@ func motionPhotoVideoSize(meta map[string][]string) (int64, bool) { return 0, false } -// looksLikeMP4 reports whether buf begins with an ISO base media (MP4/QuickTime) -// "ftyp" box, which Google Motion Photo and legacy MicroVideo clips start with. -func looksLikeMP4(buf []byte) bool { - return len(buf) >= 8 && string(buf[4:8]) == "ftyp" -} - -// motionPhotoHasVideo confirms that the file actually contains the embedded video -// the XMP advertises. A photos.google.com share strips the appended video but -// keeps the XMP, which would otherwise make us expose an unplayable facet. The -// video is appended at the end, so it starts at fileSize-videoSize; we read a few -// bytes there and require an MP4 signature. -func (t Tika) motionPhotoHasVideo(ctx context.Context, ri *provider.ResourceInfo, videoSize int64) bool { - size := int64(ri.GetSize()) - if videoSize <= 0 || videoSize >= size { - return false - } - - rc, err := t.RetrieveRange(ctx, ri.GetId(), size-videoSize, motionPhotoVideoSignatureLen) +// isMotionPhotoVideo reports whether meta describes the video tika extracted +// from a motion photo. Tika only emits it when the bytes the xmp advertises are +// really there, so its presence is what confirms the facet: a shared motion +// photo can keep the xmp and lose the appended video. +func isMotionPhotoVideo(meta map[string][]string) bool { + // tika 4 renamed the meta prefix from X-TIKA: to tk: + name, err := getFirstValue(meta, "tk:resource-name", "X-TIKA:resource-name") if err != nil { - t.logger.Debug().Err(err).Interface("ResourceID", ri.GetId()).Msg("could not read motion photo video header, dropping facet") return false } - defer rc.Close() - - buf := make([]byte, motionPhotoVideoSignatureLen) - n, _ := io.ReadFull(rc, buf) - return looksLikeMP4(buf[:n]) + return name == motionPhotoVideoName || strings.HasPrefix(name, motionPhotoVideoName+".") } diff --git a/services/search/pkg/content/tika_motion_photo_test.go b/services/search/pkg/content/tika_motion_photo_test.go index 02dd8793ce..4cd300ca77 100644 --- a/services/search/pkg/content/tika_motion_photo_test.go +++ b/services/search/pkg/content/tika_motion_photo_test.go @@ -1,17 +1,9 @@ package content import ( - "context" - "errors" - "io" - "strings" - - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" libregraph "github.com/opencloud-eu/libre-graph-api-go" - - "github.com/opencloud-eu/opencloud/pkg/log" ) var _ = Describe("getMotionPhoto", func() { @@ -85,73 +77,16 @@ var _ = Describe("getMotionPhoto", func() { }) }) -var _ = Describe("looksLikeMP4", func() { - It("recognizes an ftyp box and rejects everything else", func() { - Expect(looksLikeMP4([]byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'i', 's', 'o', 'm'})).To(BeTrue()) - Expect(looksLikeMP4([]byte("JFIF garbage"))).To(BeFalse()) - Expect(looksLikeMP4([]byte{0, 0, 0})).To(BeFalse()) - }) -}) - -// rangeStub serves RetrieveRange from a string and records the requested range. -type rangeStub struct { - data string - err error - gotOffset int64 - gotLength int64 - calls int -} - -func (r *rangeStub) Retrieve(context.Context, *provider.ResourceId) (io.ReadCloser, error) { - return nil, errors.New("unused") -} - -func (r *rangeStub) RetrieveRange(_ context.Context, _ *provider.ResourceId, offset, length int64) (io.ReadCloser, error) { - r.calls++ - r.gotOffset, r.gotLength = offset, length - if r.err != nil { - return nil, r.err - } - return io.NopCloser(strings.NewReader(r.data)), nil -} - -var _ = Describe("motionPhotoHasVideo", func() { - var ( - retriever *rangeStub - tika Tika - ri *provider.ResourceInfo +var _ = Describe("isMotionPhotoVideo", func() { + DescribeTable("recognizes the video tika emits as an embedded attachment", + func(meta map[string][]string, expected bool) { + Expect(isMotionPhotoVideo(meta)).To(Equal(expected)) + }, + Entry("named attachment", map[string][]string{"tk:resource-name": {"motion-photo.mp4"}}, true), + Entry("legacy tika prefix", map[string][]string{"X-TIKA:resource-name": {"motion-photo.mp4"}}, true), + Entry("no extension, as for MicroVideo", map[string][]string{"tk:resource-name": {"motion-photo"}}, true), + Entry("another attachment", map[string][]string{"tk:resource-name": {"cover.jpg"}}, false), + Entry("a name that only starts alike", map[string][]string{"tk:resource-name": {"motion-photography.mp4"}}, false), + Entry("the image itself", map[string][]string{"Camera:MotionPhoto": {"1"}}, false), ) - - BeforeEach(func() { - retriever = &rangeStub{} - basic, err := NewBasicExtractor(log.NewLogger()) - Expect(err).ToNot(HaveOccurred()) - tika = Tika{Basic: basic, Retriever: retriever} - ri = &provider.ResourceInfo{Size: 100} - }) - - It("confirms a video that starts with an ftyp box", func() { - retriever.data = "\x00\x00\x00\x18ftypisom" - Expect(tika.motionPhotoHasVideo(context.Background(), ri, 40)).To(BeTrue()) - Expect(retriever.gotOffset).To(Equal(int64(60)), "the video starts at size-videoSize") - Expect(retriever.gotLength).To(Equal(int64(motionPhotoVideoSignatureLen))) - }) - - It("rejects trailing bytes without an MP4 signature", func() { - retriever.data = "not a video " - Expect(tika.motionPhotoHasVideo(context.Background(), ri, 40)).To(BeFalse()) - }) - - It("rejects degenerate video sizes without reading", func() { - Expect(tika.motionPhotoHasVideo(context.Background(), ri, 0)).To(BeFalse()) - Expect(tika.motionPhotoHasVideo(context.Background(), ri, -1)).To(BeFalse()) - Expect(tika.motionPhotoHasVideo(context.Background(), ri, 100)).To(BeFalse()) - Expect(tika.motionPhotoHasVideo(context.Background(), ri, 101)).To(BeFalse()) - Expect(retriever.calls).To(BeZero()) - }) - - It("drops the facet when the range read fails", func() { - retriever.err = errors.New("nope") - Expect(tika.motionPhotoHasVideo(context.Background(), ri, 40)).To(BeFalse()) - }) }) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 1e4d64feef..154e4798e9 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -217,13 +217,8 @@ var _ = Describe("Tika", func() { Expect(doc.Content).To(Equal("one two")) }) - It("verifies the motion photo video against the file", func() { - fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}]` - retriever := &contentMocks.Retriever{} - retriever.On("Retrieve", mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("")), nil) - retriever.On("RetrieveRange", mock.Anything, mock.Anything, int64(60), mock.Anything). - Return(io.NopCloser(strings.NewReader("\x00\x00\x00\x18ftypisom")), nil) - tika.Retriever = retriever + It("keeps the motion photo facet when tika emits the video", func() { + fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}, {"tk:resource-name": "motion-photo.mp4", "Content-Type": "video/mp4"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, @@ -236,11 +231,6 @@ var _ = Describe("Tika", func() { It("drops the motion photo facet when the advertised video is gone", func() { fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}]` - retriever := &contentMocks.Retriever{} - retriever.On("Retrieve", mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("")), nil) - retriever.On("RetrieveRange", mock.Anything, mock.Anything, int64(60), mock.Anything). - Return(io.NopCloser(strings.NewReader("JFIF leftovers")), nil) - tika.Retriever = retriever doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, From ae9fd595d9cd9c19086fdb48f05c556eec43406f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 2 Sep 2026 19:02:57 +0200 Subject: [PATCH 05/10] chore(search): drop the dead legacy tika key --- services/search/pkg/content/tika_motion_photo.go | 3 +-- services/search/pkg/content/tika_motion_photo_test.go | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/services/search/pkg/content/tika_motion_photo.go b/services/search/pkg/content/tika_motion_photo.go index 5115f13a38..113857bcf0 100644 --- a/services/search/pkg/content/tika_motion_photo.go +++ b/services/search/pkg/content/tika_motion_photo.go @@ -90,8 +90,7 @@ func motionPhotoVideoSize(meta map[string][]string) (int64, bool) { // really there, so its presence is what confirms the facet: a shared motion // photo can keep the xmp and lose the appended video. func isMotionPhotoVideo(meta map[string][]string) bool { - // tika 4 renamed the meta prefix from X-TIKA: to tk: - name, err := getFirstValue(meta, "tk:resource-name", "X-TIKA:resource-name") + name, err := getFirstValue(meta, "tk:resource-name") if err != nil { return false } diff --git a/services/search/pkg/content/tika_motion_photo_test.go b/services/search/pkg/content/tika_motion_photo_test.go index 4cd300ca77..97ccbe25d8 100644 --- a/services/search/pkg/content/tika_motion_photo_test.go +++ b/services/search/pkg/content/tika_motion_photo_test.go @@ -83,7 +83,6 @@ var _ = Describe("isMotionPhotoVideo", func() { Expect(isMotionPhotoVideo(meta)).To(Equal(expected)) }, Entry("named attachment", map[string][]string{"tk:resource-name": {"motion-photo.mp4"}}, true), - Entry("legacy tika prefix", map[string][]string{"X-TIKA:resource-name": {"motion-photo.mp4"}}, true), Entry("no extension, as for MicroVideo", map[string][]string{"tk:resource-name": {"motion-photo"}}, true), Entry("another attachment", map[string][]string{"tk:resource-name": {"cover.jpg"}}, false), Entry("a name that only starts alike", map[string][]string{"tk:resource-name": {"motion-photography.mp4"}}, false), From 8aebf80124f4ae53eff363a4cd2aa5987ef9b42b Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 2 Sep 2026 19:05:38 +0200 Subject: [PATCH 06/10] refactor(search): confirm the motion photo video by type, not by name --- services/search/pkg/content/tika.go | 11 +++++----- .../search/pkg/content/tika_motion_photo.go | 22 ++++++------------- .../pkg/content/tika_motion_photo_test.go | 17 +++++++------- services/search/pkg/content/tika_test.go | 2 +- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 7bcdae426e..abd7bdde39 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -82,7 +82,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, err } - var motionPhotoVideo bool + var embeddedVideo bool for _, meta := range metas { title, err := getFirstValue(meta, "dc:title") if err != nil { @@ -119,14 +119,13 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if v := t.getMotionPhoto(meta); v != nil { doc.MotionPhoto = v } - if isMotionPhotoVideo(meta) { - motionPhotoVideo = true + if isVideo(meta) { + embeddedVideo = true } } - // the xmp alone does not prove the video is there, tika emitting it as an - // embedded attachment does - if !motionPhotoVideo { + // the xmp alone does not prove the video is there, tika extracting it does + if !embeddedVideo { doc.MotionPhoto = nil } diff --git a/services/search/pkg/content/tika_motion_photo.go b/services/search/pkg/content/tika_motion_photo.go index 113857bcf0..ced6942be1 100644 --- a/services/search/pkg/content/tika_motion_photo.go +++ b/services/search/pkg/content/tika_motion_photo.go @@ -7,11 +7,6 @@ import ( "strings" ) -// motionPhotoVideoName is the resource name tika gives the appended video when -// it emits it as an embedded attachment. The extension follows the detected -// type and is absent for legacy MicroVideo, which declares no mime type. -const motionPhotoVideoName = "motion-photo" - // getMotionPhoto reads Google Motion Photo XMP, which Tika exposes under the // canonical Camera/Container prefixes. It covers both the current MotionPhoto // scheme and the legacy MicroVideo scheme. videoSize (the embedded video's byte @@ -85,14 +80,11 @@ func motionPhotoVideoSize(meta map[string][]string) (int64, bool) { return 0, false } -// isMotionPhotoVideo reports whether meta describes the video tika extracted -// from a motion photo. Tika only emits it when the bytes the xmp advertises are -// really there, so its presence is what confirms the facet: a shared motion -// photo can keep the xmp and lose the appended video. -func isMotionPhotoVideo(meta map[string][]string) bool { - name, err := getFirstValue(meta, "tk:resource-name") - if err != nil { - return false - } - return name == motionPhotoVideoName || strings.HasPrefix(name, motionPhotoVideoName+".") +// isVideo reports whether meta describes a video. Tika emits the video appended +// to a motion photo as an embedded document, and it only does so when the bytes +// the xmp advertises are really there: a shared motion photo can keep the xmp +// and lose the video. +func isVideo(meta map[string][]string) bool { + v, err := getFirstValue(meta, "Content-Type") + return err == nil && strings.HasPrefix(v, "video/") } diff --git a/services/search/pkg/content/tika_motion_photo_test.go b/services/search/pkg/content/tika_motion_photo_test.go index 97ccbe25d8..62812130d1 100644 --- a/services/search/pkg/content/tika_motion_photo_test.go +++ b/services/search/pkg/content/tika_motion_photo_test.go @@ -77,15 +77,16 @@ var _ = Describe("getMotionPhoto", func() { }) }) -var _ = Describe("isMotionPhotoVideo", func() { - DescribeTable("recognizes the video tika emits as an embedded attachment", +var _ = Describe("isVideo", func() { + DescribeTable("recognizes the video tika extracted from a motion photo", func(meta map[string][]string, expected bool) { - Expect(isMotionPhotoVideo(meta)).To(Equal(expected)) + Expect(isVideo(meta)).To(Equal(expected)) }, - Entry("named attachment", map[string][]string{"tk:resource-name": {"motion-photo.mp4"}}, true), - Entry("no extension, as for MicroVideo", map[string][]string{"tk:resource-name": {"motion-photo"}}, true), - Entry("another attachment", map[string][]string{"tk:resource-name": {"cover.jpg"}}, false), - Entry("a name that only starts alike", map[string][]string{"tk:resource-name": {"motion-photography.mp4"}}, false), - Entry("the image itself", map[string][]string{"Camera:MotionPhoto": {"1"}}, false), + Entry("mp4", map[string][]string{"Content-Type": {"video/mp4"}}, true), + Entry("quicktime", map[string][]string{"Content-Type": {"video/quicktime"}}, true), + Entry("with parameters", map[string][]string{"Content-Type": {"video/mp4; codecs=avc1"}}, true), + Entry("the image itself", map[string][]string{"Content-Type": {"image/jpeg"}}, false), + Entry("another attachment", map[string][]string{"Content-Type": {"application/pdf"}}, false), + Entry("no content type", map[string][]string{"Camera:MotionPhoto": {"1"}}, false), ) }) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 154e4798e9..66120a593c 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -218,7 +218,7 @@ var _ = Describe("Tika", func() { }) It("keeps the motion photo facet when tika emits the video", func() { - fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}, {"tk:resource-name": "motion-photo.mp4", "Content-Type": "video/mp4"}]` + fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}, {"Content-Type": "video/mp4"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, From 88f21d804b8f8b270cf30699d4c70b78e3230062 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 2 Sep 2026 19:09:25 +0200 Subject: [PATCH 07/10] refactor(search): restore cs3 Retrieve, the download split had only one caller left --- services/search/pkg/content/cs3.go | 34 ++++++++++++------------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/services/search/pkg/content/cs3.go b/services/search/pkg/content/cs3.go index a458b68f48..e21dcd94ec 100644 --- a/services/search/pkg/content/cs3.go +++ b/services/search/pkg/content/cs3.go @@ -33,45 +33,37 @@ func newCS3Retriever(gatewaySelector pool.Selectable[gateway.GatewayAPIClient], } } -// initiateDownload resolves the download endpoint, transfer token and auth token -// for rID through the cs3 gateway. -func (s cs3) initiateDownload(ctx context.Context, rID *provider.ResourceId) (endpoint, transferToken, authToken string, err error) { - authToken, ok := contextGet(ctx, revactx.TokenHeader) +// Retrieve downloads the file from a cs3 service +// The caller MUST make sure to close the returned ReadCloser +func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) { + at, ok := contextGet(ctx, revactx.TokenHeader) if !ok { - return "", "", "", fmt.Errorf("context without %s", revactx.TokenHeader) + return nil, fmt.Errorf("context without %s", revactx.TokenHeader) } gatewayClient, err := s.gatewaySelector.Next() if err != nil { s.logger.Error().Err(err).Msg("could not get reva gatewayClient") - return "", "", "", err + return nil, err } res, err := gatewayClient.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{Ref: &provider.Reference{ResourceId: rID, Path: "."}}) if err != nil { - return "", "", "", err + return nil, err } if res.Status.Code != rpc.Code_CODE_OK { - return "", "", "", fmt.Errorf("could not load resoure: %s", res.Status.Message) + return nil, fmt.Errorf("could not load resoure: %s", res.Status.Message) } + var ep, tt string for _, p := range res.Protocols { if p.Protocol == "spaces" { - return p.DownloadEndpoint, p.Token, authToken, nil + ep, tt = p.DownloadEndpoint, p.Token + break } } - if len(res.Protocols) > 0 { - return res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token, authToken, nil - } - return "", "", "", fmt.Errorf("no download protocol found") -} - -// Retrieve downloads the file from a cs3 service -// The caller MUST make sure to close the returned ReadCloser -func (s cs3) Retrieve(ctx context.Context, rID *provider.ResourceId) (io.ReadCloser, error) { - ep, tt, at, err := s.initiateDownload(ctx, rID) - if err != nil { - return nil, err + if (ep == "" || tt == "") && len(res.Protocols) > 0 { + ep, tt = res.Protocols[0].DownloadEndpoint, res.Protocols[0].Token } req, err := http.NewRequest(http.MethodGet, ep, nil) From 706aaf02f20750d289854f45f04b2511fdc58ddc Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 2 Sep 2026 19:12:42 +0200 Subject: [PATCH 08/10] refactor(search): decide the motion photo from the file xmp and its extracted video --- services/search/pkg/content/tika.go | 16 ++++------ .../pkg/content/tika_motion_photo_test.go | 32 +++++++++---------- services/search/pkg/content/tika_test.go | 4 +-- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index abd7bdde39..070bf44e6a 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -82,7 +83,6 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, err } - var embeddedVideo bool for _, meta := range metas { title, err := getFirstValue(meta, "dc:title") if err != nil { @@ -116,17 +116,13 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if v := t.getVideo(meta); v != nil { doc.Video = v } - if v := t.getMotionPhoto(meta); v != nil { - doc.MotionPhoto = v - } - if isVideo(meta) { - embeddedVideo = true - } } - // the xmp alone does not prove the video is there, tika extracting it does - if !embeddedVideo { - doc.MotionPhoto = nil + // a motion photo is the xmp on the file itself plus the video tika extracted + // from it. The xmp alone proves nothing: a share can keep it and strip the + // appended video. + if len(metas) > 0 && slices.ContainsFunc(metas[1:], isVideo) { + doc.MotionPhoto = t.getMotionPhoto(metas[0]) } if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { diff --git a/services/search/pkg/content/tika_motion_photo_test.go b/services/search/pkg/content/tika_motion_photo_test.go index 62812130d1..85a1f85d78 100644 --- a/services/search/pkg/content/tika_motion_photo_test.go +++ b/services/search/pkg/content/tika_motion_photo_test.go @@ -9,10 +9,10 @@ import ( var _ = Describe("getMotionPhoto", func() { It("maps the current MotionPhoto XMP scheme (container item length)", func() { mp := Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MotionPhotoVersion": {"1"}, - "Camera:MotionPhotoPresentationTimestampUs": {"1500000"}, - "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, - "Container:Directory/Item[2]/Item:Length": {"1048576"}, + "Camera:MotionPhotoVersion": {"1"}, + "Camera:MotionPhotoPresentationTimestampUs": {"1500000"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, }) Expect(mp).ToNot(BeNil()) Expect(mp.Version).To(Equal(libregraph.PtrInt32(1))) @@ -44,10 +44,10 @@ var _ = Describe("getMotionPhoto", func() { It("treats a zero MotionPhoto marker as a still image", func() { Expect(Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MotionPhoto": {"0"}, - "Camera:MotionPhotoVersion": {"1"}, - "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, - "Container:Directory/Item[2]/Item:Length": {"1048576"}, + "Camera:MotionPhoto": {"0"}, + "Camera:MotionPhotoVersion": {"1"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, })).To(BeNil()) Expect(Tika{}.getMotionPhoto(map[string][]string{ "Camera:MicroVideo": {"0"}, @@ -57,19 +57,19 @@ var _ = Describe("getMotionPhoto", func() { It("treats undefined marker values as a still image", func() { Expect(Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MotionPhoto": {"2"}, - "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, - "Container:Directory/Item[2]/Item:Length": {"1048576"}, + "Camera:MotionPhoto": {"2"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, })).To(BeNil()) }) It("prefers the current scheme when both are present", func() { mp := Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MotionPhotoVersion": {"2"}, - "Camera:MicroVideoVersion": {"1"}, - "Camera:MicroVideoOffset": {"2097152"}, - "Container:Directory/Item[2]/Item:Semantic": {"MotionPhoto"}, - "Container:Directory/Item[2]/Item:Length": {"1048576"}, + "Camera:MotionPhotoVersion": {"2"}, + "Camera:MicroVideoVersion": {"1"}, + "Camera:MicroVideoOffset": {"2097152"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, + "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, }) Expect(mp).ToNot(BeNil()) Expect(mp.Version).To(Equal(libregraph.PtrInt32(2))) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 66120a593c..8b8d01ccd9 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -218,7 +218,7 @@ var _ = Describe("Tika", func() { }) It("keeps the motion photo facet when tika emits the video", func() { - fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}, {"Content-Type": "video/mp4"}]` + fullResponse = `[{"Camera:MotionPhotoVersion": "1", "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": "MotionPhoto", "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": "40"}, {"Content-Type": "video/mp4"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, @@ -230,7 +230,7 @@ var _ = Describe("Tika", func() { }) It("drops the motion photo facet when the advertised video is gone", func() { - fullResponse = `[{"Camera:MotionPhotoVersion": "1", "Container:Directory/Item[2]/Item:Semantic": "MotionPhoto", "Container:Directory/Item[2]/Item:Length": "40"}]` + fullResponse = `[{"Camera:MotionPhotoVersion": "1", "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": "MotionPhoto", "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": "40"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, From a8318fe03b3548cf30eed4615b7d0e99949484ef Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 2 Sep 2026 22:52:25 +0200 Subject: [PATCH 09/10] refactor(search): take the motion photo video size from the video tika extracted --- services/search/pkg/content/tika.go | 6 ++- .../search/pkg/content/tika_motion_photo.go | 53 +++++-------------- .../pkg/content/tika_motion_photo_test.go | 51 ++++++++---------- services/search/pkg/content/tika_test.go | 4 +- 4 files changed, 41 insertions(+), 73 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 070bf44e6a..1bd584f1ab 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -121,8 +121,10 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, // a motion photo is the xmp on the file itself plus the video tika extracted // from it. The xmp alone proves nothing: a share can keep it and strip the // appended video. - if len(metas) > 0 && slices.ContainsFunc(metas[1:], isVideo) { - doc.MotionPhoto = t.getMotionPhoto(metas[0]) + if len(metas) > 0 { + if i := slices.IndexFunc(metas[1:], isVideo); i >= 0 { + doc.MotionPhoto = t.getMotionPhoto(metas[0], metas[i+1]) + } } if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { diff --git a/services/search/pkg/content/tika_motion_photo.go b/services/search/pkg/content/tika_motion_photo.go index ced6942be1..c4d2cc4d80 100644 --- a/services/search/pkg/content/tika_motion_photo.go +++ b/services/search/pkg/content/tika_motion_photo.go @@ -2,20 +2,20 @@ package content import ( libregraph "github.com/opencloud-eu/libre-graph-api-go" - "sort" "strconv" "strings" ) // getMotionPhoto reads Google Motion Photo XMP, which Tika exposes under the // canonical Camera/Container prefixes. It covers both the current MotionPhoto -// scheme and the legacy MicroVideo scheme. videoSize (the embedded video's byte -// length, needed to range-fetch it) is required, so the facet is dropped without it. -func (t Tika) getMotionPhoto(meta map[string][]string) *libregraph.MotionPhoto { - // per the spec only a MotionPhoto/MicroVideo marker of 1 means motion - // photo, every other value is "treat as a still image". An absent marker - // is tolerated on purpose, the byte-level video check below decides. - if v, err := getFirstValue(meta, "Camera:MotionPhoto", "Camera:MicroVideo"); err == nil && v != "1" { +// scheme and the legacy MicroVideo scheme. videoSize (needed to range-fetch the +// video) comes from the video tika extracted, and is required: without it the +// facet is dropped. +func (t Tika) getMotionPhoto(meta, video map[string][]string) *libregraph.MotionPhoto { + // the marker is what makes this a motion photo rather than a picture that + // happens to carry a video: per the spec only a value of 1 counts, every + // other value means "treat as a still image". + if v, err := getFirstValue(meta, "Camera:MotionPhoto", "Camera:MicroVideo"); err != nil || v != "1" { return nil } @@ -40,9 +40,11 @@ func (t Tika) getMotionPhoto(meta map[string][]string) *libregraph.MotionPhoto { } } - if size, ok := motionPhotoVideoSize(meta); ok { - initMotionPhoto() - motionPhoto.SetVideoSize(size) + if v, err := getFirstValue(video, "Content-Length"); err == nil { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + initMotionPhoto() + motionPhoto.SetVideoSize(i) + } } if motionPhoto == nil || !motionPhoto.HasVideoSize() { @@ -51,35 +53,6 @@ func (t Tika) getMotionPhoto(meta map[string][]string) *libregraph.MotionPhoto { return motionPhoto } -// motionPhotoVideoSize returns the embedded video's byte length: the length of -// the Container item whose semantic is "MotionPhoto", or for legacy files the -// MicroVideo offset (bytes from EOF to the video start, which equals its -// length). The current scheme wins when both are present, like everywhere else. -func motionPhotoVideoSize(meta map[string][]string) (int64, bool) { - keys := make([]string, 0, len(meta)) - for k := range meta { - keys = append(keys, k) - } - // map order is random, the first matching container item must be stable - sort.Strings(keys) - for _, k := range keys { - if vals := meta[k]; !strings.HasSuffix(k, "/Item:Semantic") || len(vals) == 0 || vals[0] != "MotionPhoto" { - continue - } - if v, err := getFirstValue(meta, strings.TrimSuffix(k, "/Item:Semantic")+"/Item:Length"); err == nil { - if i, err := strconv.ParseInt(v, 10, 64); err == nil { - return i, true - } - } - } - if v, err := getFirstValue(meta, "Camera:MicroVideoOffset"); err == nil { - if i, err := strconv.ParseInt(v, 10, 64); err == nil { - return i, true - } - } - return 0, false -} - // isVideo reports whether meta describes a video. Tika emits the video appended // to a motion photo as an embedded document, and it only does so when the bytes // the xmp advertises are really there: a shared motion photo can keep the xmp diff --git a/services/search/pkg/content/tika_motion_photo_test.go b/services/search/pkg/content/tika_motion_photo_test.go index 85a1f85d78..ff10f85ee0 100644 --- a/services/search/pkg/content/tika_motion_photo_test.go +++ b/services/search/pkg/content/tika_motion_photo_test.go @@ -7,70 +7,63 @@ import ( ) var _ = Describe("getMotionPhoto", func() { - It("maps the current MotionPhoto XMP scheme (container item length)", func() { + It("maps the current MotionPhoto XMP scheme", func() { mp := Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MotionPhotoVersion": {"1"}, - "Camera:MotionPhotoPresentationTimestampUs": {"1500000"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, - }) + "Camera:MotionPhoto": {"1"}, + "Camera:MotionPhotoVersion": {"1"}, + "Camera:MotionPhotoPresentationTimestampUs": {"1500000"}, + }, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}}) Expect(mp).ToNot(BeNil()) Expect(mp.Version).To(Equal(libregraph.PtrInt32(1))) Expect(mp.PresentationTimestampUs).To(Equal(libregraph.PtrInt64(1500000))) Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(1048576))) }) - It("maps the legacy MicroVideo XMP scheme (offset is the length)", func() { + It("maps the legacy MicroVideo XMP scheme", func() { mp := Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MicroVideo": {"1"}, "Camera:MicroVideoVersion": {"1"}, "Camera:MicroVideoPresentationTimestampUs": {"1500000"}, - "Camera:MicroVideoOffset": {"2097152"}, - }) + }, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}}) Expect(mp).ToNot(BeNil()) Expect(mp.Version).To(Equal(libregraph.PtrInt32(1))) Expect(mp.PresentationTimestampUs).To(Equal(libregraph.PtrInt64(1500000))) - Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(2097152))) + Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(1048576))) }) - It("drops the facet without a video size", func() { + It("drops the facet when the video reports no length", func() { Expect(Tika{}.getMotionPhoto(map[string][]string{ + "Camera:MotionPhoto": {"1"}, "Camera:MotionPhotoVersion": {"1"}, - })).To(BeNil()) + }, map[string][]string{"Content-Type": {"video/mp4"}})).To(BeNil()) }) - It("returns nil when no motion photo metadata is present", func() { - Expect(Tika{}.getMotionPhoto(map[string][]string{})).To(BeNil()) + It("returns nil without the marker, a picture may just carry a video", func() { + Expect(Tika{}.getMotionPhoto(map[string][]string{}, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}})).To(BeNil()) }) It("treats a zero MotionPhoto marker as a still image", func() { Expect(Tika{}.getMotionPhoto(map[string][]string{ "Camera:MotionPhoto": {"0"}, "Camera:MotionPhotoVersion": {"1"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, - })).To(BeNil()) + }, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}})).To(BeNil()) Expect(Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MicroVideo": {"0"}, - "Camera:MicroVideoOffset": {"2097152"}, - })).To(BeNil()) + "Camera:MicroVideo": {"0"}, + }, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}})).To(BeNil()) }) It("treats undefined marker values as a still image", func() { Expect(Tika{}.getMotionPhoto(map[string][]string{ "Camera:MotionPhoto": {"2"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, - })).To(BeNil()) + }, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}})).To(BeNil()) }) It("prefers the current scheme when both are present", func() { mp := Tika{}.getMotionPhoto(map[string][]string{ - "Camera:MotionPhotoVersion": {"2"}, - "Camera:MicroVideoVersion": {"1"}, - "Camera:MicroVideoOffset": {"2097152"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": {"MotionPhoto"}, - "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": {"1048576"}, - }) + "Camera:MotionPhoto": {"1"}, + "Camera:MotionPhotoVersion": {"2"}, + "Camera:MicroVideoVersion": {"1"}, + }, map[string][]string{"Content-Length": {"1048576"}, "Content-Type": {"video/mp4"}}) Expect(mp).ToNot(BeNil()) Expect(mp.Version).To(Equal(libregraph.PtrInt32(2))) Expect(mp.VideoSize).To(Equal(libregraph.PtrInt64(1048576))) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 8b8d01ccd9..5a224a9e33 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -218,7 +218,7 @@ var _ = Describe("Tika", func() { }) It("keeps the motion photo facet when tika emits the video", func() { - fullResponse = `[{"Camera:MotionPhotoVersion": "1", "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": "MotionPhoto", "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": "40"}, {"Content-Type": "video/mp4"}]` + fullResponse = `[{"Camera:MotionPhoto": "1", "Camera:MotionPhotoVersion": "1"}, {"Content-Type": "video/mp4", "Content-Length": "40"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, @@ -230,7 +230,7 @@ var _ = Describe("Tika", func() { }) It("drops the motion photo facet when the advertised video is gone", func() { - fullResponse = `[{"Camera:MotionPhotoVersion": "1", "xmp-raw:Container:Directory[2]/Container:Item/Item:Semantic": "MotionPhoto", "xmp-raw:Container:Directory[2]/Container:Item/Item:Length": "40"}]` + fullResponse = `[{"Camera:MotionPhoto": "1", "Camera:MotionPhotoVersion": "1"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, From 3515594787c005dc0d94ef747611364a55dbda8a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 3 Sep 2026 01:09:41 +0200 Subject: [PATCH 10/10] fix(search): keep the video facet on the file itself --- .../messages/search/v0/search.pb.web.go | 36 ------------------- services/search/pkg/content/tika.go | 10 ++++-- 2 files changed, 7 insertions(+), 39 deletions(-) diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.web.go b/protogen/gen/opencloud/messages/search/v0/search.pb.web.go index 8bb3efa233..1c4c124331 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.web.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.web.go @@ -262,42 +262,6 @@ func (m *Video) UnmarshalJSON(b []byte) error { var _ json.Unmarshaler = (*Video)(nil) -// MotionPhotoJSONMarshaler describes the default jsonpb.Marshaler used by all -// instances of MotionPhoto. This struct is safe to replace or modify but -// should not be done so concurrently. -var MotionPhotoJSONMarshaler = new(jsonpb.Marshaler) - -// MarshalJSON satisfies the encoding/json Marshaler interface. This method -// uses the more correct jsonpb package to correctly marshal the message. -func (m *MotionPhoto) MarshalJSON() ([]byte, error) { - if m == nil { - return json.Marshal(nil) - } - - buf := &bytes.Buffer{} - - if err := MotionPhotoJSONMarshaler.Marshal(buf, m); err != nil { - return nil, err - } - - return buf.Bytes(), nil -} - -var _ json.Marshaler = (*MotionPhoto)(nil) - -// MotionPhotoJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all -// instances of MotionPhoto. This struct is safe to replace or modify but -// should not be done so concurrently. -var MotionPhotoJSONUnmarshaler = new(jsonpb.Unmarshaler) - -// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method -// uses the more correct jsonpb package to correctly unmarshal the message. -func (m *MotionPhoto) UnmarshalJSON(b []byte) error { - return MotionPhotoJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m) -} - -var _ json.Unmarshaler = (*MotionPhoto)(nil) - // EntityJSONMarshaler describes the default jsonpb.Marshaler used by all // instances of Entity. This struct is safe to replace or modify but // should not be done so concurrently. diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 1bd584f1ab..b13c13cfd1 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -113,9 +113,13 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if v := t.getAudio(meta); v != nil { doc.Audio = v } - if v := t.getVideo(meta); v != nil { - doc.Video = v - } + } + + if len(metas) > 0 { + // the video facet says the file is a video, so it comes from the file + // itself: the clip tika extracts from a motion photo must not make its + // image look like one + doc.Video = t.getVideo(metas[0]) } // a motion photo is the xmp on the file itself plus the video tika extracted