From 935ce7c073024aea1b8ea7e495925a2eda8abc2c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 17:34:35 +0200 Subject: [PATCH 01/18] feat(thumbnails): split preview mime types + add HasPreview Split SupportedMimeTypes into UnconditionalPreviewMimeTypes (preview follows from the mimetype) and EmbeddedPreviewMimeTypes (audio cover art, may be absent); SupportedMimeTypes stays as their union for the generator. Add HasPreview(md) as the single source of truth: unconditional types are always true, embedded types depend on stored oc.preview dimensions. --- .../thumbnails/pkg/thumbnail/haspreview.go | 60 +++++++++++++++++++ .../pkg/thumbnail/haspreview_test.go | 55 +++++++++++++++++ .../thumbnails/pkg/thumbnail/mimetypes.go | 33 +++++----- .../pkg/thumbnail/mimetypes_common.go | 26 ++++++++ .../pkg/thumbnail/mimetypes_vips.go | 35 +++++------ 5 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 services/thumbnails/pkg/thumbnail/haspreview.go create mode 100644 services/thumbnails/pkg/thumbnail/haspreview_test.go create mode 100644 services/thumbnails/pkg/thumbnail/mimetypes_common.go diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go new file mode 100644 index 0000000000..5af6c07b8e --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -0,0 +1,60 @@ +package thumbnail + +import ( + "strconv" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// Arbitrary-metadata keys under which the content extraction pipeline stores the +// dimensions of an embedded preview (for example audio cover art). These are an +// internal signal, not a Microsoft Graph facet. Their presence means the file +// carries an embedded preview. +const ( + PreviewWidthKey = "oc.preview.width" + PreviewHeightKey = "oc.preview.height" +) + +// HasPreview reports whether a thumbnail/preview can be produced for the given +// resource. For unconditional types it follows from the mimetype alone. For +// embedded-preview types (audio cover art) it depends on whether an embedded +// preview was detected at index time, signalled by the stored preview +// dimensions. Files that have not been indexed yet report no preview rather than +// promising one that would fail to render. +func HasPreview(md *provider.ResourceInfo) bool { + if md == nil { + return false + } + + mimeType := md.GetMimeType() + if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { + return true + } + if _, ok := EmbeddedPreviewMimeTypes[mimeType]; ok { + w, h := PreviewDimensions(md) + return w > 0 && h > 0 + } + return false +} + +// PreviewDimensions returns the stored dimensions of a resource's embedded +// preview, or (0, 0) if none were recorded. Only meaningful for +// EmbeddedPreviewMimeTypes. +func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { + meta := md.GetArbitraryMetadata().GetMetadata() + if meta == nil { + return 0, 0 + } + return parseInt32(meta[PreviewWidthKey]), parseInt32(meta[PreviewHeightKey]) +} + +func parseInt32(s string) int32 { + if s == "" { + return 0 + } + v, err := strconv.ParseInt(s, 10, 32) + if err != nil { + return 0 + } + return int32(v) +} diff --git a/services/thumbnails/pkg/thumbnail/haspreview_test.go b/services/thumbnails/pkg/thumbnail/haspreview_test.go new file mode 100644 index 0000000000..7d446ef70c --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/haspreview_test.go @@ -0,0 +1,55 @@ +package thumbnail + +import ( + "testing" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +func resourceInfo(mime string, meta map[string]string) *provider.ResourceInfo { + ri := &provider.ResourceInfo{MimeType: mime} + if meta != nil { + ri.ArbitraryMetadata = &provider.ArbitraryMetadata{Metadata: meta} + } + return ri +} + +func TestHasPreview(t *testing.T) { + cases := []struct { + name string + md *provider.ResourceInfo + want bool + }{ + {"nil", nil, false}, + {"unconditional image", resourceInfo("image/png", nil), true}, + {"unconditional text", resourceInfo("text/plain", nil), true}, + {"unsupported type", resourceInfo("application/pdf", nil), false}, + {"audio without preview dims", resourceInfo("audio/mpeg", nil), false}, + {"audio with empty dims", resourceInfo("audio/mpeg", map[string]string{ + PreviewWidthKey: "0", PreviewHeightKey: "0", + }), false}, + {"audio with preview dims", resourceInfo("audio/mpeg", map[string]string{ + PreviewWidthKey: "500", PreviewHeightKey: "500", + }), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := HasPreview(tc.md); got != tc.want { + t.Errorf("HasPreview(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestSupportedMimeTypesIsUnion(t *testing.T) { + for k := range UnconditionalPreviewMimeTypes { + if _, ok := SupportedMimeTypes[k]; !ok { + t.Errorf("SupportedMimeTypes missing unconditional type %q", k) + } + } + for k := range EmbeddedPreviewMimeTypes { + if _, ok := SupportedMimeTypes[k]; !ok { + t.Errorf("SupportedMimeTypes missing embedded type %q", k) + } + } +} diff --git a/services/thumbnails/pkg/thumbnail/mimetypes.go b/services/thumbnails/pkg/thumbnail/mimetypes.go index 02a91e22f5..5dc32cbe74 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes.go @@ -2,21 +2,18 @@ package thumbnail -var ( - // SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer. - SupportedMimeTypes = map[string]struct{}{ - "image/png": {}, - "image/jpg": {}, - "image/jpeg": {}, - "image/gif": {}, - "image/bmp": {}, - "image/x-ms-bmp": {}, - "image/tiff": {}, - "text/plain": {}, - "audio/flac": {}, - "audio/mpeg": {}, - "audio/ogg": {}, - "application/vnd.geogebra.slides": {}, - "application/vnd.geogebra.pinboard": {}, - } -) +// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows +// from the mimetype alone: the thumbnailer can always render a preview from the +// content, so a preview is guaranteed to exist. +var UnconditionalPreviewMimeTypes = map[string]struct{}{ + "image/png": {}, + "image/jpg": {}, + "image/jpeg": {}, + "image/gif": {}, + "image/bmp": {}, + "image/x-ms-bmp": {}, + "image/tiff": {}, + "text/plain": {}, + "application/vnd.geogebra.slides": {}, + "application/vnd.geogebra.pinboard": {}, +} diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_common.go b/services/thumbnails/pkg/thumbnail/mimetypes_common.go new file mode 100644 index 0000000000..a774dd7d68 --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/mimetypes_common.go @@ -0,0 +1,26 @@ +package thumbnail + +// EmbeddedPreviewMimeTypes are mimetypes whose preview is an embedded resource +// (for example audio cover art) that may or may not be present. Preview +// availability cannot be derived from the mimetype alone and must be determined +// per file (see HasPreview). +var EmbeddedPreviewMimeTypes = map[string]struct{}{ + "audio/flac": {}, + "audio/mpeg": {}, + "audio/ogg": {}, +} + +// SupportedMimeTypes contains all mimetypes the thumbnailer can produce a +// thumbnail for: the union of the unconditional and embedded preview types. +// The generator gates on this union; preview availability per file is decided +// by HasPreview. +var SupportedMimeTypes = func() map[string]struct{} { + m := make(map[string]struct{}, len(UnconditionalPreviewMimeTypes)+len(EmbeddedPreviewMimeTypes)) + for k := range UnconditionalPreviewMimeTypes { + m[k] = struct{}{} + } + for k := range EmbeddedPreviewMimeTypes { + m[k] = struct{}{} + } + return m +}() diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go index b94fafafaf..677ae43d86 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go @@ -2,22 +2,19 @@ package thumbnail -var ( - // SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer. - SupportedMimeTypes = map[string]struct{}{ - "image/png": {}, - "image/jpg": {}, - "image/jpeg": {}, - "image/gif": {}, - "image/bmp": {}, - "image/x-ms-bmp": {}, - "image/tiff": {}, - "text/plain": {}, - "audio/flac": {}, - "audio/mpeg": {}, - "audio/ogg": {}, - "application/vnd.geogebra.slides": {}, - "application/vnd.geogebra.pinboard": {}, - "image/webp": {}, - } -) +// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows +// from the mimetype alone: the thumbnailer can always render a preview from the +// content, so a preview is guaranteed to exist. +var UnconditionalPreviewMimeTypes = map[string]struct{}{ + "image/png": {}, + "image/jpg": {}, + "image/jpeg": {}, + "image/gif": {}, + "image/bmp": {}, + "image/x-ms-bmp": {}, + "image/tiff": {}, + "image/webp": {}, + "text/plain": {}, + "application/vnd.geogebra.slides": {}, + "application/vnd.geogebra.pinboard": {}, +} From 9717fad2c36f3d1deeb18fdf8852979e29d19496 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 17:42:49 +0200 Subject: [PATCH 02/18] feat(search): index embedded preview dimensions as oc.preview For embedded-preview mime types (audio cover art), extract the embedded cover's dimensions from Tika's recursive metadata (requires TIKA-4801) and store them as oc.preview.width/height in the search index and arbitrary metadata, routed through the shared facet-to-metadata flattening. Presence of the dimensions is the preview-presence signal consumed by thumbnail.HasPreview. --- .../pkg/bleve/testdata/mapping.golden.json | 32 +++++++++++++++ services/search/pkg/content/content.go | 21 ++++++++++ services/search/pkg/content/tika.go | 32 +++++++++++++++ .../search/pkg/content/tika_preview_test.go | 40 +++++++++++++++++++ .../opensearch/testdata/resource.golden.json | 10 +++++ services/search/pkg/search/service.go | 1 + 6 files changed, 136 insertions(+) create mode 100644 services/search/pkg/content/tika_preview_test.go diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index 61ea6474f1..5f12e2b398 100644 --- a/services/search/pkg/bleve/testdata/mapping.golden.json +++ b/services/search/pkg/bleve/testdata/mapping.golden.json @@ -1053,6 +1053,38 @@ } } }, + "preview": { + "enabled": true, + "dynamic": true, + "properties": { + "height": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + }, + "width": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "number", + "store": true, + "index": true, + "include_in_all": true, + "docvalues": true + } + ] + } + } + }, "video": { "enabled": true, "dynamic": true, diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index 6a945b0fd3..6e65e3933a 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -30,6 +30,27 @@ type Document struct { Video *libregraph.Video `json:"video,omitempty"` MotionPhoto *libregraph.MotionPhoto `json:"motionPhoto,omitempty"` LivePhoto *libregraph.LivePhoto `json:"livePhoto,omitempty"` + Preview *Preview `json:"preview,omitempty"` +} + +// Preview holds the dimensions of an embedded preview image (for example audio +// cover art) for content types whose thumbnail is embedded rather than rendered +// and may therefore be absent. It is an internal signal, not a Microsoft Graph +// facet: its presence marks that a preview exists for the resource. +type Preview struct { + Width int32 `json:"width"` + Height int32 `json:"height"` +} + +// ToMap lets Preview flow through the same facet-to-metadata flattening as the +// Microsoft Graph facets, so it is stored under the oc.preview. prefix (keys +// oc.preview.width / oc.preview.height, matching thumbnail.PreviewWidthKey / +// thumbnail.PreviewHeightKey). Preview is not itself a Graph facet. +func (p Preview) ToMap() (map[string]interface{}, error) { + return map[string]interface{}{ + "width": p.Width, + "height": p.Height, + }, nil } func CleanString(content, langCode string) string { diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 7b918e9ab9..86517f5014 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "slices" + "strconv" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -15,6 +16,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/search/pkg/config" + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) // Tika is used to extract content from a resource, @@ -134,6 +136,8 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, } } + doc.Preview = getPreview(ri.GetMimeType(), metas) + if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { doc.Content = CleanString(doc.Content, langCode) } @@ -162,3 +166,31 @@ func (t Tika) detectLanguage(ctx context.Context, content string) string { } return "" } + +// getPreview extracts the dimensions of an embedded preview image for content +// whose thumbnail is embedded rather than rendered (audio cover art). Tika with +// TIKA-4801 surfaces the embedded cover as an additional image entry carrying +// tiff dimensions. It only runs for EmbeddedPreviewMimeTypes; unconditional +// types have their preview availability decided by the mimetype alone. +func getPreview(mimeType string, metas []map[string][]string) *Preview { + if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { + return nil + } + for _, meta := range metas { + ct, err := getFirstValue(meta, "Content-Type") + if err != nil || !strings.HasPrefix(ct, "image/") { + continue + } + w, wErr := getFirstValue(meta, "tiff:ImageWidth") + h, hErr := getFirstValue(meta, "tiff:ImageLength") + if wErr != nil || hErr != nil { + continue + } + width, wErr := strconv.ParseInt(w, 10, 32) + height, hErr := strconv.ParseInt(h, 10, 32) + if wErr == nil && hErr == nil && width > 0 && height > 0 { + return &Preview{Width: int32(width), Height: int32(height)} + } + } + return nil +} diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go new file mode 100644 index 0000000000..d626a6c1f6 --- /dev/null +++ b/services/search/pkg/content/tika_preview_test.go @@ -0,0 +1,40 @@ +package content + +import "testing" + +func TestGetPreview(t *testing.T) { + audio := map[string][]string{"Content-Type": {"audio/mpeg"}} + cover := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"500"}, + "tiff:ImageLength": {"400"}, + } + coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} + + t.Run("audio with embedded cover returns dims", func(t *testing.T) { + p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) + if p == nil || p.Width != 500 || p.Height != 400 { + t.Fatalf("expected 500x400, got %+v", p) + } + }) + + t.Run("audio without cover returns nil", func(t *testing.T) { + if p := getPreview("audio/mpeg", []map[string][]string{audio}); p != nil { + t.Fatalf("expected nil, got %+v", p) + } + }) + + t.Run("audio with cover lacking dims returns nil", func(t *testing.T) { + if p := getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims}); p != nil { + t.Fatalf("expected nil, got %+v", p) + } + }) + + t.Run("non-embedded type is gated out", func(t *testing.T) { + // an image file is unconditional; its preview is not driven by oc.preview, + // so getPreview must return nil even though an image meta is present. + if p := getPreview("image/png", []map[string][]string{cover}); p != nil { + t.Fatalf("expected nil for non-embedded type, got %+v", p) + } + }) +} diff --git a/services/search/pkg/opensearch/testdata/resource.golden.json b/services/search/pkg/opensearch/testdata/resource.golden.json index ab343eef11..584ca6ea6d 100644 --- a/services/search/pkg/opensearch/testdata/resource.golden.json +++ b/services/search/pkg/opensearch/testdata/resource.golden.json @@ -283,6 +283,16 @@ } } }, + "preview": { + "properties": { + "height": { + "type": "integer" + }, + "width": { + "type": "integer" + } + } + }, "video": { "properties": { "audioBitsPerSample": { diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index 47caee4070..b39d64ce52 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -688,6 +688,7 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) { facetToMetadata(metadata, doc.Video, "libre.graph.video.") facetToMetadata(metadata, doc.MotionPhoto, "libre.graph.motionPhoto.") facetToMetadata(metadata, doc.LivePhoto, "libre.graph.livePhoto.") + facetToMetadata(metadata, doc.Preview, "oc.preview.") if len(metadata) == 0 { return } From d64fd2c01d19567aaafe781dee18f4c32778def4 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 17:46:08 +0200 Subject: [PATCH 03/18] fix(search): derive facets from container metadata only With embedded resources now surfaced by Tika (cover art via TIKA-4801), the per-meta facet loop let an embedded image overwrite the container's facets: audio files ended up with a bogus image facet (the cover's dimensions) and a wiped audio facet. Take audio/image/photo/location facets from the container entry only; embedded cover dimensions are captured as the preview instead. --- services/search/pkg/content/tika.go | 47 +++++++++++------------------ 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 86517f5014..3a4fd3aea2 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -84,7 +84,12 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if err != nil { return doc, err } + if len(metas) == 0 { + return doc, nil + } + // Title and content are aggregated across the container and all embedded + // resources (e.g. text embedded in a document). for _, meta := range metas { title, err := getFirstValue(meta, "dc:title") if err != nil { @@ -100,42 +105,26 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, } else if content, err := getFirstValue(meta, "X-TIKA:content"); err == nil { doc.Content = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Content, content)) } - - // keep facets from earlier entries, an embedded resource's meta - // (e.g. cover art) must not reset them - if v := t.getLocation(meta); v != nil { - doc.Location = v - } - if v := t.getImage(meta); v != nil { - doc.Image = v - } - if v := t.getPhoto(meta); v != nil { - doc.Photo = v - } - if v := t.getAudio(meta); v != nil { - doc.Audio = v - } - if v := t.getLivePhoto(meta); v != nil { - doc.LivePhoto = 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 // from it. The xmp alone proves nothing: a share can keep it and strip the // appended video. - if len(metas) > 0 { - if i := slices.IndexFunc(metas[1:], isVideo); i >= 0 { - doc.MotionPhoto = t.getMotionPhoto(metas[0], metas[i+1]) - } + if i := slices.IndexFunc(metas[1:], isVideo); i >= 0 { + doc.MotionPhoto = t.getMotionPhoto(metas[0], metas[i+1]) } + // Facets describe the resource itself, so they are taken from the container + // (the first entry). Its embedded resources, such as audio cover art, must + // not leak into them; the cover's dimensions become the preview instead. + container := metas[0] + doc.Location = t.getLocation(container) + doc.Image = t.getImage(container) + doc.Photo = t.getPhoto(container) + doc.Audio = t.getAudio(container) + doc.Video = t.getVideo(container) + doc.LivePhoto = t.getLivePhoto(container) + doc.Preview = getPreview(ri.GetMimeType(), metas) if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { From 9c48782d79cfa46f7239205f04e4b3f86596c847 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 18:00:57 +0200 Subject: [PATCH 04/18] feat(search): prefer the front cover for the preview dimensions getPreview now prefers the embedded image tagged as the front cover (dc:description "Cover (front)") and falls back to the first embedded image, matching the thumbnailer's cover selection so the reported oc.preview dimensions belong to the picture that actually gets rendered. --- services/search/pkg/content/tika.go | 29 ++++++++++++++----- .../search/pkg/content/tika_preview_test.go | 15 ++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 3a4fd3aea2..2f67bdedc0 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -156,15 +156,23 @@ func (t Tika) detectLanguage(ctx context.Context, content string) string { return "" } -// getPreview extracts the dimensions of an embedded preview image for content +// frontCoverDescription is the picture type Tika reports (as dc:description) for +// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. +const frontCoverDescription = "Cover (front)" + +// getPreview extracts the dimensions of the embedded preview image for content // whose thumbnail is embedded rather than rendered (audio cover art). Tika with -// TIKA-4801 surfaces the embedded cover as an additional image entry carrying -// tiff dimensions. It only runs for EmbeddedPreviewMimeTypes; unconditional -// types have their preview availability decided by the mimetype alone. +// TIKA-4801 surfaces embedded covers as image entries carrying tiff dimensions +// and the picture type as dc:description. It prefers the front cover and falls +// back to the first embedded image, matching the thumbnailer's cover selection, +// so the reported dimensions belong to the picture that actually gets rendered. +// It only runs for EmbeddedPreviewMimeTypes; unconditional types have their +// preview availability decided by the mimetype alone. func getPreview(mimeType string, metas []map[string][]string) *Preview { if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { return nil } + var first *Preview for _, meta := range metas { ct, err := getFirstValue(meta, "Content-Type") if err != nil || !strings.HasPrefix(ct, "image/") { @@ -177,9 +185,16 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { } width, wErr := strconv.ParseInt(w, 10, 32) height, hErr := strconv.ParseInt(h, 10, 32) - if wErr == nil && hErr == nil && width > 0 && height > 0 { - return &Preview{Width: int32(width), Height: int32(height)} + if wErr != nil || hErr != nil || width <= 0 || height <= 0 { + continue + } + preview := &Preview{Width: int32(width), Height: int32(height)} + if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { + return preview + } + if first == nil { + first = preview } } - return nil + return first } diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index d626a6c1f6..6bd069d8cc 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -30,6 +30,21 @@ func TestGetPreview(t *testing.T) { } }) + t.Run("prefers the front cover over an earlier back cover", func(t *testing.T) { + back := map[string][]string{ + "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (back)"}, + "tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"}, + } + front := map[string][]string{ + "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (front)"}, + "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, + } + p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) + if p == nil || p.Width != 64 || p.Height != 40 { + t.Fatalf("expected front cover 64x40, got %+v", p) + } + }) + t.Run("non-embedded type is gated out", func(t *testing.T) { // an image file is unconditional; its preview is not driven by oc.preview, // so getPreview must return nil even though an image meta is present. From 8c7e4b42576cfcaa517eb405e020428e4e3457b4 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 18:09:32 +0200 Subject: [PATCH 05/18] feat(graph): thumbnails relationship on driveItem listings Add a shared helper that builds the thumbnails relationship from a resource's info, gated on thumbnail.HasPreview so audio without embedded cover art no longer advertises a preview that fails to render. Reported dimensions are aspect-correct (fit into the box) rather than square, with a source thumbnail carrying the native dimensions (audio cover from oc.preview, images from the image facet). Wired into the drive children listings on $expand=thumbnails. --- services/graph/pkg/service/v0/driveitems.go | 1 + 1 file changed, 1 insertion(+) diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 167f8b1b62..9f441d7814 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -390,6 +390,7 @@ func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) { if !ok { return } + g.setDriveItemsThumbnails(r, files, res.GetInfos()) render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: files}) From b68b272e4bc3db802c888e8eed64f30d6e129d9e Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 18:23:02 +0200 Subject: [PATCH 06/18] feat(search): honest has-preview for audio in search results Add a preview facet to the search message entity carrying the embedded preview dimensions, mapped from the index in both engines. The webdav search report now derives oc:has-preview via thumbnail.HasPreviewForMimeType: unconditional types stay true, embedded-preview types (audio) are true only when a cover was indexed, instead of the blanket mimetype match that reported a preview for every audio file. --- .../opencloud/messages/search/v0/search.pb.go | 567 ++++++++++-------- .../services/search/v0/search.swagger.json | 17 + .../opencloud/messages/search/v0/search.proto | 8 + services/search/pkg/bleve/backend.go | 1 + .../opensearch/internal/convert/opensearch.go | 1 + .../thumbnails/pkg/thumbnail/haspreview.go | 14 +- services/webdav/pkg/service/v0/search.go | 3 +- 7 files changed, 367 insertions(+), 244 deletions(-) diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 0a8bf9c1c2..88290ad957 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.go @@ -361,6 +361,63 @@ func (x *Image) GetHeight() int32 { return 0 } +// Preview carries the dimensions of an embedded preview (e.g. audio cover art). +// Its presence signals that a preview is available for the resource. +type Preview struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Width *int32 `protobuf:"varint,1,opt,name=width,proto3,oneof" json:"width,omitempty"` + Height *int32 `protobuf:"varint,2,opt,name=height,proto3,oneof" json:"height,omitempty"` +} + +func (x *Preview) Reset() { + *x = Preview{} + if protoimpl.UnsafeEnabled { + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Preview) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Preview) ProtoMessage() {} + +func (x *Preview) ProtoReflect() protoreflect.Message { + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Preview.ProtoReflect.Descriptor instead. +func (*Preview) Descriptor() ([]byte, []int) { + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{4} +} + +func (x *Preview) GetWidth() int32 { + if x != nil && x.Width != nil { + return *x.Width + } + return 0 +} + +func (x *Preview) GetHeight() int32 { + if x != nil && x.Height != nil { + return *x.Height + } + return 0 +} + type GeoCoordinates struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -374,7 +431,7 @@ type GeoCoordinates struct { func (x *GeoCoordinates) Reset() { *x = GeoCoordinates{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -387,7 +444,7 @@ func (x *GeoCoordinates) String() string { func (*GeoCoordinates) ProtoMessage() {} func (x *GeoCoordinates) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[4] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -400,7 +457,7 @@ func (x *GeoCoordinates) ProtoReflect() protoreflect.Message { // Deprecated: Use GeoCoordinates.ProtoReflect.Descriptor instead. func (*GeoCoordinates) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{4} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{5} } func (x *GeoCoordinates) GetAltitude() float64 { @@ -443,7 +500,7 @@ type Photo struct { func (x *Photo) Reset() { *x = Photo{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -456,7 +513,7 @@ func (x *Photo) String() string { func (*Photo) ProtoMessage() {} func (x *Photo) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[5] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -469,7 +526,7 @@ func (x *Photo) ProtoReflect() protoreflect.Message { // Deprecated: Use Photo.ProtoReflect.Descriptor instead. func (*Photo) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{5} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{6} } func (x *Photo) GetCameraMake() string { @@ -555,7 +612,7 @@ type Video struct { func (x *Video) Reset() { *x = Video{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -568,7 +625,7 @@ func (x *Video) String() string { func (*Video) ProtoMessage() {} func (x *Video) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[6] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -581,7 +638,7 @@ func (x *Video) ProtoReflect() protoreflect.Message { // Deprecated: Use Video.ProtoReflect.Descriptor instead. func (*Video) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{6} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{7} } func (x *Video) GetAudioBitsPerSample() int32 { @@ -667,7 +724,7 @@ type MotionPhoto struct { func (x *MotionPhoto) Reset() { *x = MotionPhoto{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -680,7 +737,7 @@ func (x *MotionPhoto) String() string { func (*MotionPhoto) ProtoMessage() {} func (x *MotionPhoto) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -693,7 +750,7 @@ func (x *MotionPhoto) ProtoReflect() protoreflect.Message { // Deprecated: Use MotionPhoto.ProtoReflect.Descriptor instead. func (*MotionPhoto) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{7} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{8} } func (x *MotionPhoto) GetVersion() int32 { @@ -732,7 +789,7 @@ type LivePhoto struct { func (x *LivePhoto) Reset() { *x = LivePhoto{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -745,7 +802,7 @@ func (x *LivePhoto) String() string { func (*LivePhoto) ProtoMessage() {} func (x *LivePhoto) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[8] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -758,7 +815,7 @@ func (x *LivePhoto) ProtoReflect() protoreflect.Message { // Deprecated: Use LivePhoto.ProtoReflect.Descriptor instead. func (*LivePhoto) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{8} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{9} } func (x *LivePhoto) GetContentId() string { @@ -824,12 +881,13 @@ type Entity struct { MotionPhoto *MotionPhoto `protobuf:"bytes,21,opt,name=motionPhoto,proto3" json:"motionPhoto,omitempty"` Video *Video `protobuf:"bytes,22,opt,name=video,proto3" json:"video,omitempty"` LivePhoto *LivePhoto `protobuf:"bytes,23,opt,name=livePhoto,proto3" json:"livePhoto,omitempty"` + Preview *Preview `protobuf:"bytes,24,opt,name=preview,proto3" json:"preview,omitempty"` } func (x *Entity) Reset() { *x = Entity{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -842,7 +900,7 @@ func (x *Entity) String() string { func (*Entity) ProtoMessage() {} func (x *Entity) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[9] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -855,7 +913,7 @@ func (x *Entity) ProtoReflect() protoreflect.Message { // Deprecated: Use Entity.ProtoReflect.Descriptor instead. func (*Entity) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{9} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{10} } func (x *Entity) GetRef() *Reference { @@ -1019,6 +1077,13 @@ func (x *Entity) GetLivePhoto() *LivePhoto { return nil } +func (x *Entity) GetPreview() *Preview { + if x != nil { + return x.Preview + } + return nil +} + type Match struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1033,7 +1098,7 @@ type Match struct { func (x *Match) Reset() { *x = Match{} if protoimpl.UnsafeEnabled { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1046,7 +1111,7 @@ func (x *Match) String() string { func (*Match) ProtoMessage() {} func (x *Match) ProtoReflect() protoreflect.Message { - mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[10] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1059,7 +1124,7 @@ func (x *Match) ProtoReflect() protoreflect.Message { // Deprecated: Use Match.ProtoReflect.Descriptor instead. func (*Match) Descriptor() ([]byte, []int) { - return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{10} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{11} } func (x *Match) GetEntity() *Entity { @@ -1149,197 +1214,206 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{ 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, - 0x67, 0x68, 0x74, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, - 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, - 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, - 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, - 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, - 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, - 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, - 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, - 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, - 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, - 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, - 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, 0x52, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, - 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, - 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, - 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, - 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, - 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, - 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, - 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, - 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, 0x52, 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, - 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x45, 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, - 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, - 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, - 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, - 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, - 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, - 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, - 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, - 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, - 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, - 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, - 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, - 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, - 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, - 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, - 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, - 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, - 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, - 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, - 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, - 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, - 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, - 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, - 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, - 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, - 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, - 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, - 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, - 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, - 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, - 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, - 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, - 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, - 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, - 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, - 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, - 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, - 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, - 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, - 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, - 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, - 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, - 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, - 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, - 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, 0x02, 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, - 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, - 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, - 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, - 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, - 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x48, 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, - 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, - 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, - 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, - 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, - 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, - 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, - 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0xc9, 0x08, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, - 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, - 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, - 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, - 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, - 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, - 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, - 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, - 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, - 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, - 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x1e, 0x0a, 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, - 0x67, 0x68, 0x74, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x69, 0x67, 0x68, - 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x39, 0x0a, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, - 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, - 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, 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, + 0x67, 0x68, 0x74, 0x22, 0x56, 0x0a, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, 0x19, + 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, + 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, + 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, 0x69, + 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, + 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x22, 0x9d, 0x01, 0x0a, 0x0e, + 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, + 0x0a, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, + 0x48, 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, + 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x01, 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, + 0x12, 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, + 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, + 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, + 0x0a, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, + 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, + 0x61, 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, + 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, + 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x01, 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, + 0x01, 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, + 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, + 0x52, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, + 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, + 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, + 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, + 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, + 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, + 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, + 0x05, 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, + 0x01, 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, + 0x52, 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, + 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, + 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, + 0x45, 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, + 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, + 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, + 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, + 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, + 0x12, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, + 0x74, 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, + 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, + 0x06, 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, + 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, + 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, + 0x64, 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, + 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, + 0x00, 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, + 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, + 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, + 0x01, 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, + 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, + 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, + 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, + 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, + 0x6f, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, + 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, + 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, + 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, + 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, + 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, + 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, + 0x05, 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, + 0x13, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, + 0x6d, 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, + 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, + 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, + 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, + 0x75, 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, + 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, + 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, + 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, + 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, + 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, + 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, + 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, + 0x02, 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x00, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, + 0x2f, 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, + 0x65, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, + 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, + 0x12, 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, + 0x52, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, + 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, + 0x48, 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, + 0x65, 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, + 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, + 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, + 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, + 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, + 0x6d, 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, + 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, + 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, + 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8a, 0x09, 0x0a, 0x06, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, + 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, + 0x12, 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, + 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, + 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, + 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, + 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, + 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, + 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, + 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, + 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, + 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, + 0x61, 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 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, 0x56, 0x69, 0x64, 0x65, 0x6f, - 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x45, 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, - 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, - 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 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, + 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, 0x6f, 0x75, 0x64, 0x2e, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, + 0x30, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x39, + 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x68, 0x6f, + 0x74, 0x6f, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x61, 0x76, + 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x66, 0x61, + 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x6f, 0x74, 0x69, + 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, + 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x39, 0x0a, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x16, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, + 0x76, 0x30, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, + 0x45, 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, + 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, + 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x3f, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 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, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x07, + 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, + 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, + 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, + 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, + 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, + 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, + 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1354,42 +1428,44 @@ func file_opencloud_messages_search_v0_search_proto_rawDescGZIP() []byte { return file_opencloud_messages_search_v0_search_proto_rawDescData } -var file_opencloud_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_opencloud_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_opencloud_messages_search_v0_search_proto_goTypes = []interface{}{ (*ResourceID)(nil), // 0: opencloud.messages.search.v0.ResourceID (*Reference)(nil), // 1: opencloud.messages.search.v0.Reference (*Audio)(nil), // 2: opencloud.messages.search.v0.Audio (*Image)(nil), // 3: opencloud.messages.search.v0.Image - (*GeoCoordinates)(nil), // 4: opencloud.messages.search.v0.GeoCoordinates - (*Photo)(nil), // 5: opencloud.messages.search.v0.Photo - (*Video)(nil), // 6: opencloud.messages.search.v0.Video - (*MotionPhoto)(nil), // 7: opencloud.messages.search.v0.MotionPhoto - (*LivePhoto)(nil), // 8: opencloud.messages.search.v0.LivePhoto - (*Entity)(nil), // 9: opencloud.messages.search.v0.Entity - (*Match)(nil), // 10: opencloud.messages.search.v0.Match - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (*Preview)(nil), // 4: opencloud.messages.search.v0.Preview + (*GeoCoordinates)(nil), // 5: opencloud.messages.search.v0.GeoCoordinates + (*Photo)(nil), // 6: opencloud.messages.search.v0.Photo + (*Video)(nil), // 7: opencloud.messages.search.v0.Video + (*MotionPhoto)(nil), // 8: opencloud.messages.search.v0.MotionPhoto + (*LivePhoto)(nil), // 9: opencloud.messages.search.v0.LivePhoto + (*Entity)(nil), // 10: opencloud.messages.search.v0.Entity + (*Match)(nil), // 11: opencloud.messages.search.v0.Match + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp } var file_opencloud_messages_search_v0_search_proto_depIdxs = []int32{ 0, // 0: opencloud.messages.search.v0.Reference.resource_id:type_name -> opencloud.messages.search.v0.ResourceID - 11, // 1: opencloud.messages.search.v0.Photo.takenDateTime:type_name -> google.protobuf.Timestamp + 12, // 1: opencloud.messages.search.v0.Photo.takenDateTime:type_name -> google.protobuf.Timestamp 1, // 2: opencloud.messages.search.v0.Entity.ref:type_name -> opencloud.messages.search.v0.Reference 0, // 3: opencloud.messages.search.v0.Entity.id:type_name -> opencloud.messages.search.v0.ResourceID - 11, // 4: opencloud.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp + 12, // 4: opencloud.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp 0, // 5: opencloud.messages.search.v0.Entity.parent_id:type_name -> opencloud.messages.search.v0.ResourceID 2, // 6: opencloud.messages.search.v0.Entity.audio:type_name -> opencloud.messages.search.v0.Audio - 4, // 7: opencloud.messages.search.v0.Entity.location:type_name -> opencloud.messages.search.v0.GeoCoordinates + 5, // 7: opencloud.messages.search.v0.Entity.location:type_name -> opencloud.messages.search.v0.GeoCoordinates 0, // 8: opencloud.messages.search.v0.Entity.remote_item_id:type_name -> opencloud.messages.search.v0.ResourceID 3, // 9: opencloud.messages.search.v0.Entity.image:type_name -> opencloud.messages.search.v0.Image - 5, // 10: opencloud.messages.search.v0.Entity.photo:type_name -> opencloud.messages.search.v0.Photo - 7, // 11: opencloud.messages.search.v0.Entity.motionPhoto:type_name -> opencloud.messages.search.v0.MotionPhoto - 6, // 12: opencloud.messages.search.v0.Entity.video:type_name -> opencloud.messages.search.v0.Video - 8, // 13: opencloud.messages.search.v0.Entity.livePhoto:type_name -> opencloud.messages.search.v0.LivePhoto - 9, // 14: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity - 15, // [15:15] is the sub-list for method output_type - 15, // [15:15] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 6, // 10: opencloud.messages.search.v0.Entity.photo:type_name -> opencloud.messages.search.v0.Photo + 8, // 11: opencloud.messages.search.v0.Entity.motionPhoto:type_name -> opencloud.messages.search.v0.MotionPhoto + 7, // 12: opencloud.messages.search.v0.Entity.video:type_name -> opencloud.messages.search.v0.Video + 9, // 13: opencloud.messages.search.v0.Entity.livePhoto:type_name -> opencloud.messages.search.v0.LivePhoto + 4, // 14: opencloud.messages.search.v0.Entity.preview:type_name -> opencloud.messages.search.v0.Preview + 10, // 15: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity + 16, // [16:16] is the sub-list for method output_type + 16, // [16:16] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_opencloud_messages_search_v0_search_proto_init() } @@ -1447,7 +1523,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*GeoCoordinates); i { + switch v := v.(*Preview); i { case 0: return &v.state case 1: @@ -1459,7 +1535,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Photo); i { + switch v := v.(*GeoCoordinates); i { case 0: return &v.state case 1: @@ -1471,7 +1547,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Video); i { + switch v := v.(*Photo); i { case 0: return &v.state case 1: @@ -1483,7 +1559,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*MotionPhoto); i { + switch v := v.(*Video); i { case 0: return &v.state case 1: @@ -1495,7 +1571,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*LivePhoto); i { + switch v := v.(*MotionPhoto); i { case 0: return &v.state case 1: @@ -1507,7 +1583,7 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Entity); i { + switch v := v.(*LivePhoto); i { case 0: return &v.state case 1: @@ -1519,6 +1595,18 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Entity); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_opencloud_messages_search_v0_search_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Match); i { case 0: return &v.state @@ -1538,13 +1626,14 @@ func file_opencloud_messages_search_v0_search_proto_init() { file_opencloud_messages_search_v0_search_proto_msgTypes[6].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[7].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[8].OneofWrappers = []interface{}{} + file_opencloud_messages_search_v0_search_proto_msgTypes[9].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_opencloud_messages_search_v0_search_proto_rawDesc, NumEnums: 0, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 43ff1e2c79..86d46d57d1 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -307,6 +307,9 @@ }, "livePhoto": { "$ref": "#/definitions/v0LivePhoto" + }, + "preview": { + "$ref": "#/definitions/v0Preview" } } }, @@ -478,6 +481,20 @@ } } }, + "v0Preview": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + } + }, + "description": "Preview carries the dimensions of an embedded preview (e.g. audio cover art).\nIts presence signals that a preview is available for the resource." + }, "v0Reference": { "type": "object", "properties": { diff --git a/protogen/proto/opencloud/messages/search/v0/search.proto b/protogen/proto/opencloud/messages/search/v0/search.proto index a2a25a8d5e..41bcb5136b 100644 --- a/protogen/proto/opencloud/messages/search/v0/search.proto +++ b/protogen/proto/opencloud/messages/search/v0/search.proto @@ -41,6 +41,13 @@ message Image { optional int32 height = 2; } +// Preview carries the dimensions of an embedded preview (e.g. audio cover art). +// Its presence signals that a preview is available for the resource. +message Preview { + optional int32 width = 1; + optional int32 height = 2; +} + message GeoCoordinates { optional double altitude = 1; optional double latitude = 2; @@ -110,6 +117,7 @@ message Entity { MotionPhoto motionPhoto = 21; Video video = 22; LivePhoto livePhoto = 23; + Preview preview = 24; } message Match { diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index 7fc6fe5bfa..d6e4f1aa5c 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -142,6 +142,7 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques Video: hitToFacet[searchMessage.Video](hit.Fields, "video"), MotionPhoto: hitToFacet[searchMessage.MotionPhoto](hit.Fields, "motionPhoto"), LivePhoto: hitToFacet[searchMessage.LivePhoto](hit.Fields, "livePhoto"), + Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), }, } diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 81d1b347e2..1fb04229d3 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -86,6 +86,7 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, Video: copyFacet[searchMessage.Video](resource.Video), MotionPhoto: copyFacet[searchMessage.MotionPhoto](resource.MotionPhoto), LivePhoto: copyFacet[searchMessage.LivePhoto](resource.LivePhoto), + Preview: copyFacet[searchMessage.Preview](resource.Preview), }, } diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go index 5af6c07b8e..6b9136eacc 100644 --- a/services/thumbnails/pkg/thumbnail/haspreview.go +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -25,14 +25,22 @@ func HasPreview(md *provider.ResourceInfo) bool { if md == nil { return false } + w, h := PreviewDimensions(md) + return HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0) +} - mimeType := md.GetMimeType() +// HasPreviewForMimeType reports whether a preview can be produced for a resource +// of the given mimetype. For unconditional types it follows from the mimetype +// alone; for embedded-preview types (audio cover art) it depends on +// hasEmbeddedPreview, i.e. whether an embedded preview was detected at index +// time. Callers that only have the mimetype and a presence signal (for example +// search results) use this instead of HasPreview. +func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { return true } if _, ok := EmbeddedPreviewMimeTypes[mimeType]; ok { - w, h := PreviewDimensions(md) - return w > 0 && h > 0 + return hasEmbeddedPreview } return false } diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index e2f6a3f4a4..8da71806d9 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -222,8 +222,7 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType)) - _, isSupportedMimeType := thumbnail.SupportedMimeTypes[match.Entity.MimeType] - if isSupportedMimeType { + if thumbnail.HasPreviewForMimeType(match.Entity.MimeType, match.Entity.GetPreview() != nil) { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "0")) From c3c6aaea9b05725407883fcab4b53eecee614e21 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:02:50 +0200 Subject: [PATCH 07/18] docs: shorten comments --- services/search/pkg/content/content.go | 12 ++++------ services/search/pkg/content/tika.go | 12 ++++------ .../thumbnails/pkg/thumbnail/haspreview.go | 22 +++++-------------- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index 6e65e3933a..f39d3ce71f 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -33,19 +33,15 @@ type Document struct { Preview *Preview `json:"preview,omitempty"` } -// Preview holds the dimensions of an embedded preview image (for example audio -// cover art) for content types whose thumbnail is embedded rather than rendered -// and may therefore be absent. It is an internal signal, not a Microsoft Graph -// facet: its presence marks that a preview exists for the resource. +// Preview holds the dimensions of an embedded preview (e.g. audio cover art). +// Internal signal, not a Graph facet; its presence marks that a preview exists. type Preview struct { Width int32 `json:"width"` Height int32 `json:"height"` } -// ToMap lets Preview flow through the same facet-to-metadata flattening as the -// Microsoft Graph facets, so it is stored under the oc.preview. prefix (keys -// oc.preview.width / oc.preview.height, matching thumbnail.PreviewWidthKey / -// thumbnail.PreviewHeightKey). Preview is not itself a Graph facet. +// ToMap lets Preview flow through the shared facet-to-metadata flattening (under +// the oc.preview. prefix). Preview is not itself a Graph facet. func (p Preview) ToMap() (map[string]interface{}, error) { return map[string]interface{}{ "width": p.Width, diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 2f67bdedc0..463e6d7f87 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -160,14 +160,10 @@ func (t Tika) detectLanguage(ctx context.Context, content string) string { // the front cover, matching the thumbnailer's selection in the dhowden/tag fork. const frontCoverDescription = "Cover (front)" -// getPreview extracts the dimensions of the embedded preview image for content -// whose thumbnail is embedded rather than rendered (audio cover art). Tika with -// TIKA-4801 surfaces embedded covers as image entries carrying tiff dimensions -// and the picture type as dc:description. It prefers the front cover and falls -// back to the first embedded image, matching the thumbnailer's cover selection, -// so the reported dimensions belong to the picture that actually gets rendered. -// It only runs for EmbeddedPreviewMimeTypes; unconditional types have their -// preview availability decided by the mimetype alone. +// getPreview returns the dimensions of an audio file's embedded cover art from +// Tika's recursive metadata, preferring the front cover (dc:description) and +// falling back to the first image, matching the thumbnailer's selection. It only +// runs for EmbeddedPreviewMimeTypes. func getPreview(mimeType string, metas []map[string][]string) *Preview { if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { return nil diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go index 6b9136eacc..30db1e32a2 100644 --- a/services/thumbnails/pkg/thumbnail/haspreview.go +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -6,21 +6,15 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" ) -// Arbitrary-metadata keys under which the content extraction pipeline stores the -// dimensions of an embedded preview (for example audio cover art). These are an -// internal signal, not a Microsoft Graph facet. Their presence means the file -// carries an embedded preview. +// Arbitrary-metadata keys holding an embedded preview's dimensions (e.g. audio +// cover art), written at index time. Their presence signals a preview exists. const ( PreviewWidthKey = "oc.preview.width" PreviewHeightKey = "oc.preview.height" ) -// HasPreview reports whether a thumbnail/preview can be produced for the given -// resource. For unconditional types it follows from the mimetype alone. For -// embedded-preview types (audio cover art) it depends on whether an embedded -// preview was detected at index time, signalled by the stored preview -// dimensions. Files that have not been indexed yet report no preview rather than -// promising one that would fail to render. +// HasPreview reports whether a preview can be produced for the resource: +// unconditional types by mimetype, embedded-preview types by stored dimensions. func HasPreview(md *provider.ResourceInfo) bool { if md == nil { return false @@ -29,12 +23,8 @@ func HasPreview(md *provider.ResourceInfo) bool { return HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0) } -// HasPreviewForMimeType reports whether a preview can be produced for a resource -// of the given mimetype. For unconditional types it follows from the mimetype -// alone; for embedded-preview types (audio cover art) it depends on -// hasEmbeddedPreview, i.e. whether an embedded preview was detected at index -// time. Callers that only have the mimetype and a presence signal (for example -// search results) use this instead of HasPreview. +// HasPreviewForMimeType is HasPreview for callers that only have the mimetype +// and a presence signal (e.g. search results) rather than a full ResourceInfo. func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { return true From 93ab534580c19f1ffa57df51f1b529f254ae22fb Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:27:24 +0200 Subject: [PATCH 08/18] docs: tighten comments --- services/search/pkg/content/content.go | 3 +-- services/search/pkg/content/tika.go | 8 +++----- services/thumbnails/pkg/thumbnail/haspreview.go | 5 ++--- services/thumbnails/pkg/thumbnail/mimetypes.go | 5 ++--- services/thumbnails/pkg/thumbnail/mimetypes_common.go | 11 +++-------- services/thumbnails/pkg/thumbnail/mimetypes_vips.go | 5 ++--- 6 files changed, 13 insertions(+), 24 deletions(-) diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index f39d3ce71f..af48607751 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -40,8 +40,7 @@ type Preview struct { Height int32 `json:"height"` } -// ToMap lets Preview flow through the shared facet-to-metadata flattening (under -// the oc.preview. prefix). Preview is not itself a Graph facet. +// ToMap flows Preview through the shared facet flattening, under oc.preview. func (p Preview) ToMap() (map[string]interface{}, error) { return map[string]interface{}{ "width": p.Width, diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 463e6d7f87..81b480a7ba 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -88,8 +88,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } - // Title and content are aggregated across the container and all embedded - // resources (e.g. text embedded in a document). + // Title and content aggregate across the container and embedded resources. for _, meta := range metas { title, err := getFirstValue(meta, "dc:title") if err != nil { @@ -114,9 +113,8 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.MotionPhoto = t.getMotionPhoto(metas[0], metas[i+1]) } - // Facets describe the resource itself, so they are taken from the container - // (the first entry). Its embedded resources, such as audio cover art, must - // not leak into them; the cover's dimensions become the preview instead. + // Facets come from the container (first entry) only; embedded resources like + // audio cover art must not leak in (the cover becomes the preview instead). container := metas[0] doc.Location = t.getLocation(container) doc.Image = t.getImage(container) diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go index 30db1e32a2..f3cff6d71e 100644 --- a/services/thumbnails/pkg/thumbnail/haspreview.go +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -35,9 +35,8 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { return false } -// PreviewDimensions returns the stored dimensions of a resource's embedded -// preview, or (0, 0) if none were recorded. Only meaningful for -// EmbeddedPreviewMimeTypes. +// PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). +// Only meaningful for EmbeddedPreviewMimeTypes. func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { meta := md.GetArbitraryMetadata().GetMetadata() if meta == nil { diff --git a/services/thumbnails/pkg/thumbnail/mimetypes.go b/services/thumbnails/pkg/thumbnail/mimetypes.go index 5dc32cbe74..390cec01c7 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes.go @@ -2,9 +2,8 @@ package thumbnail -// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows -// from the mimetype alone: the thumbnailer can always render a preview from the -// content, so a preview is guaranteed to exist. +// UnconditionalPreviewMimeTypes always have a preview: the thumbnailer renders +// one from the content. var UnconditionalPreviewMimeTypes = map[string]struct{}{ "image/png": {}, "image/jpg": {}, diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_common.go b/services/thumbnails/pkg/thumbnail/mimetypes_common.go index a774dd7d68..96f047c713 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes_common.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes_common.go @@ -1,19 +1,14 @@ package thumbnail -// EmbeddedPreviewMimeTypes are mimetypes whose preview is an embedded resource -// (for example audio cover art) that may or may not be present. Preview -// availability cannot be derived from the mimetype alone and must be determined -// per file (see HasPreview). +// EmbeddedPreviewMimeTypes have an embedded preview (e.g. audio cover art) that +// may be absent; availability is decided per file (see HasPreview). var EmbeddedPreviewMimeTypes = map[string]struct{}{ "audio/flac": {}, "audio/mpeg": {}, "audio/ogg": {}, } -// SupportedMimeTypes contains all mimetypes the thumbnailer can produce a -// thumbnail for: the union of the unconditional and embedded preview types. -// The generator gates on this union; preview availability per file is decided -// by HasPreview. +// SupportedMimeTypes is the union of both preview sets; the generator gates on it. var SupportedMimeTypes = func() map[string]struct{} { m := make(map[string]struct{}, len(UnconditionalPreviewMimeTypes)+len(EmbeddedPreviewMimeTypes)) for k := range UnconditionalPreviewMimeTypes { diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go index 677ae43d86..929718e7ad 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go @@ -2,9 +2,8 @@ package thumbnail -// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows -// from the mimetype alone: the thumbnailer can always render a preview from the -// content, so a preview is guaranteed to exist. +// UnconditionalPreviewMimeTypes always have a preview: the thumbnailer renders +// one from the content. var UnconditionalPreviewMimeTypes = map[string]struct{}{ "image/png": {}, "image/jpg": {}, From 9ab5c8282df5670efb747094cefcbc486613f098 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:31:20 +0200 Subject: [PATCH 09/18] refactor: move getPreview to tika_preview.go, rename has_preview.go --- services/search/pkg/content/tika.go | 41 ---------------- services/search/pkg/content/tika_preview.go | 47 +++++++++++++++++++ .../{haspreview.go => has_preview.go} | 0 ...haspreview_test.go => has_preview_test.go} | 0 4 files changed, 47 insertions(+), 41 deletions(-) create mode 100644 services/search/pkg/content/tika_preview.go rename services/thumbnails/pkg/thumbnail/{haspreview.go => has_preview.go} (100%) rename services/thumbnails/pkg/thumbnail/{haspreview_test.go => has_preview_test.go} (100%) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 81b480a7ba..9fc0232df7 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "slices" - "strconv" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -16,7 +15,6 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/search/pkg/config" - "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) // Tika is used to extract content from a resource, @@ -153,42 +151,3 @@ func (t Tika) detectLanguage(ctx context.Context, content string) string { } return "" } - -// frontCoverDescription is the picture type Tika reports (as dc:description) for -// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. -const frontCoverDescription = "Cover (front)" - -// getPreview returns the dimensions of an audio file's embedded cover art from -// Tika's recursive metadata, preferring the front cover (dc:description) and -// falling back to the first image, matching the thumbnailer's selection. It only -// runs for EmbeddedPreviewMimeTypes. -func getPreview(mimeType string, metas []map[string][]string) *Preview { - if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { - return nil - } - var first *Preview - for _, meta := range metas { - ct, err := getFirstValue(meta, "Content-Type") - if err != nil || !strings.HasPrefix(ct, "image/") { - continue - } - w, wErr := getFirstValue(meta, "tiff:ImageWidth") - h, hErr := getFirstValue(meta, "tiff:ImageLength") - if wErr != nil || hErr != nil { - continue - } - width, wErr := strconv.ParseInt(w, 10, 32) - height, hErr := strconv.ParseInt(h, 10, 32) - if wErr != nil || hErr != nil || width <= 0 || height <= 0 { - continue - } - preview := &Preview{Width: int32(width), Height: int32(height)} - if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { - return preview - } - if first == nil { - first = preview - } - } - return first -} diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go new file mode 100644 index 0000000000..a8d3280de2 --- /dev/null +++ b/services/search/pkg/content/tika_preview.go @@ -0,0 +1,47 @@ +package content + +import ( + "strconv" + "strings" + + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" +) + +// frontCoverDescription is the picture type Tika reports (as dc:description) for +// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. +const frontCoverDescription = "Cover (front)" + +// getPreview returns the dimensions of an audio file's embedded cover art from +// Tika's recursive metadata, preferring the front cover (dc:description) and +// falling back to the first image, matching the thumbnailer's selection. It only +// runs for EmbeddedPreviewMimeTypes. +func getPreview(mimeType string, metas []map[string][]string) *Preview { + if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { + return nil + } + var first *Preview + for _, meta := range metas { + ct, err := getFirstValue(meta, "Content-Type") + if err != nil || !strings.HasPrefix(ct, "image/") { + continue + } + w, wErr := getFirstValue(meta, "tiff:ImageWidth") + h, hErr := getFirstValue(meta, "tiff:ImageLength") + if wErr != nil || hErr != nil { + continue + } + width, wErr := strconv.ParseInt(w, 10, 32) + height, hErr := strconv.ParseInt(h, 10, 32) + if wErr != nil || hErr != nil || width <= 0 || height <= 0 { + continue + } + preview := &Preview{Width: int32(width), Height: int32(height)} + if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { + return preview + } + if first == nil { + first = preview + } + } + return first +} diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/has_preview.go similarity index 100% rename from services/thumbnails/pkg/thumbnail/haspreview.go rename to services/thumbnails/pkg/thumbnail/has_preview.go diff --git a/services/thumbnails/pkg/thumbnail/haspreview_test.go b/services/thumbnails/pkg/thumbnail/has_preview_test.go similarity index 100% rename from services/thumbnails/pkg/thumbnail/haspreview_test.go rename to services/thumbnails/pkg/thumbnail/has_preview_test.go From 3b9280928596f187a8d0ddf27e4f799022a58785 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:42:18 +0200 Subject: [PATCH 10/18] test: use ginkgo/gomega for the preview tests --- .../search/pkg/content/tika_preview_test.go | 44 ++++++------ .../pkg/thumbnail/has_preview_test.go | 68 ++++++++----------- .../pkg/thumbnail/thumbnail_suite_test.go | 13 ++++ 3 files changed, 61 insertions(+), 64 deletions(-) create mode 100644 services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index 6bd069d8cc..f57055d768 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -1,8 +1,11 @@ package content -import "testing" +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) -func TestGetPreview(t *testing.T) { +var _ = Describe("getPreview", func() { audio := map[string][]string{"Content-Type": {"audio/mpeg"}} cover := map[string][]string{ "Content-Type": {"image/jpeg"}, @@ -11,26 +14,21 @@ func TestGetPreview(t *testing.T) { } coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} - t.Run("audio with embedded cover returns dims", func(t *testing.T) { + It("returns the embedded cover dimensions for audio", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) - if p == nil || p.Width != 500 || p.Height != 400 { - t.Fatalf("expected 500x400, got %+v", p) - } + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 500, Height: 400})) }) - t.Run("audio without cover returns nil", func(t *testing.T) { - if p := getPreview("audio/mpeg", []map[string][]string{audio}); p != nil { - t.Fatalf("expected nil, got %+v", p) - } + It("returns nil when the audio has no cover", func() { + Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil()) }) - t.Run("audio with cover lacking dims returns nil", func(t *testing.T) { - if p := getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims}); p != nil { - t.Fatalf("expected nil, got %+v", p) - } + It("returns nil when the cover lacks dimensions", func() { + Expect(getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims})).To(BeNil()) }) - t.Run("prefers the front cover over an earlier back cover", func(t *testing.T) { + It("prefers the front cover over an earlier back cover", func() { back := map[string][]string{ "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (back)"}, "tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"}, @@ -40,16 +38,12 @@ func TestGetPreview(t *testing.T) { "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, } p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) - if p == nil || p.Width != 64 || p.Height != 40 { - t.Fatalf("expected front cover 64x40, got %+v", p) - } + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 64, Height: 40})) }) - t.Run("non-embedded type is gated out", func(t *testing.T) { - // an image file is unconditional; its preview is not driven by oc.preview, - // so getPreview must return nil even though an image meta is present. - if p := getPreview("image/png", []map[string][]string{cover}); p != nil { - t.Fatalf("expected nil for non-embedded type, got %+v", p) - } + It("is gated to embedded-preview types", func() { + // an image is unconditional; its preview is not driven by oc.preview. + Expect(getPreview("image/png", []map[string][]string{cover})).To(BeNil()) }) -} +}) diff --git a/services/thumbnails/pkg/thumbnail/has_preview_test.go b/services/thumbnails/pkg/thumbnail/has_preview_test.go index 7d446ef70c..271e091cca 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview_test.go +++ b/services/thumbnails/pkg/thumbnail/has_preview_test.go @@ -1,9 +1,11 @@ -package thumbnail +package thumbnail_test import ( - "testing" - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) func resourceInfo(mime string, meta map[string]string) *provider.ResourceInfo { @@ -14,42 +16,30 @@ func resourceInfo(mime string, meta map[string]string) *provider.ResourceInfo { return ri } -func TestHasPreview(t *testing.T) { - cases := []struct { - name string - md *provider.ResourceInfo - want bool - }{ - {"nil", nil, false}, - {"unconditional image", resourceInfo("image/png", nil), true}, - {"unconditional text", resourceInfo("text/plain", nil), true}, - {"unsupported type", resourceInfo("application/pdf", nil), false}, - {"audio without preview dims", resourceInfo("audio/mpeg", nil), false}, - {"audio with empty dims", resourceInfo("audio/mpeg", map[string]string{ - PreviewWidthKey: "0", PreviewHeightKey: "0", - }), false}, - {"audio with preview dims", resourceInfo("audio/mpeg", map[string]string{ - PreviewWidthKey: "500", PreviewHeightKey: "500", - }), true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := HasPreview(tc.md); got != tc.want { - t.Errorf("HasPreview(%s) = %v, want %v", tc.name, got, tc.want) - } - }) - } -} +var _ = Describe("HasPreview", func() { + DescribeTable("preview availability", + func(md *provider.ResourceInfo, want bool) { + Expect(thumbnail.HasPreview(md)).To(Equal(want)) + }, + Entry("nil", nil, false), + Entry("unconditional image", resourceInfo("image/png", nil), true), + Entry("unconditional text", resourceInfo("text/plain", nil), true), + Entry("unsupported type", resourceInfo("application/pdf", nil), false), + Entry("audio without preview dims", resourceInfo("audio/mpeg", nil), false), + Entry("audio with zero dims", resourceInfo("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "0", thumbnail.PreviewHeightKey: "0", + }), false), + Entry("audio with preview dims", resourceInfo("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "500", thumbnail.PreviewHeightKey: "500", + }), true), + ) -func TestSupportedMimeTypesIsUnion(t *testing.T) { - for k := range UnconditionalPreviewMimeTypes { - if _, ok := SupportedMimeTypes[k]; !ok { - t.Errorf("SupportedMimeTypes missing unconditional type %q", k) + It("SupportedMimeTypes is the union of both preview sets", func() { + for k := range thumbnail.UnconditionalPreviewMimeTypes { + Expect(thumbnail.SupportedMimeTypes).To(HaveKey(k)) } - } - for k := range EmbeddedPreviewMimeTypes { - if _, ok := SupportedMimeTypes[k]; !ok { - t.Errorf("SupportedMimeTypes missing embedded type %q", k) + for k := range thumbnail.EmbeddedPreviewMimeTypes { + Expect(thumbnail.SupportedMimeTypes).To(HaveKey(k)) } - } -} + }) +}) diff --git a/services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go b/services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go new file mode 100644 index 0000000000..7217b6a146 --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go @@ -0,0 +1,13 @@ +package thumbnail_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestThumbnail(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Thumbnail Suite") +} From efb89c1bf8d4132e14f5a017a8ae8d5e7b548b52 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:45:00 +0200 Subject: [PATCH 11/18] refactor: StringToInt32 returns the parse error --- pkg/conversions/strings.go | 7 ++++++ .../thumbnails/pkg/thumbnail/has_preview.go | 22 +++++-------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index 63cc68f2a7..d0e6a8e7e0 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -1,6 +1,7 @@ package conversions import ( + "strconv" "strings" ) @@ -14,3 +15,9 @@ func StringToSliceString(src string, sep string) []string { return parts } + +// StringToInt32 parses s as a base-10 int32. +func StringToInt32(s string) (int32, error) { + v, err := strconv.ParseInt(s, 10, 32) + return int32(v), err +} diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index f3cff6d71e..e6226ea018 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -1,9 +1,9 @@ package thumbnail import ( - "strconv" - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + + "github.com/opencloud-eu/opencloud/pkg/conversions" ) // Arbitrary-metadata keys holding an embedded preview's dimensions (e.g. audio @@ -39,19 +39,7 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // Only meaningful for EmbeddedPreviewMimeTypes. func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { meta := md.GetArbitraryMetadata().GetMetadata() - if meta == nil { - return 0, 0 - } - return parseInt32(meta[PreviewWidthKey]), parseInt32(meta[PreviewHeightKey]) -} - -func parseInt32(s string) int32 { - if s == "" { - return 0 - } - v, err := strconv.ParseInt(s, 10, 32) - if err != nil { - return 0 - } - return int32(v) + width, _ = conversions.StringToInt32(meta[PreviewWidthKey]) + height, _ = conversions.StringToInt32(meta[PreviewHeightKey]) + return } From 6a9318816774abeaae22f9785f7f16e8fe33d8af Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:46:43 +0200 Subject: [PATCH 12/18] refactor: drop named returns in PreviewDimensions --- services/thumbnails/pkg/thumbnail/has_preview.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index e6226ea018..b3db28c026 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -37,9 +37,9 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). // Only meaningful for EmbeddedPreviewMimeTypes. -func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { +func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() - width, _ = conversions.StringToInt32(meta[PreviewWidthKey]) - height, _ = conversions.StringToInt32(meta[PreviewHeightKey]) - return + w, _ := conversions.StringToInt32(meta[PreviewWidthKey]) + h, _ := conversions.StringToInt32(meta[PreviewHeightKey]) + return w, h } From daae6d28f76507028c8e7eb967cdb178f3182b30 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:51:06 +0200 Subject: [PATCH 13/18] refactor: StringToInt32 takes an explicit fallback --- pkg/conversions/strings.go | 9 ++++++--- services/thumbnails/pkg/thumbnail/has_preview.go | 4 +--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index d0e6a8e7e0..a03b7d963b 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -16,8 +16,11 @@ func StringToSliceString(src string, sep string) []string { return parts } -// StringToInt32 parses s as a base-10 int32. -func StringToInt32(s string) (int32, error) { +// StringToInt32 parses s as a base-10 int32, returning fallback on any error. +func StringToInt32(s string, fallback int32) int32 { v, err := strconv.ParseInt(s, 10, 32) - return int32(v), err + if err != nil { + return fallback + } + return int32(v) } diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index b3db28c026..3bb1934438 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -39,7 +39,5 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // Only meaningful for EmbeddedPreviewMimeTypes. func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() - w, _ := conversions.StringToInt32(meta[PreviewWidthKey]) - h, _ := conversions.StringToInt32(meta[PreviewHeightKey]) - return w, h + return conversions.StringToInt32(meta[PreviewWidthKey], 0), conversions.StringToInt32(meta[PreviewHeightKey], 0) } From 7fc112b07f9e27d829f1de1cb4d9c1729545da6c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:54:04 +0200 Subject: [PATCH 14/18] refactor: split PreviewDimensions onto multiple lines --- services/thumbnails/pkg/thumbnail/has_preview.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index 3bb1934438..063f53e2bc 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -39,5 +39,7 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // Only meaningful for EmbeddedPreviewMimeTypes. func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() - return conversions.StringToInt32(meta[PreviewWidthKey], 0), conversions.StringToInt32(meta[PreviewHeightKey], 0) + w := conversions.StringToInt32(meta[PreviewWidthKey], 0) + h := conversions.StringToInt32(meta[PreviewHeightKey], 0) + return w, h } From 60f93c059151782900498bdacf931e436a3e455b Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:55:32 +0200 Subject: [PATCH 15/18] docs: drop fork mention in comment --- services/search/pkg/content/tika_preview.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index a8d3280de2..76a238e562 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -8,7 +8,7 @@ import ( ) // frontCoverDescription is the picture type Tika reports (as dc:description) for -// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. +// the front cover, matching the thumbnailer's cover selection. const frontCoverDescription = "Cover (front)" // getPreview returns the dimensions of an audio file's embedded cover art from From c1a9cc59a08a4655736580a8610d79f4095b102d Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 1 Sep 2026 10:51:36 +0200 Subject: [PATCH 16/18] refactor(search): set the preview apart from the 1:1 facets --- services/search/pkg/bleve/backend.go | 23 ++++++++++--------- services/search/pkg/content/content.go | 19 +++++++-------- services/search/pkg/content/tika.go | 4 ++-- .../opensearch/internal/convert/opensearch.go | 4 +++- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index d6e4f1aa5c..6da56dd018 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -125,16 +125,18 @@ 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), + 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), + Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), + Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"), Image: hitToFacet[searchMessage.Image](hit.Fields, "image"), Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"), @@ -142,7 +144,6 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques Video: hitToFacet[searchMessage.Video](hit.Fields, "video"), MotionPhoto: hitToFacet[searchMessage.MotionPhoto](hit.Fields, "motionPhoto"), LivePhoto: hitToFacet[searchMessage.LivePhoto](hit.Fields, "livePhoto"), - Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), }, } diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index af48607751..d645a90bb2 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -15,14 +15,16 @@ 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"` + 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"` + Preview *Preview `json:"preview,omitempty"` + Audio *libregraph.Audio `json:"audio,omitempty"` Image *libregraph.Image `json:"image,omitempty"` Location *libregraph.GeoCoordinates `json:"location,omitempty"` @@ -30,7 +32,6 @@ type Document struct { Video *libregraph.Video `json:"video,omitempty"` MotionPhoto *libregraph.MotionPhoto `json:"motionPhoto,omitempty"` LivePhoto *libregraph.LivePhoto `json:"livePhoto,omitempty"` - Preview *Preview `json:"preview,omitempty"` } // Preview holds the dimensions of an embedded preview (e.g. audio cover art). diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 9fc0232df7..34e68a6a47 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -111,6 +111,8 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.MotionPhoto = t.getMotionPhoto(metas[0], metas[i+1]) } + doc.Preview = getPreview(ri.GetMimeType(), metas) + // Facets come from the container (first entry) only; embedded resources like // audio cover art must not leak in (the cover becomes the preview instead). container := metas[0] @@ -121,8 +123,6 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.Video = t.getVideo(container) doc.LivePhoto = t.getLivePhoto(container) - doc.Preview = getPreview(ri.GetMimeType(), metas) - if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { doc.Content = CleanString(doc.Content, langCode) } diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 1fb04229d3..747f1dd856 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -79,6 +79,9 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, return strings.Join(contentHighlights[:], "; ") }(), + + Preview: copyFacet[searchMessage.Preview](resource.Preview), + Audio: copyFacet[searchMessage.Audio](resource.Audio), Image: copyFacet[searchMessage.Image](resource.Image), Location: copyFacet[searchMessage.GeoCoordinates](resource.Location), @@ -86,7 +89,6 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, Video: copyFacet[searchMessage.Video](resource.Video), MotionPhoto: copyFacet[searchMessage.MotionPhoto](resource.MotionPhoto), LivePhoto: copyFacet[searchMessage.LivePhoto](resource.LivePhoto), - Preview: copyFacet[searchMessage.Preview](resource.Preview), }, } From ab5c9615b9993771fd06bfc873fa181f8e8c1f45 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 1 Sep 2026 10:53:39 +0200 Subject: [PATCH 17/18] test(search): pin container-only facets and the cover preview in extract --- services/search/pkg/content/tika_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 5a224a9e33..fc0c3e41ec 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -170,17 +170,22 @@ var _ = Describe("Tika", func() { Expect(doc.Content).To(Equal("body test stop words!!!")) }) - It("keeps the audio facet when an embedded resource follows", func() { - fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500"}]` + It("keeps the audio facet and captures the cover as preview", func() { + fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500", "tiff:ImageLength": "400"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Size: 1, + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 1, + MimeType: "audio/mpeg", }) Expect(err).ToNot(HaveOccurred()) Expect(doc.Audio).ToNot(BeNil()) Expect(doc.Audio.Title).To(Equal(libregraph.PtrString("Sucker"))) - Expect(doc.Image).ToNot(BeNil()) + // facets describe the container only, the cover surfaces as preview + Expect(doc.Image).To(BeNil()) + Expect(doc.Preview).ToNot(BeNil()) + Expect(doc.Preview.Width).To(Equal(int32(500))) + Expect(doc.Preview.Height).To(Equal(int32(400))) }) It("adds no audio facet to non-audio documents", func() { From 6a6530252cff6fd3039e88cd3539c45831a940b9 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 6 Sep 2026 23:47:35 +0200 Subject: [PATCH 18/18] feat(graph): base the thumbnails on the preview check The driveItem listings pick their thumbnails by mime type, which claims a preview for anything the thumbnailer could render in principle and misses the embedded ones. Use thumbnail.HasPreview instead, which also answers for audio with cover art, and add the source thumbnail with the dimensions that are actually known. The share listings keep matching on the mime type, they carry driveItems without a resource info. --- services/graph/pkg/service/v0/driveitems.go | 1 - services/graph/pkg/service/v0/thumbnails.go | 32 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 9f441d7814..167f8b1b62 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -390,7 +390,6 @@ func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) { if !ok { return } - g.setDriveItemsThumbnails(r, files, res.GetInfos()) render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: files}) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 75d28bd3a7..357647fcf1 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -8,6 +8,7 @@ import ( libregraph "github.com/opencloud-eu/libre-graph-api-go" "github.com/opencloud-eu/reva/v2/pkg/storagespace" + "github.com/opencloud-eu/opencloud/pkg/conversions" "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) @@ -34,17 +35,36 @@ func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceIn } } -// previewThumbnailSet returns nil when the thumbnailer cannot render the resource. +// previewThumbnailSet returns nil when no preview can be produced for the resource. func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet { - if !thumbnail.IsMimeTypeSupported(res.GetMimeType()) { + if !thumbnail.HasPreview(res) { return nil } - return thumbnailSetFor(baseURL, storagespace.FormatResourceID(res.GetId())) + + itemID := storagespace.FormatResourceID(res.GetId()) + set := thumbnailSetFor(baseURL, itemID) + // only the source carries exact dimensions, the boxes above are requests + if w, h := previewSourceDimensions(res); w > 0 && h > 0 { + url := fmt.Sprintf("%s&x=%d&y=%d", previewBaseURL(baseURL, itemID), w, h) + set.Source = &libregraph.Thumbnail{Url: &url, Width: &w, Height: &h} + } + return set +} + +// previewSourceDimensions: audio cover from oc.preview, images from the image facet. +func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { + if w, h := thumbnail.PreviewDimensions(res); w > 0 && h > 0 { + return w, h + } + meta := res.GetArbitraryMetadata().GetMetadata() + w := conversions.StringToInt32(meta["libre.graph.image.width"], 0) + h := conversions.StringToInt32(meta["libre.graph.image.height"], 0) + return w, h } // thumbnailSetFor builds the urls of the WebDAV preview endpoint. func thumbnailSetFor(baseURL, itemID string) *libregraph.ThumbnailSet { - base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, itemID) + base := previewBaseURL(baseURL, itemID) return &libregraph.ThumbnailSet{ Small: previewThumbnail(base, thumbnailBoxSmall), Medium: previewThumbnail(base, thumbnailBoxMedium), @@ -52,6 +72,10 @@ func thumbnailSetFor(baseURL, itemID string) *libregraph.ThumbnailSet { } } +func previewBaseURL(baseURL, itemID string) string { + return fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, itemID) +} + func previewThumbnail(base string, box int32) *libregraph.Thumbnail { url := fmt.Sprintf("%s&x=%d&y=%d", base, box, box) return &libregraph.Thumbnail{Url: &url}