From 0e4a24b9e826fdb98d2ade7917e750224d718fd8 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 30 Aug 2026 14:04:49 +0200 Subject: [PATCH 01/18] feat(webdav): thumbnails through a Tika server Every file the built-in converters cannot render goes to /unpack/thumbnail (Tika 4.1) and comes back with the image Tika marks as its thumbnail. --- services/webdav/README.md | 2 + services/webdav/pkg/config/config.go | 4 +- services/webdav/pkg/preprocessor/errors.go | 1 + .../webdav/pkg/preprocessor/preprocessor.go | 44 +++++-- services/webdav/pkg/preprocessor/tika.go | 99 +++++++++++++++ services/webdav/pkg/preprocessor/tika_test.go | 114 ++++++++++++++++++ services/webdav/pkg/service/v0/service.go | 5 +- .../pkg/service/v0/thumbnail_errors_test.go | 39 ++++++ services/webdav/pkg/thumbnail/tika.go | 58 +++++++++ services/webdav/pkg/thumbnail/tika_test.go | 37 ++++++ .../pkg/thumbnail/workflow/format_test.go | 18 +++ .../webdav/pkg/thumbnail/workflow/workflow.go | 105 ++++++++++++++-- 12 files changed, 503 insertions(+), 23 deletions(-) create mode 100644 services/webdav/pkg/preprocessor/tika.go create mode 100644 services/webdav/pkg/preprocessor/tika_test.go create mode 100644 services/webdav/pkg/service/v0/thumbnail_errors_test.go create mode 100644 services/webdav/pkg/thumbnail/tika.go create mode 100644 services/webdav/pkg/thumbnail/tika_test.go create mode 100644 services/webdav/pkg/thumbnail/workflow/format_test.go diff --git a/services/webdav/README.md b/services/webdav/README.md index 22ac474844..9c59102b5c 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -10,6 +10,8 @@ Currently, the webdav service handles request for two functionalities, which are The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints. +When `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set, an Apache Tika server (4.1 or newer) supplies the preview image for documents, raw photos, audio cover art and other types. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. + Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. A backend nobody can serve, an unknown name or an `s3` backend without bucket, region and credentials, fails at startup instead of serving without a cache, as long as previews are enabled. A service that is not AWS needs `WEBDAV_THUMBNAIL_CACHE_S3_ENDPOINT` too, and it is always addressed over https. The `memory` backend is bounded and drops its oldest entries; `none` disables caching on purpose. #### Thumbnail Query String Parameters diff --git a/services/webdav/pkg/config/config.go b/services/webdav/pkg/config/config.go index 9b7b6d09e3..887a10b32a 100644 --- a/services/webdav/pkg/config/config.go +++ b/services/webdav/pkg/config/config.go @@ -47,7 +47,9 @@ type Config struct { ThumbnailTypeMapping []string `yaml:"thumbnail_type_mapping" env:"OC_THUMBNAIL_TYPE_MAPPING;WEBDAV_THUMBNAIL_TYPE_MAPPING" desc:"The type a thumbnail is returned in, per type of the image it is made from, as mime:mime entries, e.g. image/vnd.microsoft.icon:image/png. The target is asked for by the name WEBDAV_THUMBNAIL_GENERATOR_FORMATS gives it. A type that is not listed becomes a JPEG; a target this service has no format name for is asked for as a JPEG as well." introductionVersion:"%%NEXT%%"` // the yaml key stays unprefixed, a config file written against it has to keep working - ThumbnailFontMapFile string `yaml:"font_map_file" env:"THUMBNAILS_TXT_FONTMAP_FILE;WEBDAV_THUMBNAIL_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"%%NEXT%%"` + ThumbnailFontMapFile string `yaml:"font_map_file" env:"THUMBNAILS_TXT_FONTMAP_FILE;WEBDAV_THUMBNAIL_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"%%NEXT%%"` + TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;WEBDAV_TIKA_URL" desc:"URL of an Apache Tika server. When set, thumbnails are generated for documents, raw photos, audio cover art and other formats Tika can extract a preview from." introductionVersion:"%%NEXT%%"` + TikaThumbnailMimeTypes []string `yaml:"tika_thumbnail_mime_types" env:"OC_TIKA_THUMBNAIL_MIME_TYPES;WEBDAV_TIKA_THUMBNAIL_MIME_TYPES" desc:"Restrict thumbnail generation via Tika to these mime types. Empty means all types that are not handled directly. An entry can map a type to the one Tika knows the format by, e.g. image/x-raw-samsung:image/x-samsung-srw." introductionVersion:"%%NEXT%%"` Context context.Context `yaml:"-"` } diff --git a/services/webdav/pkg/preprocessor/errors.go b/services/webdav/pkg/preprocessor/errors.go index f8c22cfcc4..a0db070da5 100644 --- a/services/webdav/pkg/preprocessor/errors.go +++ b/services/webdav/pkg/preprocessor/errors.go @@ -7,4 +7,5 @@ var ( ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file") ErrNoConverterForExtractedImageFromAudioFile = errors.New("preprocessor: could not find converter for image extracted from audio file") ErrNoConverterForExtractedImageFromGgpFile = errors.New("preprocessor: could not find converter for image extracted from ggp file") + ErrNoThumbnail = errors.New("preprocessor: the document has no thumbnail") ) diff --git a/services/webdav/pkg/preprocessor/preprocessor.go b/services/webdav/pkg/preprocessor/preprocessor.go index 88f70e22a2..89108b0955 100644 --- a/services/webdav/pkg/preprocessor/preprocessor.go +++ b/services/webdav/pkg/preprocessor/preprocessor.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" "image" "image/draw" "image/png" @@ -36,12 +37,13 @@ type Image struct { Data []byte } -// GgsDecoder is a converter for the geogebra slides file +// GgsDecoder is a converter for the geogebra slides file. +// +// Deprecated: Tika provides the thumbnail; kept for setups without Tika. type GgsDecoder struct { thumbnailpath string - // maxSize bounds the extracted image, because a deflated entry expands by - // orders of magnitude and the container's own size says nothing about it. + // a deflated entry expands by orders of magnitude, so its own size says nothing maxSize uint64 } @@ -87,7 +89,9 @@ func (g GgsDecoder) Convert(r io.Reader) (*Image, error) { return nil, errors.Errorf("%s not found", g.thumbnailpath) } -// AudioDecoder is a converter for the audio file +// AudioDecoder is a converter for the audio file. +// +// Deprecated: Tika provides the cover art; kept for setups without Tika. type AudioDecoder struct{} // Convert reads the audio file and extracts the thumbnail image from the id3 tag. @@ -226,7 +230,9 @@ type GGPStruct struct { } } -// GgpDecoder is a converter for the geogebra pinboard file +// GgpDecoder is a converter for the geogebra pinboard file. +// +// Deprecated: see GgsDecoder. type GgpDecoder struct{} // Convert reads the ggp file and returns the first thumbnail image. @@ -355,6 +361,17 @@ type Options struct { FontMapFile string FontFace *opentype.FaceOptions MaxImageSize uint64 + Tika thumbnail.Tika + Filename string +} + +// tikaOr prefers a configured Tika server, else the fallback +func tikaOr(opts Options, mimeType string, fallback FileConverter) FileConverter { + if !opts.Tika.Supports(mimeType) { + return fallback + } + + return TikaThumbnail{tikaURL: opts.Tika.URL, filename: opts.Filename, contentType: opts.Tika.ContentType(mimeType)} } // ForType returns the converter for the specified mimeType @@ -383,19 +400,26 @@ func ForType(mimeType string, opts Options) FileConverter { fontLoader: fontLoader, } case "application/vnd.geogebra.slides": - return GgsDecoder{thumbnailpath: "_slide0/geogebra_thumbnail.png", maxSize: maxExtractedSize(opts)} + return tikaOr(opts, mimeType, GgsDecoder{thumbnailpath: "_slide0/geogebra_thumbnail.png", maxSize: maxExtractedSize(opts)}) case "application/vnd.geogebra.pinboard": - return GgpDecoder{} + return tikaOr(opts, mimeType, GgpDecoder{}) case "audio/flac": fallthrough case "audio/mpeg": fallthrough case "audio/ogg": - return AudioDecoder{} + return tikaOr(opts, mimeType, AudioDecoder{}) default: // webdav decodes no image: an image/* source is streamed to the // generator untouched, so a type without a converter here is one - // nobody can make a preview of. - return nil + // only Tika can make a preview of. + return tikaOr(opts, mimeType, nil) } } + +// Encoded is an image a converter hands on as it found it. The type decides +// the format the thumbnail is written in. +type Encoded struct { + ContentType string + Data []byte +} diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go new file mode 100644 index 0000000000..781d2fec2c --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika.go @@ -0,0 +1,99 @@ +package preprocessor + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// maxTikaResponse bounds what is read back from Tika +const maxTikaResponse = 100 * 1024 * 1024 + +var tikaHTTPClient = &http.Client{Timeout: 60 * time.Second} + +// TikaThumbnail asks a Tika server for the document's thumbnail. +type TikaThumbnail struct { + tikaURL string + // detection hints + filename string + contentType string +} + +// Convert hands the thumbnail on undecoded: the generator reads formats webdav +// cannot, an .ico icon group out of a PE binary for one. +func (t TikaThumbnail) Convert(r io.Reader) (*Image, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + + contentType, img, err := tikaThumbnail(t.tikaURL, t.filename, t.contentType, data) + if err != nil { + return nil, err + } + + return &Image{ContentType: contentType, Data: img}, nil +} + +// tikaThumbnail returns the thumbnail Tika picks (/unpack/thumbnail, Tika >= 4.1) and its content type. +func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, []byte, error) { + if contentType == "" { + contentType = "application/octet-stream" + } + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/thumbnail?renderThumbnails=true", bytes.NewReader(data)) + if err != nil { + return "", nil, err + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Accept", "application/json") + if filename != "" { + req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + } + + resp, err := tikaHTTPClient.Do(req) + if err != nil { + return "", nil, fmt.Errorf("tika request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return "", nil, ErrNoThumbnail + default: + return "", nil, fmt.Errorf("tika thumbnail returned %s", resp.Status) + } + + var body struct { + Metadata map[string]any `json:"metadata"` + Image string `json:"image"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxTikaResponse)).Decode(&body); err != nil { + return "", nil, fmt.Errorf("tika thumbnail response: %w", err) + } + img, err := base64.StdEncoding.DecodeString(body.Image) + if err != nil || len(img) == 0 { + return "", nil, fmt.Errorf("tika thumbnail response: no image") + } + return metadataString(body.Metadata, "Content-Type"), img, nil +} + +// metadataString reads a key; values are strings or lists. +func metadataString(meta map[string]any, key string) string { + switch v := meta[key].(type) { + case string: + return v + case []any: + if len(v) > 0 { + if s, ok := v[0].(string); ok { + return s + } + } + } + return "" +} diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go new file mode 100644 index 0000000000..db2ca37378 --- /dev/null +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -0,0 +1,114 @@ +package preprocessor + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +func pngBytes() []byte { + img := image.NewRGBA(image.Rect(0, 0, 4, 3)) + img.Set(1, 1, color.RGBA{R: 255, A: 255}) + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return buf.Bytes() +} + +var _ = Describe("TikaThumbnail", func() { + var ( + server *httptest.Server + requests []*http.Request + thumbnail func(w http.ResponseWriter, r *http.Request) + ) + + BeforeEach(func() { + requests = nil + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r) + if r.URL.Path != "/unpack/thumbnail" { + w.WriteHeader(http.StatusNotFound) + return + } + thumbnail(w, r) + })) + }) + + AfterEach(func() { + server.Close() + }) + + It("takes the thumbnail Tika picks, with its metadata, from one request", func() { + thumbnail = func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodPut)) + Expect(r.URL.Query().Get("renderThumbnails")).To(Equal("true")) + Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring(`filename="shot.nef"`)) + Expect(r.Header.Get("Content-Type")).To(Equal("image/x-nikon-nef"), "the file's type travels as the detection hint") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "metadata": map[string]any{"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"}, + "image": base64.StdEncoding.EncodeToString(pngBytes()), + }) + } + + img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil()) + Expect(requests).To(HaveLen(1)) + }) + + It("reports a document without a thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip"))) + Expect(err).To(MatchError(ErrNoThumbnail)) + }) + + It("reports a failing server", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("500"))) + }) + + It("reports a Tika without the endpoint", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ContainSubstring("404"))) + }) +}) + +var _ = Describe("ForType with a Tika server", func() { + It("routes everything Tika is asked for to Tika, text and gif stay native", func() { + opts := Options{Tika: thumbnail.NewTika("http://tika:9998", nil), Filename: "song.mp3"} + Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("image/x-nikon-nef", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("application/vnd.geogebra.slides", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("text/plain", opts)).To(BeAssignableToTypeOf(TxtToImageConverter{})) + }) + + It("keeps the in-process converters without a Tika server", func() { + Expect(ForType("audio/mpeg", Options{})).To(BeAssignableToTypeOf(AudioDecoder{})) + Expect(ForType("application/vnd.geogebra.slides", Options{})).To(BeAssignableToTypeOf(GgsDecoder{})) + Expect(ForType("image/x-nikon-nef", Options{})).To(BeNil(), "an image reaches the generator undecoded") + }) + + It("follows a configured list and hands the mapped type to Tika", func() { + opts := Options{Tika: thumbnail.NewTika("http://tika:9998", []string{"application/pdf", "image/x-raw-samsung:image/x-samsung-srw"})} + Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) + Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(AudioDecoder{}), "not in the configured list") + Expect(ForType("image/x-raw-samsung", opts).(TikaThumbnail).contentType).To(Equal("image/x-samsung-srw")) + }) +}) diff --git a/services/webdav/pkg/service/v0/service.go b/services/webdav/pkg/service/v0/service.go index a992753a5f..d26e41570a 100644 --- a/services/webdav/pkg/service/v0/service.go +++ b/services/webdav/pkg/service/v0/service.go @@ -212,6 +212,7 @@ func newThumbnailWorkflow(conf *config.Config, sourceTypes thumbnail.SourceTypes workflow.WithMaxInputSize(maxInputSize), workflow.WithSizes(sizes), workflow.WithSourceTypes(sourceTypes), + workflow.WithTika(thumbnail.NewTika(conf.TikaURL, conf.TikaThumbnailMimeTypes)), workflow.WithFormats(formats), workflow.WithTypeMapping(typeMapping), workflow.WithWebdavNamespace(conf.WebdavNamespace), @@ -421,7 +422,6 @@ func setAspectIgnoredHeader(w http.ResponseWriter, ignored bool) { // was attempted, for the log line. func (g Webdav) handleWorkflowError(w http.ResponseWriter, r *http.Request, err error, tr *requests.ThumbnailRequest, logger log.Logger, step string) { var busy *workflow.GeneratorBusyError - logger = log.Logger{Logger: logger.With().Str("step", step).Logger()} switch { @@ -444,6 +444,9 @@ func (g Webdav) handleWorkflowError(w http.ResponseWriter, r *http.Request, err case errors.Is(err, workflow.ErrNotFound): logger.Debug().Err(err).Msg("thumbnail source could not be located") renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) + case errors.Is(err, workflow.ErrNoThumbnail): + logger.Debug().Err(err).Msg("the file carries no thumbnail") + renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) case errors.Is(err, workflow.ErrUnsupportedFileType): logger.Debug().Err(err).Msg("requested for an unsupported file type") renderError(w, r, errNotFound(notFoundMsg(tr.Filename))) diff --git a/services/webdav/pkg/service/v0/thumbnail_errors_test.go b/services/webdav/pkg/service/v0/thumbnail_errors_test.go new file mode 100644 index 0000000000..cc79fa72b5 --- /dev/null +++ b/services/webdav/pkg/service/v0/thumbnail_errors_test.go @@ -0,0 +1,39 @@ +package svc + +import ( + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/dav/requests" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail/workflow" +) + +var _ = Describe("thumbnail error mapping", func() { + var ( + g Webdav + tr *requests.ThumbnailRequest + logger log.Logger + ) + + BeforeEach(func() { + logger = log.NopLogger() + g = Webdav{log: logger} + tr = &requests.ThumbnailRequest{Filename: "song.mp3"} + }) + + DescribeTable("a file without a thumbnail is not a server error", + func(step string) { + // clients cache a 404 as "no preview"; a 500 makes them retry forever + rr := httptest.NewRecorder() + g.handleWorkflowError(rr, httptest.NewRequest(http.MethodGet, "/song.mp3", nil), workflow.ErrNoThumbnail, tr, logger, step) + + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }, + Entry("on GET", "thumbnail"), + Entry("on HEAD", "thumbnail head check"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/tika.go b/services/webdav/pkg/thumbnail/tika.go new file mode 100644 index 0000000000..d744cb501a --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika.go @@ -0,0 +1,58 @@ +package thumbnail + +import ( + "mime" + "strings" +) + +// Tika is the configured Tika server: its URL and the types it is asked for. +// It only decides which file goes there, the call lives in the preprocessor. +// By default every non-image file is sent and Tika decides. +type Tika struct { + URL string + // our mime type -> Tika's, "" when equal; "*" means every type + mimeTypes map[string]string +} + +// NewTika parses "mime[:tika-mime]" entries; empty means every type. +func NewTika(url string, mimeTypes []string) Tika { + t := Tika{URL: url, mimeTypes: map[string]string{}} + for _, entry := range mimeTypes { + ours, theirs, _ := strings.Cut(strings.ToLower(strings.TrimSpace(entry)), ":") + if ours = strings.TrimSpace(ours); ours != "" { + t.mimeTypes[ours] = strings.TrimSpace(theirs) + } + } + if len(t.mimeTypes) == 0 { + t.mimeTypes["*"] = "" + } + return t +} + +// Supports reports whether the type is sent to Tika for its thumbnail. +func (t Tika) Supports(mimeType string) bool { + if t.URL == "" { + return false + } + m, _, err := mime.ParseMediaType(mimeType) + if err != nil || m == "httpd/unix-directory" { + return false + } + if _, ok := t.mimeTypes[m]; ok { + return true + } + _, all := t.mimeTypes["*"] + return all +} + +// ContentType is the type Tika is told, mapped where configured. +func (t Tika) ContentType(mimeType string) string { + m, _, err := mime.ParseMediaType(mimeType) + if err != nil { + return "" + } + if theirs := t.mimeTypes[m]; theirs != "" { + return theirs + } + return m +} diff --git a/services/webdav/pkg/thumbnail/tika_test.go b/services/webdav/pkg/thumbnail/tika_test.go new file mode 100644 index 0000000000..9b5c2ad23d --- /dev/null +++ b/services/webdav/pkg/thumbnail/tika_test.go @@ -0,0 +1,37 @@ +package thumbnail_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +var _ = Describe("Tika", func() { + none := thumbnail.NewTika("", nil) + all := thumbnail.NewTika("http://tika:9998", nil) + some := thumbnail.NewTika("http://tika:9998", []string{" Application/PDF ", "audio/mpeg", "image/x-raw-samsung:image/x-samsung-srw"}) + + DescribeTable("Supports", + func(tika thumbnail.Tika, mimeType string, want bool) { + Expect(tika.Supports(mimeType)).To(Equal(want)) + }, + Entry("nothing without a server", none, "application/pdf", false), + Entry("everything by default", all, "application/x-anything; charset=binary", true), + Entry("but no directories", all, "httpd/unix-directory", false), + Entry("nor an unparsable type", all, "not a mime", false), + Entry("a listed type", some, "application/pdf", true), + Entry("a listed type with parameters", some, "audio/mpeg; charset=binary", true), + Entry("a mapped type", some, "image/x-raw-samsung", true), + Entry("an unlisted type", some, "application/zip", false), + ) + + DescribeTable("ContentType", + func(tika thumbnail.Tika, mimeType, want string) { + Expect(tika.ContentType(mimeType)).To(Equal(want)) + }, + Entry("maps a type to the one Tika knows", some, "image/x-raw-samsung", "image/x-samsung-srw"), + Entry("passes an unmapped type through, without parameters", some, "application/pdf; charset=binary", "application/pdf"), + Entry("passes everything through by default", all, "audio/mpeg", "audio/mpeg"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/workflow/format_test.go b/services/webdav/pkg/thumbnail/workflow/format_test.go new file mode 100644 index 0000000000..bc7bf6ed4c --- /dev/null +++ b/services/webdav/pkg/thumbnail/workflow/format_test.go @@ -0,0 +1,18 @@ +package workflow + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("thumbnail format", func() { + DescribeTable("typeOfThumbnail reads the type back out of cached bytes", + func(data []byte, contentType string) { + Expect(typeOfThumbnail(data, "fallback")).To(Equal(contentType)) + }, + Entry("png", []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, "image/png"), + Entry("jpeg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"), + Entry("gif", []byte("GIF89a"), "image/gif"), + Entry("anything else keeps the fallback", []byte("whatever"), "fallback"), + ) +}) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index 4f39a04f0f..785890aedb 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -71,6 +71,29 @@ type UserResolver interface { // surface this as HTTP 425 Too Early with a Retry-After header. var ErrFileProcessing = fmt.Errorf("file is processing") +// ErrNoThumbnail is the answer for a file that carries none at all. It is +// cached as an empty entry, so the next request does not ask Tika again. +var ErrNoThumbnail = errors.New("thumbnails: the file has no thumbnail") + +// tikaFormatPlaceholder stands in the cache key for a format only Tika's answer +// reveals, so the entry is found without downloading the file first. +const tikaFormatPlaceholder = "tika" + +// typeOfThumbnail reads the type of bytes that were cached under the +// placeholder, because the key cannot say what they turned out to be. +func typeOfThumbnail(data []byte, fallback string) string { + switch { + case bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G'}): + return "image/png" + case bytes.HasPrefix(data, []byte{0xff, 0xd8}): + return "image/jpeg" + case bytes.HasPrefix(data, []byte("GIF8")): + return "image/gif" + } + + return fallback +} + // ErrImageTooLarge is returned when the input image exceeds the configured // maximum width/height. Callers should surface this as HTTP 403 Forbidden, // matching the legacy thumbnail service error message. @@ -142,6 +165,7 @@ type ThumbnailWorkflow struct { sizes *thumbnail.Sizes typeMapping thumbnail.TypeMapping sourceTypes thumbnail.SourceTypes + tika thumbnail.Tika formats generator.Formats webdavNS string fontMapFile string @@ -227,6 +251,11 @@ func WithTypeMapping(m thumbnail.TypeMapping) Option { return func(w *ThumbnailWorkflow) { w.typeMapping = m } } +// WithTika sets the Tika server that thumbnails what webdav cannot read itself. +func WithTika(t thumbnail.Tika) Option { + return func(w *ThumbnailWorkflow) { w.tika = t } +} + // WithFontMapFile sets the font map file used for text thumbnail rendering. func WithFontMapFile(file string) Option { return func(w *ThumbnailWorkflow) { w.fontMapFile = file } @@ -311,7 +340,7 @@ func (w *ThumbnailWorkflow) statSource(ctx context.Context, ref *providerv1beta1 return nil, ErrPermissionDenied } - if !w.sourceTypes.Supports(info.GetMimeType()) { + if !w.supportsMimeType(info.GetMimeType()) { return nil, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -419,13 +448,14 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R mimeType := info.GetMimeType() // The thumbnail is returned in the type the source type maps to, asked for by - // the name the generator's filter uses. A type nobody mapped, and one whose - // target the generator cannot write, becomes a JPEG. - outputType, outputFormat := "image/jpeg", "jpg" - if target := w.typeMapping.TargetFor(mimeType); target != "" { - if format := w.formats.For(target); format != "" { - outputType, outputFormat = target, format - } + // the name the generator's filter uses. + outputType, outputFormat := w.outputFor(mimeType) + + // What Tika answers with is only known once it has answered, so the entry is + // keyed under a placeholder until then. + fromTika := !strings.HasPrefix(mimeType, "image/") && w.tika.Supports(mimeType) + if fromTika { + outputFormat = tikaFormatPlaceholder } // The operation is resolved up front so it can be part of the cache key: a @@ -452,6 +482,15 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R if w.cache != nil { if cached, err := w.cache.Get(cacheKey); err == nil { + // an empty entry is a file we know carries no thumbnail + if len(cached) == 0 { + return Thumbnail{}, ErrNoThumbnail + } + if fromTika { + // the format was not known when the entry was written + return Thumbnail{Data: cached, ContentType: typeOfThumbnail(cached, outputType), AspectIgnored: aIgnored}, nil + } + return Thumbnail{Data: cached, ContentType: outputType, AspectIgnored: aIgnored}, nil } } @@ -460,19 +499,35 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R // generator fill that box. Only fit-in suppresses upscaling (noUpscale); for // fill/thumbnail/resize the generator may enlarge a small source (e.g. a text // file rendered at a fixed canvas) to fill the snapped box, matching main. - genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputFormat, noUpscale) - // Produce the source image to send to the generator, never decoding an image // in webdav: real images (incl. gif) are streamed straight through undecoded; // non-image sources are converted to image bytes here so the generator always // receives an image. imgStream, srcType, cleanup, err := w.sourceImage(ctx, ref, auth, mimeType, tr.Filename, logger) if err != nil { + if errors.Is(err, ErrNoThumbnail) { + // a file that carries no thumbnail is an answer, not a failure, and + // the empty entry keeps the next request from asking again + logger.Debug().Err(err).Msg("no source image for thumbnail") + if w.cache != nil { + _ = w.cache.Put(cacheKey, nil) + } + + return Thumbnail{}, err + } + logger.Error().Err(err).Msg("could not obtain source image for thumbnail") + return Thumbnail{}, err } defer cleanup() + if fromTika { + // only now is the type known that Tika answered with + outputType, outputFormat = w.outputFor(srcType) + } + + genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputFormat, noUpscale) thumbBytes, thumbType, err := w.postToGenerator(ctx, genURL, imgStream, tr.Filename, srcType) if err != nil { logger.Error().Err(err).Msg("could not generate thumbnail") @@ -525,13 +580,21 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return nil, "", nil, fmt.Errorf("read source: %w", err) } - converter := preprocessor.ForType(mimeType, preprocessor.Options{FontMapFile: w.fontMapFile, MaxImageSize: w.maxInputSize}) + converter := preprocessor.ForType(mimeType, preprocessor.Options{ + FontMapFile: w.fontMapFile, + MaxImageSize: w.maxInputSize, + Tika: w.tika, + Filename: filename, + }) if converter == nil { logger.Debug().Str("mimetype", mimeType).Msg("no converter for this type") return nil, "", nil, fmt.Errorf("%w: no converter for %s", ErrNotFound, mimeType) } img, err := converter.Convert(bytes.NewReader(fileBytes)) + if errors.Is(err, preprocessor.ErrNoThumbnail) { + return nil, "", nil, ErrNoThumbnail + } if err != nil || img == nil { logger.Debug().Err(err).Msg("could not convert file to image") return nil, "", nil, fmt.Errorf("%w: could not get image", ErrNotFound) @@ -540,6 +603,26 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return bytes.NewReader(img.Data), img.ContentType, func() {}, nil } +// supportsMimeType reports whether a preview is attempted for the type: the +// announced source types plus whatever a configured Tika takes. +func (w *ThumbnailWorkflow) supportsMimeType(mimeType string) bool { + return w.sourceTypes.Supports(mimeType) || w.tika.Supports(mimeType) +} + +// outputFor is the type a thumbnail of this source is written in and the name +// the generator's format filter has for it. A type nobody mapped, and one whose +// target the generator cannot write, becomes a JPEG. +func (w *ThumbnailWorkflow) outputFor(mimeType string) (outputType, outputFormat string) { + outputType, outputFormat = "image/jpeg", "jpg" + if target := w.typeMapping.TargetFor(mimeType); target != "" { + if format := w.formats.For(target); format != "" { + outputType, outputFormat = target, format + } + } + + return outputType, outputFormat +} + // matchOperation picks how to fit within the box webdav already snapped. A // processor the client named wins, "thumbnail" and "fit" being legacy aliases for // fill and fit-in; without one, an unconstrained axis, then "a", then the source From 652ae0e92d3bd202e9ebd79d04b41b61b219610e Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 30 Aug 2026 14:04:49 +0200 Subject: [PATCH 02/18] chore(search): read the shared OC_TIKA_URL --- services/search/README.md | 2 +- services/search/pkg/config/content.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/search/README.md b/services/search/README.md index d0f650e528..a5e178d557 100644 --- a/services/search/README.md +++ b/services/search/README.md @@ -107,7 +107,7 @@ As soon as Tika is installed and configured, the search service needs to be told The following settings must be set: * `SEARCH_EXTRACTOR_TYPE=tika` -* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` +* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` (or the shared `OC_TIKA_URL`, which the webdav service reads for thumbnails too) Additionally, the following optional settings can be set: diff --git a/services/search/pkg/config/content.go b/services/search/pkg/config/content.go index 515db03b63..bd9baa7a82 100644 --- a/services/search/pkg/config/content.go +++ b/services/search/pkg/config/content.go @@ -9,6 +9,6 @@ type Extractor struct { // ExtractorTika configures the Tika extractor type ExtractorTika struct { - TikaURL string `yaml:"tika_url" env:"SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"` + TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"` CleanStopWords bool `yaml:"clean_stop_words" env:"SEARCH_EXTRACTOR_TIKA_CLEAN_STOP_WORDS" desc:"Defines if stop words should be cleaned or not. See the documentation for more details." introductionVersion:"1.0.0"` } From 75293859a8bdb3dfa9fbf79443133b56c7938959 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Sun, 6 Sep 2026 21:46:42 +0200 Subject: [PATCH 03/18] feat(webdav): take the thumbnail tika marks, and the rendering of a metafile --- services/webdav/pkg/preprocessor/tika.go | 143 ++++++++++++++++-- services/webdav/pkg/preprocessor/tika_test.go | 118 +++++++++++++-- 2 files changed, 238 insertions(+), 23 deletions(-) diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go index 781d2fec2c..24b4e9990f 100644 --- a/services/webdav/pkg/preprocessor/tika.go +++ b/services/webdav/pkg/preprocessor/tika.go @@ -1,8 +1,8 @@ package preprocessor import ( + "archive/zip" "bytes" - "encoding/base64" "encoding/json" "fmt" "io" @@ -40,17 +40,20 @@ func (t TikaThumbnail) Convert(r io.Reader) (*Image, error) { return &Image{ContentType: contentType, Data: img}, nil } -// tikaThumbnail returns the thumbnail Tika picks (/unpack/thumbnail, Tika >= 4.1) and its content type. +// tikaThumbnail returns the thumbnail Tika found in the document and its content +// type. Tika 4.1 marks it as a THUMBNAIL embedded document, so /unpack/all is +// enough: no preset, no endpoint of our own. func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, []byte, error) { if contentType == "" { contentType = "application/octet-stream" } - req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/thumbnail?renderThumbnails=true", bytes.NewReader(data)) + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/all", bytes.NewReader(data)) if err != nil { return "", nil, err } req.Header.Set("Content-Type", contentType) - req.Header.Set("Accept", "application/json") + req.Header.Set("Accept", "application/zip") + // the extension is what routes a raw image to its parser if filename != "" { req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) } @@ -66,21 +69,135 @@ func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, case http.StatusNoContent: return "", nil, ErrNoThumbnail default: - return "", nil, fmt.Errorf("tika thumbnail returned %s", resp.Status) + return "", nil, fmt.Errorf("tika unpack returned %s", resp.Status) } - var body struct { - Metadata map[string]any `json:"metadata"` - Image string `json:"image"` + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTikaResponse)) + if err != nil { + return "", nil, fmt.Errorf("tika unpack response: %w", err) + } + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + return "", nil, fmt.Errorf("tika unpack response: %w", err) + } + return thumbnailFromUnpack(zr) +} + +// vectorThumbnailTypes are thumbnail types nothing here can decode. Office +// documents carry their preview as a metafile, so the THUMBNAIL entry is a +// vector and the usable image is its rendering. +var vectorThumbnailTypes = map[string]struct{}{ + "image/emf": {}, + "image/wmf": {}, + "image/x-emf": {}, + "image/x-wmf": {}, + "image/svg+xml": {}, +} + +// unpacked is one entry of an /unpack/all response: the bytes and the metadata +// are separate zip entries, "1.jpg" alongside "1.jpg.metadata.json". +type unpacked struct { + name string + kind string + mimeType string + idPath string +} + +// thumbnailFromUnpack picks the image to use out of an unpack response. Tika +// marks the document's preview as THUMBNAIL. For office documents that preview +// is a metafile, and the raster we want is the RENDERING nested under it, which +// tika only emits when its emf/wmf parser has renderImage turned on. +func thumbnailFromUnpack(zr *zip.Reader) (string, []byte, error) { + var entries []unpacked + for _, f := range zr.File { + if !strings.HasSuffix(f.Name, ".metadata.json") { + continue + } + meta, err := readZipJSON(f) + if err != nil { + continue + } + entries = append(entries, unpacked{ + name: strings.TrimSuffix(f.Name, ".metadata.json"), + kind: metadataString(meta, "tk:embedded-resource-type"), + mimeType: metadataString(meta, "Content-Type"), + idPath: metadataString(meta, "tk:embedded-id-path"), + }) + } + + for _, e := range entries { + if e.kind != "THUMBNAIL" { + continue + } + if _, vector := vectorThumbnailTypes[e.mimeType]; !vector { + return readThumbnail(zr, e) + } + if r, ok := renderingOf(entries, e); ok { + return readThumbnail(zr, r) + } + return "", nil, ErrNoThumbnail + } + + // a document without a preview of its own can still have been rendered, + // a pdf page for instance + for _, e := range entries { + if e.kind == "RENDERING" { + return readThumbnail(zr, e) + } } - if err := json.NewDecoder(io.LimitReader(resp.Body, maxTikaResponse)).Decode(&body); err != nil { - return "", nil, fmt.Errorf("tika thumbnail response: %w", err) + return "", nil, ErrNoThumbnail +} + +// renderingOf finds the rendering tika nested under a thumbnail. The rendering +// is a child of the image it renders, so its id path extends the thumbnail's. +func renderingOf(entries []unpacked, thumbnail unpacked) (unpacked, bool) { + for _, e := range entries { + if e.kind == "RENDERING" && thumbnail.idPath != "" && strings.HasPrefix(e.idPath, thumbnail.idPath+"/") { + return e, true + } } - img, err := base64.StdEncoding.DecodeString(body.Image) + return unpacked{}, false +} + +func readThumbnail(zr *zip.Reader, e unpacked) (string, []byte, error) { + img, err := readZipEntry(zr, e.name) if err != nil || len(img) == 0 { - return "", nil, fmt.Errorf("tika thumbnail response: no image") + return "", nil, ErrNoThumbnail + } + return e.mimeType, img, nil +} + +// readZipJSON decodes a metadata entry. Tika writes either an object or a +// single element list. +func readZipJSON(f *zip.File) (map[string]any, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + + var raw json.RawMessage + if err := json.NewDecoder(io.LimitReader(rc, maxTikaResponse)).Decode(&raw); err != nil { + return nil, err + } + var meta map[string]any + if err := json.Unmarshal(raw, &meta); err == nil { + return meta, nil + } + var list []map[string]any + if err := json.Unmarshal(raw, &list); err != nil || len(list) == 0 { + return nil, fmt.Errorf("unexpected metadata shape") + } + return list[0], nil +} + +func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { + rc, err := zr.Open(name) + if err != nil { + return nil, err } - return metadataString(body.Metadata, "Content-Type"), img, nil + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, maxTikaResponse)) } // metadataString reads a key; values are strings or lists. diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go index db2ca37378..82f9960311 100644 --- a/services/webdav/pkg/preprocessor/tika_test.go +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -1,8 +1,8 @@ package preprocessor import ( + "archive/zip" "bytes" - "encoding/base64" "encoding/json" "image" "image/color" @@ -16,6 +16,25 @@ import ( "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) +// unpackZip builds what /unpack/all returns: the entries and their metadata +// side by side, "1.png" next to "1.png.metadata.json". +func unpackZip(entries map[string][]byte, meta map[string]map[string]any) []byte { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, data := range entries { + w, err := zw.Create(name) + Expect(err).ToNot(HaveOccurred()) + _, err = w.Write(data) + Expect(err).ToNot(HaveOccurred()) + + w, err = zw.Create(name + ".metadata.json") + Expect(err).ToNot(HaveOccurred()) + Expect(json.NewEncoder(w).Encode(meta[name])).To(Succeed()) + } + Expect(zw.Close()).To(Succeed()) + return buf.Bytes() +} + func pngBytes() []byte { img := image.NewRGBA(image.Rect(0, 0, 4, 3)) img.Set(1, 1, color.RGBA{R: 255, A: 255}) @@ -36,7 +55,7 @@ var _ = Describe("TikaThumbnail", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests = append(requests, r) - if r.URL.Path != "/unpack/thumbnail" { + if r.URL.Path != "/unpack/all" { w.WriteHeader(http.StatusNotFound) return } @@ -48,17 +67,19 @@ var _ = Describe("TikaThumbnail", func() { server.Close() }) - It("takes the thumbnail Tika picks, with its metadata, from one request", func() { + It("takes the entry Tika marked as the thumbnail, in one request", func() { thumbnail = func(w http.ResponseWriter, r *http.Request) { Expect(r.Method).To(Equal(http.MethodPut)) - Expect(r.URL.Query().Get("renderThumbnails")).To(Equal("true")) Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring(`filename="shot.nef"`)) Expect(r.Header.Get("Content-Type")).To(Equal("image/x-nikon-nef"), "the file's type travels as the detection hint") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "metadata": map[string]any{"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"}, - "image": base64.StdEncoding.EncodeToString(pngBytes()), - }) + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(unpackZip( + map[string][]byte{"0.nef": []byte("the raw itself"), "1.png": pngBytes()}, + map[string]map[string]any{ + "0.nef": {"Content-Type": "image/x-nikon-nef"}, + "1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"}, + }, + )) } img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) @@ -67,6 +88,83 @@ var _ = Describe("TikaThumbnail", func() { Expect(requests).To(HaveLen(1)) }) + It("ignores embedded images that are not the thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(unpackZip( + map[string][]byte{"1.png": pngBytes()}, + map[string]map[string]any{"1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "INLINE"}}, + )) + } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + Expect(err).To(MatchError(ErrNoThumbnail), "an inline image is not a thumbnail, tika 4.0 marks cover art that way") + }) + + It("takes the rendering when the thumbnail is a metafile", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(unpackZip( + map[string][]byte{"1.wmf": []byte("vector"), "2.png": pngBytes()}, + map[string]map[string]any{ + "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, + "2.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1/2"}, + }, + )) + } + + img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.doc"}.Convert(bytes.NewReader([]byte("ole2"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil(), "office documents carry a metafile preview, the raster is its rendering") + }) + + It("reports no thumbnail when the metafile was not rendered", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(unpackZip( + map[string][]byte{"1.wmf": []byte("vector")}, + map[string]map[string]any{ + "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, + }, + )) + } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2"))) + Expect(err).To(MatchError(ErrNoThumbnail), "rendering is off in tika by default") + }) + + It("does not take a rendering that belongs to another image", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(unpackZip( + map[string][]byte{"1.wmf": []byte("vector"), "3.png": pngBytes()}, + map[string]map[string]any{ + "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, + "3.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/2/3"}, + }, + )) + } + + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2"))) + Expect(err).To(MatchError(ErrNoThumbnail)) + }) + + It("takes a rendering of the document itself", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(unpackZip( + map[string][]byte{"1.png": pngBytes()}, + map[string]map[string]any{ + "1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1"}, + }, + )) + } + + img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.pdf"}.Convert(bytes.NewReader([]byte("pdf"))) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil(), "a pdf has no preview of its own, a rendered page is one") + }) + It("reports a document without a thumbnail", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } @@ -81,7 +179,7 @@ var _ = Describe("TikaThumbnail", func() { Expect(err).To(MatchError(ContainSubstring("500"))) }) - It("reports a Tika without the endpoint", func() { + It("reports a Tika that does not know the route", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) } _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) From a98a739e0cc9af03e578b6c203a4f7060d5bcb50 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 16 Sep 2026 13:08:48 +0000 Subject: [PATCH 04/18] feat(webdav): use Tika's merged thumbnail preset Tika merged the thumbnail catalog preset and ThumbnailUnpackSelector (TIKA-4856): PUT /unpack/preset/thumbnail returns exactly the one raster image a client would show for the document - a stored thumbnail, the rendering of a metafile thumbnail, or the rendered first page. The client-side selection over /unpack/all (embedded-resource-type marks, metafile renderings, id-path matching) moves to the server; the preset zip carries one image without metadata sidecars, so the entry's extension names the type and the bytes break the tie. A 404 now points at the preset activation the server config needs. --- services/webdav/README.md | 2 +- services/webdav/pkg/preprocessor/tika.go | 145 ++++-------------- services/webdav/pkg/preprocessor/tika_test.go | 104 +++---------- 3 files changed, 46 insertions(+), 205 deletions(-) diff --git a/services/webdav/README.md b/services/webdav/README.md index 9c59102b5c..9e664061d6 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -10,7 +10,7 @@ Currently, the webdav service handles request for two functionalities, which are The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints. -When `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set, an Apache Tika server (4.1 or newer) supplies the preview image for documents, raw photos, audio cover art and other types. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. +When `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set, an Apache Tika server (4.1 or newer) supplies the preview image for documents, raw photos, audio cover art and other types. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. The webdav service calls `PUT /unpack/preset/thumbnail`, so the Tika server must activate the `thumbnail` catalog preset in its configuration (`"presets": {"thumbnail": true}`). Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. A backend nobody can serve, an unknown name or an `s3` backend without bucket, region and credentials, fails at startup instead of serving without a cache, as long as previews are enabled. A service that is not AWS needs `WEBDAV_THUMBNAIL_CACHE_S3_ENDPOINT` too, and it is always addressed over https. The `memory` backend is bounded and drops its oldest entries; `none` disables caching on purpose. diff --git a/services/webdav/pkg/preprocessor/tika.go b/services/webdav/pkg/preprocessor/tika.go index 24b4e9990f..58947275fa 100644 --- a/services/webdav/pkg/preprocessor/tika.go +++ b/services/webdav/pkg/preprocessor/tika.go @@ -3,10 +3,11 @@ package preprocessor import ( "archive/zip" "bytes" - "encoding/json" "fmt" "io" + "mime" "net/http" + "path/filepath" "strings" "time" ) @@ -40,14 +41,17 @@ func (t TikaThumbnail) Convert(r io.Reader) (*Image, error) { return &Image{ContentType: contentType, Data: img}, nil } -// tikaThumbnail returns the thumbnail Tika found in the document and its content -// type. Tika 4.1 marks it as a THUMBNAIL embedded document, so /unpack/all is -// enough: no preset, no endpoint of our own. +// tikaThumbnail returns the thumbnail Tika found in the document and its +// content type. The thumbnail catalog preset (Tika 4.1, TIKA-4856) unpacks +// exactly the raster image a client would show for the document — a stored +// thumbnail such as a raw photo's embedded preview or an audio file's cover, +// the rendering of an office document's metafile thumbnail, or the rendered +// first page of a PDF — so the zip carries that one image and nothing else. func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, []byte, error) { if contentType == "" { contentType = "application/octet-stream" } - req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/all", bytes.NewReader(data)) + req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/preset/thumbnail", bytes.NewReader(data)) if err != nil { return "", nil, err } @@ -68,6 +72,9 @@ func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, case http.StatusOK: case http.StatusNoContent: return "", nil, ErrNoThumbnail + case http.StatusNotFound: + // the route exists once the catalog preset is active + return "", nil, fmt.Errorf(`tika unpack returned %s: the "thumbnail" preset needs Tika >= 4.1 and "presets": {"thumbnail": true} in its config`, resp.Status) default: return "", nil, fmt.Errorf("tika unpack returned %s", resp.Status) } @@ -80,115 +87,32 @@ func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, if err != nil { return "", nil, fmt.Errorf("tika unpack response: %w", err) } - return thumbnailFromUnpack(zr) + return thumbnailFromZip(zr) } -// vectorThumbnailTypes are thumbnail types nothing here can decode. Office -// documents carry their preview as a metafile, so the THUMBNAIL entry is a -// vector and the usable image is its rendering. -var vectorThumbnailTypes = map[string]struct{}{ - "image/emf": {}, - "image/wmf": {}, - "image/x-emf": {}, - "image/x-wmf": {}, - "image/svg+xml": {}, -} - -// unpacked is one entry of an /unpack/all response: the bytes and the metadata -// are separate zip entries, "1.jpg" alongside "1.jpg.metadata.json". -type unpacked struct { - name string - kind string - mimeType string - idPath string -} - -// thumbnailFromUnpack picks the image to use out of an unpack response. Tika -// marks the document's preview as THUMBNAIL. For office documents that preview -// is a metafile, and the raster we want is the RENDERING nested under it, which -// tika only emits when its emf/wmf parser has renderImage turned on. -func thumbnailFromUnpack(zr *zip.Reader) (string, []byte, error) { - var entries []unpacked +// thumbnailFromZip takes the one image the preset unpacked. Lenient about the +// shape: the first regular file that is not a metadata sidecar wins. +func thumbnailFromZip(zr *zip.Reader) (string, []byte, error) { for _, f := range zr.File { - if !strings.HasSuffix(f.Name, ".metadata.json") { - continue - } - meta, err := readZipJSON(f) - if err != nil { - continue - } - entries = append(entries, unpacked{ - name: strings.TrimSuffix(f.Name, ".metadata.json"), - kind: metadataString(meta, "tk:embedded-resource-type"), - mimeType: metadataString(meta, "Content-Type"), - idPath: metadataString(meta, "tk:embedded-id-path"), - }) - } - - for _, e := range entries { - if e.kind != "THUMBNAIL" { + if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, ".metadata.json") { continue } - if _, vector := vectorThumbnailTypes[e.mimeType]; !vector { - return readThumbnail(zr, e) - } - if r, ok := renderingOf(entries, e); ok { - return readThumbnail(zr, r) - } - return "", nil, ErrNoThumbnail - } - - // a document without a preview of its own can still have been rendered, - // a pdf page for instance - for _, e := range entries { - if e.kind == "RENDERING" { - return readThumbnail(zr, e) + img, err := readZipEntry(zr, f.Name) + if err != nil || len(img) == 0 { + return "", nil, ErrNoThumbnail } + return entryContentType(f.Name, img), img, nil } return "", nil, ErrNoThumbnail } -// renderingOf finds the rendering tika nested under a thumbnail. The rendering -// is a child of the image it renders, so its id path extends the thumbnail's. -func renderingOf(entries []unpacked, thumbnail unpacked) (unpacked, bool) { - for _, e := range entries { - if e.kind == "RENDERING" && thumbnail.idPath != "" && strings.HasPrefix(e.idPath, thumbnail.idPath+"/") { - return e, true - } +// entryContentType names the image's type. The preset zip carries no metadata +// entries, so the extension decides and the bytes break the tie. +func entryContentType(name string, data []byte) string { + if byExt := mime.TypeByExtension(filepath.Ext(name)); byExt != "" { + return byExt } - return unpacked{}, false -} - -func readThumbnail(zr *zip.Reader, e unpacked) (string, []byte, error) { - img, err := readZipEntry(zr, e.name) - if err != nil || len(img) == 0 { - return "", nil, ErrNoThumbnail - } - return e.mimeType, img, nil -} - -// readZipJSON decodes a metadata entry. Tika writes either an object or a -// single element list. -func readZipJSON(f *zip.File) (map[string]any, error) { - rc, err := f.Open() - if err != nil { - return nil, err - } - defer rc.Close() - - var raw json.RawMessage - if err := json.NewDecoder(io.LimitReader(rc, maxTikaResponse)).Decode(&raw); err != nil { - return nil, err - } - var meta map[string]any - if err := json.Unmarshal(raw, &meta); err == nil { - return meta, nil - } - var list []map[string]any - if err := json.Unmarshal(raw, &list); err != nil || len(list) == 0 { - return nil, fmt.Errorf("unexpected metadata shape") - } - return list[0], nil + return http.DetectContentType(data) } func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { @@ -199,18 +123,3 @@ func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { defer rc.Close() return io.ReadAll(io.LimitReader(rc, maxTikaResponse)) } - -// metadataString reads a key; values are strings or lists. -func metadataString(meta map[string]any, key string) string { - switch v := meta[key].(type) { - case string: - return v - case []any: - if len(v) > 0 { - if s, ok := v[0].(string); ok { - return s - } - } - } - return "" -} diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go index 82f9960311..a316f76978 100644 --- a/services/webdav/pkg/preprocessor/tika_test.go +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -3,7 +3,6 @@ package preprocessor import ( "archive/zip" "bytes" - "encoding/json" "image" "image/color" "image/png" @@ -16,9 +15,9 @@ import ( "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) -// unpackZip builds what /unpack/all returns: the entries and their metadata -// side by side, "1.png" next to "1.png.metadata.json". -func unpackZip(entries map[string][]byte, meta map[string]map[string]any) []byte { +// presetZip builds what /unpack/preset/thumbnail returns: the one image the +// preset selected, and nothing else. +func presetZip(entries map[string][]byte) []byte { var buf bytes.Buffer zw := zip.NewWriter(&buf) for name, data := range entries { @@ -26,10 +25,6 @@ func unpackZip(entries map[string][]byte, meta map[string]map[string]any) []byte Expect(err).ToNot(HaveOccurred()) _, err = w.Write(data) Expect(err).ToNot(HaveOccurred()) - - w, err = zw.Create(name + ".metadata.json") - Expect(err).ToNot(HaveOccurred()) - Expect(json.NewEncoder(w).Encode(meta[name])).To(Succeed()) } Expect(zw.Close()).To(Succeed()) return buf.Bytes() @@ -55,7 +50,7 @@ var _ = Describe("TikaThumbnail", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests = append(requests, r) - if r.URL.Path != "/unpack/all" { + if r.URL.Path != "/unpack/preset/thumbnail" { w.WriteHeader(http.StatusNotFound) return } @@ -67,19 +62,13 @@ var _ = Describe("TikaThumbnail", func() { server.Close() }) - It("takes the entry Tika marked as the thumbnail, in one request", func() { + It("takes the image the preset unpacked, in one request", func() { thumbnail = func(w http.ResponseWriter, r *http.Request) { Expect(r.Method).To(Equal(http.MethodPut)) Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring(`filename="shot.nef"`)) Expect(r.Header.Get("Content-Type")).To(Equal("image/x-nikon-nef"), "the file's type travels as the detection hint") w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"0.nef": []byte("the raw itself"), "1.png": pngBytes()}, - map[string]map[string]any{ - "0.nef": {"Content-Type": "image/x-nikon-nef"}, - "1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"}, - }, - )) + _, _ = w.Write(presetZip(map[string][]byte{"1.png": pngBytes()})) } img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw"))) @@ -88,86 +77,30 @@ var _ = Describe("TikaThumbnail", func() { Expect(requests).To(HaveLen(1)) }) - It("ignores embedded images that are not the thumbnail", func() { + It("decodes an image whose entry name has no telling extension", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.png": pngBytes()}, - map[string]map[string]any{"1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "INLINE"}}, - )) + _, _ = w.Write(presetZip(map[string][]byte{"thumbnail": pngBytes()})) } - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) - Expect(err).To(MatchError(ErrNoThumbnail), "an inline image is not a thumbnail, tika 4.0 marks cover art that way") - }) - - It("takes the rendering when the thumbnail is a metafile", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.wmf": []byte("vector"), "2.png": pngBytes()}, - map[string]map[string]any{ - "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, - "2.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1/2"}, - }, - )) - } - - img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.doc"}.Convert(bytes.NewReader([]byte("ole2"))) + img, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) Expect(err).ToNot(HaveOccurred()) - Expect(img).ToNot(BeNil(), "office documents carry a metafile preview, the raster is its rendering") - }) - - It("reports no thumbnail when the metafile was not rendered", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.wmf": []byte("vector")}, - map[string]map[string]any{ - "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, - }, - )) - } - - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2"))) - Expect(err).To(MatchError(ErrNoThumbnail), "rendering is off in tika by default") + Expect(img).ToNot(BeNil(), "the bytes name the type when the entry does not") }) - It("does not take a rendering that belongs to another image", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.wmf": []byte("vector"), "3.png": pngBytes()}, - map[string]map[string]any{ - "1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"}, - "3.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/2/3"}, - }, - )) - } + It("reports a document without a thumbnail", func() { + thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2"))) + _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip"))) Expect(err).To(MatchError(ErrNoThumbnail)) }) - It("takes a rendering of the document itself", func() { + It("reports an empty zip as no thumbnail", func() { thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/zip") - _, _ = w.Write(unpackZip( - map[string][]byte{"1.png": pngBytes()}, - map[string]map[string]any{ - "1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1"}, - }, - )) + _, _ = w.Write(presetZip(nil)) } - img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.pdf"}.Convert(bytes.NewReader([]byte("pdf"))) - Expect(err).ToNot(HaveOccurred()) - Expect(img).ToNot(BeNil(), "a pdf has no preview of its own, a rendered page is one") - }) - - It("reports a document without a thumbnail", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip"))) Expect(err).To(MatchError(ErrNoThumbnail)) }) @@ -179,11 +112,10 @@ var _ = Describe("TikaThumbnail", func() { Expect(err).To(MatchError(ContainSubstring("500"))) }) - It("reports a Tika that does not know the route", func() { - thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) } - - _, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw"))) + It("points at the preset config when the route is missing", func() { + _, err := TikaThumbnail{tikaURL: server.URL + "/nowhere"}.Convert(bytes.NewReader([]byte("raw"))) Expect(err).To(MatchError(ContainSubstring("404"))) + Expect(err).To(MatchError(ContainSubstring("preset")), "a 404 means the catalog preset is not active") }) }) From 12da21f28a6bec90d3d393e7b49421d022e7b399 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 7 Oct 2026 20:39:18 +0200 Subject: [PATCH 05/18] ci: activate Tika's thumbnail preset wherever Tika runs The preset ships inert in tika-pipes-core, so PUT /unpack/preset/thumbnail answers 404 until a config names it, and the thumbnailer then finds no thumbnail for any document. The acceptance stack and the example deployment now pass a config that activates it, and the deployment reads the shared OC_TIKA_URL so the thumbnailer uses the Tika that is already there. The acceptance image goes to 4.1, the release the preset is in. --- .woodpecker.star | 11 +++++++++++ .../opencloud_full/config/tika/tika-config.json | 12 ++++++++++++ devtools/deployments/opencloud_full/tika.yml | 6 +++++- tests/acceptance/docker/src/tika.yml | 3 +++ tests/config/woodpecker/tika-config.json | 12 ++++++++++++ 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 devtools/deployments/opencloud_full/config/tika/tika-config.json create mode 100644 tests/config/woodpecker/tika-config.json diff --git a/.woodpecker.star b/.woodpecker.star index a7616ea613..6ad1f79914 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -3408,6 +3408,17 @@ def tikaService(): # tika 4 discovers its plugins relative to the image working directory, # the workspace default would leave the pipes fetchers empty "directory": "/opt/tika-server", + # the thumbnail preset ships inert; the config is what activates it + "entrypoint": [ + "java", + "-cp", + "/opt/tika-server/*:/opt/tika-server/lib/*:/tika-extras/*", + "org.apache.tika.server.core.TikaServerCli", + "-h", + "0.0.0.0", + "-c", + "%s/tests/config/woodpecker/tika-config.json" % dirs["base"], + ], "detach": True, }, { "name": "wait-for-tika-service", diff --git a/devtools/deployments/opencloud_full/config/tika/tika-config.json b/devtools/deployments/opencloud_full/config/tika/tika-config.json new file mode 100644 index 0000000000..5e0ded5f0a --- /dev/null +++ b/devtools/deployments/opencloud_full/config/tika/tika-config.json @@ -0,0 +1,12 @@ +{ + // -h and -p still come from the command line; a config file has to carry the block + "server": { + "port": 9998, + "host": "0.0.0.0" + }, + // the thumbnail preset ships inert in tika-pipes-core; activating it is what serves + // PUT /unpack/preset/thumbnail, which the webdav thumbnailer asks for + "presets": { + "thumbnail": true + } +} diff --git a/devtools/deployments/opencloud_full/tika.yml b/devtools/deployments/opencloud_full/tika.yml index 081f2876b5..ea18063b9e 100644 --- a/devtools/deployments/opencloud_full/tika.yml +++ b/devtools/deployments/opencloud_full/tika.yml @@ -3,6 +3,10 @@ services: tika: image: ${TIKA_IMAGE:-apache/tika:latest-full} # release notes: https://tika.apache.org + # the thumbnail preset ships inert; the config is what activates it + command: ['-c', '/tika-config.json'] + volumes: + - ./config/tika/tika-config.json:/tika-config.json networks: opencloud-net: restart: always @@ -13,5 +17,5 @@ services: environment: # fulltext search SEARCH_EXTRACTOR_TYPE: tika - SEARCH_EXTRACTOR_TIKA_TIKA_URL: http://tika:9998 + OC_TIKA_URL: http://tika:9998 FRONTEND_FULL_TEXT_SEARCH_ENABLED: "true" diff --git a/tests/acceptance/docker/src/tika.yml b/tests/acceptance/docker/src/tika.yml index 36bc0a905e..467001dbcc 100644 --- a/tests/acceptance/docker/src/tika.yml +++ b/tests/acceptance/docker/src/tika.yml @@ -6,3 +6,6 @@ services: command: tika:9998 tika: image: apache/tika:4.1.0-full + command: ['-c', '/tika-config.json'] + volumes: + - ../../../config/woodpecker/tika-config.json:/tika-config.json diff --git a/tests/config/woodpecker/tika-config.json b/tests/config/woodpecker/tika-config.json new file mode 100644 index 0000000000..5e0ded5f0a --- /dev/null +++ b/tests/config/woodpecker/tika-config.json @@ -0,0 +1,12 @@ +{ + // -h and -p still come from the command line; a config file has to carry the block + "server": { + "port": 9998, + "host": "0.0.0.0" + }, + // the thumbnail preset ships inert in tika-pipes-core; activating it is what serves + // PUT /unpack/preset/thumbnail, which the webdav thumbnailer asks for + "presets": { + "thumbnail": true + } +} From c086a97aabc0c5807286610e6966e83c9439de9d Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 11:54:28 +0200 Subject: [PATCH 06/18] refactor(webdav): let the preprocessor say what a type needs The cache key is derived from configuration again: a thumbnail is written in the type the file's own type maps to, so the answer no longer depends on what Tika hands back and needs no placeholder in the key. Which types Tika gets is decided in one place now. An image type goes to Tika only when a deployment names it, because the generator behind the URL may read it itself, which also lets a raw photo reach Tika at all. --- services/webdav/README.md | 2 +- .../webdav/pkg/preprocessor/preprocessor.go | 18 ++--- services/webdav/pkg/preprocessor/tika_test.go | 5 +- services/webdav/pkg/thumbnail/sourcetypes.go | 16 ++-- services/webdav/pkg/thumbnail/tika.go | 17 +++++ .../pkg/thumbnail/workflow/format_test.go | 18 ----- .../webdav/pkg/thumbnail/workflow/workflow.go | 74 +++++-------------- 7 files changed, 58 insertions(+), 92 deletions(-) delete mode 100644 services/webdav/pkg/thumbnail/workflow/format_test.go diff --git a/services/webdav/README.md b/services/webdav/README.md index 9e664061d6..0a405dfa1f 100644 --- a/services/webdav/README.md +++ b/services/webdav/README.md @@ -10,7 +10,7 @@ Currently, the webdav service handles request for two functionalities, which are The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints. -When `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set, an Apache Tika server (4.1 or newer) supplies the preview image for documents, raw photos, audio cover art and other types. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. The webdav service calls `PUT /unpack/preset/thumbnail`, so the Tika server must activate the `thumbnail` catalog preset in its configuration (`"presets": {"thumbnail": true}`). Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. +When `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set, an Apache Tika server (4.1 or newer) supplies the preview image for documents, raw photos, audio cover art and other types. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. The webdav service calls `PUT /unpack/preset/thumbnail`, so the Tika server must activate the `thumbnail` catalog preset in its configuration (`"presets": {"thumbnail": true}`). Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated. An image type is only sent to Tika when the list names it, because the generator behind the URL may read it itself, an imagor with ImageMagick reads heic where the built-in one does not. Naming a type also restricts Tika to the named ones, so a deployment that sends raw photos to Tika has to name the document types it wants from Tika as well. The thumbnail is written in the type the **file's** own type maps to (`OC_THUMBNAIL_TYPE_MAPPING`), not in whatever type Tika hands back, so the answer is the same before and after a cache hit. Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. A backend nobody can serve, an unknown name or an `s3` backend without bucket, region and credentials, fails at startup instead of serving without a cache, as long as previews are enabled. A service that is not AWS needs `WEBDAV_THUMBNAIL_CACHE_S3_ENDPOINT` too, and it is always addressed over https. The `memory` backend is bounded and drops its oldest entries; `none` disables caching on purpose. diff --git a/services/webdav/pkg/preprocessor/preprocessor.go b/services/webdav/pkg/preprocessor/preprocessor.go index 89108b0955..41953c9264 100644 --- a/services/webdav/pkg/preprocessor/preprocessor.go +++ b/services/webdav/pkg/preprocessor/preprocessor.go @@ -410,16 +410,14 @@ func ForType(mimeType string, opts Options) FileConverter { case "audio/ogg": return tikaOr(opts, mimeType, AudioDecoder{}) default: - // webdav decodes no image: an image/* source is streamed to the - // generator untouched, so a type without a converter here is one - // only Tika can make a preview of. + // an image reaches the generator as it is: it reads the common types and + // may read more, an imagor with ImageMagick reads heic where we do not. + // OC_TIKA_THUMBNAIL_MIME_TYPES is how a deployment hands an image type + // to Tika instead. + if strings.HasPrefix(mimeType, "image/") && !opts.Tika.ConfiguredFor(mimeType) { + return nil + } + return tikaOr(opts, mimeType, nil) } } - -// Encoded is an image a converter hands on as it found it. The type decides -// the format the thumbnail is written in. -type Encoded struct { - ContentType string - Data []byte -} diff --git a/services/webdav/pkg/preprocessor/tika_test.go b/services/webdav/pkg/preprocessor/tika_test.go index a316f76978..8fa8fe1e9a 100644 --- a/services/webdav/pkg/preprocessor/tika_test.go +++ b/services/webdav/pkg/preprocessor/tika_test.go @@ -123,10 +123,11 @@ var _ = Describe("ForType with a Tika server", func() { It("routes everything Tika is asked for to Tika, text and gif stay native", func() { opts := Options{Tika: thumbnail.NewTika("http://tika:9998", nil), Filename: "song.mp3"} Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) - Expect(ForType("image/x-nikon-nef", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) Expect(ForType("application/vnd.geogebra.slides", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) Expect(ForType("text/plain", opts)).To(BeAssignableToTypeOf(TxtToImageConverter{})) + Expect(ForType("image/png", opts)).To(BeNil(), "the generator reads a png itself") + Expect(ForType("image/x-nikon-nef", opts)).To(BeNil(), "an image type the catch-all does not take, the generator may read it") }) It("keeps the in-process converters without a Tika server", func() { @@ -139,6 +140,6 @@ var _ = Describe("ForType with a Tika server", func() { opts := Options{Tika: thumbnail.NewTika("http://tika:9998", []string{"application/pdf", "image/x-raw-samsung:image/x-samsung-srw"})} Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{})) Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(AudioDecoder{}), "not in the configured list") - Expect(ForType("image/x-raw-samsung", opts).(TikaThumbnail).contentType).To(Equal("image/x-samsung-srw")) + Expect(ForType("image/x-raw-samsung", opts).(TikaThumbnail).contentType).To(Equal("image/x-samsung-srw"), "a named image type does go to Tika") }) }) diff --git a/services/webdav/pkg/thumbnail/sourcetypes.go b/services/webdav/pkg/thumbnail/sourcetypes.go index 7a085e00b7..63d1ba0128 100644 --- a/services/webdav/pkg/thumbnail/sourcetypes.go +++ b/services/webdav/pkg/thumbnail/sourcetypes.go @@ -6,11 +6,10 @@ import ( "strings" ) -// DefaultSourceTypes are the types a preview is announced and attempted for -// without further configuration. The image types are what both resize backends -// read; the others are converted to an image here, by a preprocessor, before a -// generator ever sees them. -var DefaultSourceTypes = []string{ +// GeneratorImageTypes are the image types both resize backends read, so webdav +// hands the file to the generator as it is. Every other type needs something to +// make an image of it first, a preprocessor here or a Tika server. +var GeneratorImageTypes = []string{ "image/png", "image/jpg", "image/jpeg", @@ -19,13 +18,18 @@ var DefaultSourceTypes = []string{ "image/x-ms-bmp", "image/tiff", "image/webp", +} + +// DefaultSourceTypes are the types a preview is announced and attempted for +// without further configuration. +var DefaultSourceTypes = slices.Concat(GeneratorImageTypes, []string{ "text/plain", "audio/flac", "audio/mpeg", "audio/ogg", "application/vnd.geogebra.slides", "application/vnd.geogebra.pinboard", -} +}) // SourceTypes are the types a preview is announced and attempted for: the // defaults plus what a deployment configured, which is also the list the diff --git a/services/webdav/pkg/thumbnail/tika.go b/services/webdav/pkg/thumbnail/tika.go index d744cb501a..27e822c580 100644 --- a/services/webdav/pkg/thumbnail/tika.go +++ b/services/webdav/pkg/thumbnail/tika.go @@ -45,6 +45,23 @@ func (t Tika) Supports(mimeType string) bool { return all } +// ConfiguredFor reports whether OC_TIKA_THUMBNAIL_MIME_TYPES lists this type, +// as opposed to the catch-all taking it. An image is only taken away from the +// generator when the configuration asks for it, because the generator may well +// read the type itself. +func (t Tika) ConfiguredFor(mimeType string) bool { + if t.URL == "" { + return false + } + m, _, err := mime.ParseMediaType(mimeType) + if err != nil { + return false + } + _, named := t.mimeTypes[m] + + return named +} + // ContentType is the type Tika is told, mapped where configured. func (t Tika) ContentType(mimeType string) string { m, _, err := mime.ParseMediaType(mimeType) diff --git a/services/webdav/pkg/thumbnail/workflow/format_test.go b/services/webdav/pkg/thumbnail/workflow/format_test.go deleted file mode 100644 index bc7bf6ed4c..0000000000 --- a/services/webdav/pkg/thumbnail/workflow/format_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package workflow - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("thumbnail format", func() { - DescribeTable("typeOfThumbnail reads the type back out of cached bytes", - func(data []byte, contentType string) { - Expect(typeOfThumbnail(data, "fallback")).To(Equal(contentType)) - }, - Entry("png", []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, "image/png"), - Entry("jpeg", []byte{0xff, 0xd8, 0xff, 0xe0}, "image/jpeg"), - Entry("gif", []byte("GIF89a"), "image/gif"), - Entry("anything else keeps the fallback", []byte("whatever"), "fallback"), - ) -}) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index 785890aedb..f1a747ce90 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -75,25 +75,6 @@ var ErrFileProcessing = fmt.Errorf("file is processing") // cached as an empty entry, so the next request does not ask Tika again. var ErrNoThumbnail = errors.New("thumbnails: the file has no thumbnail") -// tikaFormatPlaceholder stands in the cache key for a format only Tika's answer -// reveals, so the entry is found without downloading the file first. -const tikaFormatPlaceholder = "tika" - -// typeOfThumbnail reads the type of bytes that were cached under the -// placeholder, because the key cannot say what they turned out to be. -func typeOfThumbnail(data []byte, fallback string) string { - switch { - case bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G'}): - return "image/png" - case bytes.HasPrefix(data, []byte{0xff, 0xd8}): - return "image/jpeg" - case bytes.HasPrefix(data, []byte("GIF8")): - return "image/gif" - } - - return fallback -} - // ErrImageTooLarge is returned when the input image exceeds the configured // maximum width/height. Callers should surface this as HTTP 403 Forbidden, // matching the legacy thumbnail service error message. @@ -447,17 +428,14 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R } mimeType := info.GetMimeType() - // The thumbnail is returned in the type the source type maps to, asked for by - // the name the generator's filter uses. + // The thumbnail is returned in the type the source type maps to, asked for + // by the name the generator's filter uses. What an extractor hands on is + // re-encoded to that type, because the request has to decide it: a cache + // hit knows the key, not the bytes. Keeping a png cover a png needs the + // embedded type before the key is built, which is what the preview presence + // work puts into the file's metadata. outputType, outputFormat := w.outputFor(mimeType) - // What Tika answers with is only known once it has answered, so the entry is - // keyed under a placeholder until then. - fromTika := !strings.HasPrefix(mimeType, "image/") && w.tika.Supports(mimeType) - if fromTika { - outputFormat = tikaFormatPlaceholder - } - // The operation is resolved up front so it can be part of the cache key: a // fill and a fit-in request with the same dimensions produce different images // and must not share a cache entry. noUpscale reports whether the generator @@ -486,10 +464,6 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R if len(cached) == 0 { return Thumbnail{}, ErrNoThumbnail } - if fromTika { - // the format was not known when the entry was written - return Thumbnail{Data: cached, ContentType: typeOfThumbnail(cached, outputType), AspectIgnored: aIgnored}, nil - } return Thumbnail{Data: cached, ContentType: outputType, AspectIgnored: aIgnored}, nil } @@ -522,11 +496,6 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R } defer cleanup() - if fromTika { - // only now is the type known that Tika answered with - outputType, outputFormat = w.outputFor(srcType) - } - genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputFormat, noUpscale) thumbBytes, thumbType, err := w.postToGenerator(ctx, genURL, imgStream, tr.Filename, srcType) if err != nil { @@ -561,18 +530,24 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta1.Reference, auth, mimeType, filename string, logger log.Logger) (io.Reader, string, func(), error) { m, _, _ := mime.ParseMediaType(mimeType) - if strings.HasPrefix(m, "image/") { - body, err := w.fileDownloader.DownloadStream(ctx, ref, auth) - if err != nil { - return nil, "", nil, fmt.Errorf("download: %w", err) - } - return body, m, func() { _ = body.Close() }, nil - } + // the preprocessor decides what a type needs. No converter means the file + // is handed to the generator as it is, which is the case for every type it + // reads itself. + converter := preprocessor.ForType(mimeType, preprocessor.Options{ + FontMapFile: w.fontMapFile, + MaxImageSize: w.maxInputSize, + Tika: w.tika, + Filename: filename, + }) body, err := w.fileDownloader.DownloadStream(ctx, ref, auth) if err != nil { return nil, "", nil, fmt.Errorf("download: %w", err) } + + if converter == nil { + return body, m, func() { _ = body.Close() }, nil + } defer body.Close() fileBytes, err := io.ReadAll(body) @@ -580,17 +555,6 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return nil, "", nil, fmt.Errorf("read source: %w", err) } - converter := preprocessor.ForType(mimeType, preprocessor.Options{ - FontMapFile: w.fontMapFile, - MaxImageSize: w.maxInputSize, - Tika: w.tika, - Filename: filename, - }) - if converter == nil { - logger.Debug().Str("mimetype", mimeType).Msg("no converter for this type") - return nil, "", nil, fmt.Errorf("%w: no converter for %s", ErrNotFound, mimeType) - } - img, err := converter.Convert(bytes.NewReader(fileBytes)) if errors.Is(err, preprocessor.ErrNoThumbnail) { return nil, "", nil, ErrNoThumbnail From 132fe9d16a3fab29d9aec95c205d8893239b33f5 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 15 Oct 2026 12:32:36 +0200 Subject: [PATCH 07/18] feat(thumbnails): split preview mime types and add HasPreview Unconditional types follow from the mimetype, embedded ones (audio cover art) need indexed oc.preview dimensions. --- pkg/conversions/strings.go | 10 ++++ services/webdav/pkg/thumbnail/haspreview.go | 55 +++++++++++++++++++ .../webdav/pkg/thumbnail/haspreview_test.go | 46 ++++++++++++++++ services/webdav/pkg/thumbnail/sourcetypes.go | 11 ++++ 4 files changed, 122 insertions(+) create mode 100644 services/webdav/pkg/thumbnail/haspreview.go create mode 100644 services/webdav/pkg/thumbnail/haspreview_test.go diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index 63cc68f2a7..a03b7d963b 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -1,6 +1,7 @@ package conversions import ( + "strconv" "strings" ) @@ -14,3 +15,12 @@ func StringToSliceString(src string, sep string) []string { return parts } + +// StringToInt32 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) + if err != nil { + return fallback + } + return int32(v) +} diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go new file mode 100644 index 0000000000..136bcd9c3b --- /dev/null +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -0,0 +1,55 @@ +package thumbnail + +import ( + 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 +// cover art), written at index time. Their presence signals a preview exists. +const ( + PreviewWidthKey = "oc.preview.width" + PreviewHeightKey = "oc.preview.height" +) + +// EmbeddedSourceTypes carry their preview inside the file, audio cover art for +// one, so whether a preview exists is a property of the file and not of its +// type. The index answers that, through the dimensions it stored. +var EmbeddedSourceTypes = []string{ + "audio/flac", + "audio/mpeg", + "audio/ogg", +} + +// HasPreview reports whether a preview can be produced for the resource: a type +// that always has one says so by itself, a type that carries its preview inside +// the file only when the index saw one. +func (t SourceTypes) HasPreview(md *provider.ResourceInfo) bool { + if md == nil { + return false + } + w, h := PreviewDimensions(md) + + return t.HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0) +} + +// HasPreviewForMimeType is HasPreview for callers that only have the mimetype +// and a presence signal (e.g. search results) rather than a full ResourceInfo. +func (t SourceTypes) HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { + if !t.Supports(mimeType) { + return false + } + if t.carriesPreviewInside(mimeType) { + return hasEmbeddedPreview + } + + return true +} + +// PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). +func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { + meta := md.GetArbitraryMetadata().GetMetadata() + + return conversions.StringToInt32(meta[PreviewWidthKey], 0), conversions.StringToInt32(meta[PreviewHeightKey], 0) +} diff --git a/services/webdav/pkg/thumbnail/haspreview_test.go b/services/webdav/pkg/thumbnail/haspreview_test.go new file mode 100644 index 0000000000..02637da70b --- /dev/null +++ b/services/webdav/pkg/thumbnail/haspreview_test.go @@ -0,0 +1,46 @@ +package thumbnail_test + +import ( + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" +) + +func resource(mimeType string, meta map[string]string) *provider.ResourceInfo { + return &provider.ResourceInfo{ + MimeType: mimeType, + ArbitraryMetadata: &provider.ArbitraryMetadata{Metadata: meta}, + } +} + +var _ = Describe("HasPreview", func() { + types := thumbnail.NewSourceTypes(nil) + + DescribeTable("answers from the type, or from what the index saw", + func(md *provider.ResourceInfo, want bool) { + Expect(types.HasPreview(md)).To(Equal(want)) + }, + Entry("an image always has one", resource("image/png", nil), true), + Entry("a type nobody makes a preview of has none", resource("application/zip", nil), false), + Entry("audio without indexed dimensions has none", resource("audio/mpeg", nil), false), + Entry("audio with indexed dimensions has one", + resource("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + }), true), + Entry("audio with zero dimensions has none", + resource("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "0", + thumbnail.PreviewHeightKey: "0", + }), false), + Entry("no resource at all", nil, false), + ) + + It("answers the same for a caller that only has the mimetype", func() { + Expect(types.HasPreviewForMimeType("audio/mpeg", true)).To(BeTrue()) + Expect(types.HasPreviewForMimeType("audio/mpeg", false)).To(BeFalse()) + Expect(types.HasPreviewForMimeType("image/png", false)).To(BeTrue()) + }) +}) diff --git a/services/webdav/pkg/thumbnail/sourcetypes.go b/services/webdav/pkg/thumbnail/sourcetypes.go index 63d1ba0128..2be2dd5eaf 100644 --- a/services/webdav/pkg/thumbnail/sourcetypes.go +++ b/services/webdav/pkg/thumbnail/sourcetypes.go @@ -54,6 +54,17 @@ func NewSourceTypes(entries []string) SourceTypes { return t } +// carriesPreviewInside reports whether the type only has a preview when the file +// carries one, which is what EmbeddedSourceTypes names. +func (t SourceTypes) carriesPreviewInside(contentType string) bool { + parsed, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + + return slices.Contains(EmbeddedSourceTypes, parsed) +} + // Supports reports whether a preview is made of this type. func (t SourceTypes) Supports(contentType string) bool { parsed, _, err := mime.ParseMediaType(contentType) From 183023b966877a17a6b8b993977c18b5fa9ca8b8 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 15 Oct 2026 12:32:43 +0200 Subject: [PATCH 08/18] feat(search): index embedded cover dimensions as oc.preview Prefers the front cover. Adds the preview field to the search entity. --- .../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 | 22 +- .../pkg/bleve/testdata/mapping.golden.json | 32 + services/search/pkg/content/content.go | 33 +- services/search/pkg/content/tika.go | 2 + services/search/pkg/content/tika_preview.go | 47 ++ .../search/pkg/content/tika_preview_test.go | 49 ++ services/search/pkg/content/tika_test.go | 15 +- .../opensearch/internal/convert/opensearch.go | 3 + .../opensearch/testdata/resource.golden.json | 10 + services/search/pkg/search/service.go | 1 + 13 files changed, 543 insertions(+), 263 deletions(-) create mode 100644 services/search/pkg/content/tika_preview.go create mode 100644 services/search/pkg/content/tika_preview_test.go diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 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 db24bd32b5..5e48993af2 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -307,6 +307,9 @@ }, "livePhoto": { "$ref": "#/definitions/v0LivePhoto" + }, + "preview": { + "$ref": "#/definitions/v0Preview" } } }, @@ -493,6 +496,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 d8426e5435..d185fe7531 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -121,16 +121,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"), diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index 73f6baa748..511d86359f 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..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"` @@ -32,6 +34,21 @@ type Document struct { LivePhoto *libregraph.LivePhoto `json:"livePhoto,omitempty"` } +// 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 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, + "height": p.Height, + }, nil +} + func CleanString(content, langCode string) string { return strings.TrimSpace(stopwords.CleanString(content, langCode, true)) } diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 1d941abf01..bc18482f4f 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -118,6 +118,8 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.MotionPhoto = t.getMotionPhoto(m0, metas[i+1]) } + 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/content/tika_preview.go b/services/search/pkg/content/tika_preview.go new file mode 100644 index 0000000000..76a238e562 --- /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 cover selection. +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_test.go b/services/search/pkg/content/tika_preview_test.go new file mode 100644 index 0000000000..f57055d768 --- /dev/null +++ b/services/search/pkg/content/tika_preview_test.go @@ -0,0 +1,49 @@ +package content + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("getPreview", func() { + audio := map[string][]string{"Content-Type": {"audio/mpeg"}} + cover := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"500"}, + "tiff:ImageLength": {"400"}, + } + coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} + + It("returns the embedded cover dimensions for audio", func() { + p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 500, Height: 400})) + }) + + It("returns nil when the audio has no cover", func() { + Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil()) + }) + + It("returns nil when the cover lacks dimensions", func() { + Expect(getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims})).To(BeNil()) + }) + + 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"}, + } + 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}) + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 64, Height: 40})) + }) + + 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/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index f0b1850879..fc0c3e41ec 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -170,19 +170,22 @@ var _ = Describe("Tika", func() { Expect(doc.Content).To(Equal("body test stop words!!!")) }) - It("takes facets from the main document, not from an embedded resource", func() { - // metas[0] is the file (audio), metas[1] its embedded cover art. The - // cover must not give the track an image facet. - fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500"}]` + It("keeps the audio facet and captures the cover as preview", func() { + fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500", "tiff:ImageLength": "400"}]` 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"))) + // 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() { diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 81d1b347e2..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), 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 a45968197e..ec5e9a4413 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -701,6 +701,7 @@ func (s *Service) doUpsertItem(ctx context.Context, ref *provider.Reference, bat facetToMetadata(metadata, doc.Video, "libre.graph.video.") facetToMetadata(metadata, doc.MotionPhoto, "libre.graph.motionPhoto.") facetToMetadata(metadata, doc.LivePhoto, "libre.graph.livePhoto.") + facetToMetadata(metadata, doc.Preview, "oc.preview.") if len(metadata) == 0 { return } From 651997fa9a97181206cc2ec161d259e99ddafd18 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 15 Oct 2026 12:32:43 +0200 Subject: [PATCH 09/18] feat(webdav): honest has-preview for audio in search results --- services/webdav/pkg/service/v0/search.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index 7412d21f4e..1886522bb5 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -222,8 +222,8 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType)) - isSupportedMimeType := sourceTypes.Supports(match.Entity.MimeType) - if isSupportedMimeType { + hasPreview := sourceTypes.HasPreviewForMimeType(match.Entity.MimeType, match.Entity.GetPreview() != nil) + if hasPreview { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "0")) From e58b882c63e015ba1ac697915ba45215b5ec5978 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 15 Oct 2026 12:32:43 +0200 Subject: [PATCH 10/18] feat(graph): base driveItem thumbnails on HasPreview, add the source thumbnail --- services/graph/pkg/service/v0/thumbnails.go | 19 ++++++++++--- services/search/pkg/content/tika_preview.go | 6 ++-- services/webdav/pkg/thumbnail/haspreview.go | 30 +++++++++++++++++++- services/webdav/pkg/thumbnail/sourcetypes.go | 11 ------- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index d5d877e740..4111b8af05 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -34,17 +34,24 @@ func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceIn } } -// previewThumbnailSet returns nil when the thumbnailer cannot render the resource. +// previewThumbnailSet returns nil when no preview can be produced for the resource. func previewThumbnailSet(res *provider.ResourceInfo, baseURL string, sourceTypes thumbnail.SourceTypes) *libregraph.ThumbnailSet { - if !sourceTypes.Supports(res.GetMimeType()) { + if !sourceTypes.HasPreview(res) { return nil } - return thumbnailSetFor(baseURL, storagespace.FormatResourceID(res.GetId())) + + itemID := storagespace.FormatResourceID(res.GetId()) + set := thumbnailSetFor(baseURL, itemID) + if w, h := thumbnail.SourceDimensions(res); w > 0 && h > 0 { + url := fmt.Sprintf("%s&x=%d&y=%d", previewBaseURL(baseURL, itemID), w, h) + set.Source = &libregraph.Thumbnail{Url: &url} + } + return set } // thumbnailSetFor builds the urls of the WebDAV preview endpoint. func thumbnailSetFor(baseURL, itemID string) *libregraph.ThumbnailSet { - base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, itemID) + base := previewBaseURL(baseURL, itemID) return &libregraph.ThumbnailSet{ Small: previewThumbnail(base, thumbnailBoxSmall), Medium: previewThumbnail(base, thumbnailBoxMedium), @@ -52,6 +59,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} diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index 76a238e562..9c64ebfdbb 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -4,7 +4,7 @@ import ( "strconv" "strings" - "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) // frontCoverDescription is the picture type Tika reports (as dc:description) for @@ -14,9 +14,9 @@ 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. +// runs for the types that carry their preview inside the file. func getPreview(mimeType string, metas []map[string][]string) *Preview { - if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { + if !thumbnail.IsEmbeddedSourceType(mimeType) { return nil } var first *Preview diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go index 136bcd9c3b..20ceb9e219 100644 --- a/services/webdav/pkg/thumbnail/haspreview.go +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -1,6 +1,9 @@ package thumbnail import ( + "mime" + "slices" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/opencloud-eu/opencloud/pkg/conversions" @@ -22,6 +25,17 @@ var EmbeddedSourceTypes = []string{ "audio/ogg", } +// IsEmbeddedSourceType reports whether the type carries its preview inside the +// file, which is what the index has to answer for. +func IsEmbeddedSourceType(contentType string) bool { + parsed, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + + return slices.Contains(EmbeddedSourceTypes, parsed) +} + // HasPreview reports whether a preview can be produced for the resource: a type // that always has one says so by itself, a type that carries its preview inside // the file only when the index saw one. @@ -40,13 +54,27 @@ func (t SourceTypes) HasPreviewForMimeType(mimeType string, hasEmbeddedPreview b if !t.Supports(mimeType) { return false } - if t.carriesPreviewInside(mimeType) { + if IsEmbeddedSourceType(mimeType) { return hasEmbeddedPreview } return true } +// SourceDimensions are the dimensions of the image the preview is rendered from, +// or (0, 0) when they are unknown. +func SourceDimensions(md *provider.ResourceInfo) (int32, int32) { + // a preview extracted at index time, an audio cover for one, has its own size + if w, h := PreviewDimensions(md); w > 0 && h > 0 { + return w, h + } + + // an image renders itself, so the image facet holds the size + meta := md.GetArbitraryMetadata().GetMetadata() + + return conversions.StringToInt32(meta["libre.graph.image.width"], 0), conversions.StringToInt32(meta["libre.graph.image.height"], 0) +} + // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() diff --git a/services/webdav/pkg/thumbnail/sourcetypes.go b/services/webdav/pkg/thumbnail/sourcetypes.go index 2be2dd5eaf..63d1ba0128 100644 --- a/services/webdav/pkg/thumbnail/sourcetypes.go +++ b/services/webdav/pkg/thumbnail/sourcetypes.go @@ -54,17 +54,6 @@ func NewSourceTypes(entries []string) SourceTypes { return t } -// carriesPreviewInside reports whether the type only has a preview when the file -// carries one, which is what EmbeddedSourceTypes names. -func (t SourceTypes) carriesPreviewInside(contentType string) bool { - parsed, _, err := mime.ParseMediaType(contentType) - if err != nil { - return false - } - - return slices.Contains(EmbeddedSourceTypes, parsed) -} - // Supports reports whether a preview is made of this type. func (t SourceTypes) Supports(contentType string) bool { parsed, _, err := mime.ParseMediaType(contentType) From 72bb245f6d8b67ba1beee8d8d3885b0638e7cb1c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 15 Oct 2026 16:43:37 +0200 Subject: [PATCH 11/18] fix(thumbnails): address review findings on preview presence --- pkg/conversions/strings.go | 2 +- services/graph/pkg/service/v0/thumbnails.go | 13 +++--- services/search/pkg/bleve/backend.go | 23 +++++----- services/search/pkg/content/content.go | 19 ++++---- services/search/pkg/content/tika_preview.go | 15 +++---- services/search/pkg/content/tika_test.go | 17 +++++++- .../opensearch/internal/convert/opensearch.go | 4 +- services/search/pkg/parity/README.md | 2 + services/search/pkg/parity/fixtures_test.go | 4 ++ .../pkg/parity/response_metadata_test.go | 23 ++++++++++ services/search/pkg/search/service_test.go | 43 +++++++++++++++++++ services/webdav/pkg/thumbnail/haspreview.go | 8 +++- 12 files changed, 128 insertions(+), 45 deletions(-) diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index a03b7d963b..84891ce8c8 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -16,7 +16,7 @@ func StringToSliceString(src string, sep string) []string { return parts } -// StringToInt32 parses s as a base-10 int32, returning fallback on any error. +// StringToInt32 returns fallback when s is not a base-10 int32 func StringToInt32(s string, fallback int32) int32 { v, err := strconv.ParseInt(s, 10, 32) if err != nil { diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 4111b8af05..8d3ef94841 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -43,8 +43,7 @@ func previewThumbnailSet(res *provider.ResourceInfo, baseURL string, sourceTypes itemID := storagespace.FormatResourceID(res.GetId()) set := thumbnailSetFor(baseURL, itemID) if w, h := thumbnail.SourceDimensions(res); w > 0 && h > 0 { - url := fmt.Sprintf("%s&x=%d&y=%d", previewBaseURL(baseURL, itemID), w, h) - set.Source = &libregraph.Thumbnail{Url: &url} + set.Source = previewThumbnail(previewBaseURL(baseURL, itemID), w, h) } return set } @@ -53,9 +52,9 @@ func previewThumbnailSet(res *provider.ResourceInfo, baseURL string, sourceTypes func thumbnailSetFor(baseURL, itemID string) *libregraph.ThumbnailSet { base := previewBaseURL(baseURL, itemID) return &libregraph.ThumbnailSet{ - Small: previewThumbnail(base, thumbnailBoxSmall), - Medium: previewThumbnail(base, thumbnailBoxMedium), - Large: previewThumbnail(base, thumbnailBoxLarge), + Small: previewThumbnail(base, thumbnailBoxSmall, thumbnailBoxSmall), + Medium: previewThumbnail(base, thumbnailBoxMedium, thumbnailBoxMedium), + Large: previewThumbnail(base, thumbnailBoxLarge, thumbnailBoxLarge), } } @@ -63,8 +62,8 @@ 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) +func previewThumbnail(base string, width, height int32) *libregraph.Thumbnail { + url := fmt.Sprintf("%s&x=%d&y=%d", base, width, height) return &libregraph.Thumbnail{Url: &url} } diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index d185fe7531..e8968f7054 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -121,18 +121,16 @@ 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), - Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), - + Id: resourceIDtoSearchID(rID), + Name: getFieldValue[string](hit.Fields, "Name"), + ParentId: resourceIDtoSearchID(pID), + Size: uint64(getFieldValue[float64](hit.Fields, "Size")), + Type: uint64(getFieldValue[float64](hit.Fields, "Type")), + MimeType: getFieldValue[string](hit.Fields, "MimeType"), + Deleted: getFieldValue[bool](hit.Fields, "Deleted"), + Tags: getFieldSliceValue[string](hit.Fields, "Tags"), + Favorites: getFieldSliceValue[string](hit.Fields, "Favorites"), + Highlights: getFragmentValue(hit.Fragments, "Content", 0), Audio: hitToFacet[searchMessage.Audio](hit.Fields, "audio"), Image: hitToFacet[searchMessage.Image](hit.Fields, "image"), Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"), @@ -140,6 +138,7 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques Video: hitToFacet[searchMessage.Video](hit.Fields, "video"), MotionPhoto: hitToFacet[searchMessage.MotionPhoto](hit.Fields, "motionPhoto"), LivePhoto: hitToFacet[searchMessage.LivePhoto](hit.Fields, "livePhoto"), + Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), }, } diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index d645a90bb2..af48607751 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -15,16 +15,14 @@ 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"` - Preview *Preview `json:"preview,omitempty"` - + Title string `json:"Title"` + Name string `json:"Name"` + Content string `json:"Content"` + Size uint64 `json:"Size"` + Mtime *time.Time `json:"Mtime,omitempty"` + MimeType string `json:"MimeType"` + Tags []string `json:"Tags"` + Favorites []string `json:"Favorites"` Audio *libregraph.Audio `json:"audio,omitempty"` Image *libregraph.Image `json:"image,omitempty"` Location *libregraph.GeoCoordinates `json:"location,omitempty"` @@ -32,6 +30,7 @@ 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_preview.go b/services/search/pkg/content/tika_preview.go index 9c64ebfdbb..e709a6c598 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -1,9 +1,9 @@ package content import ( - "strconv" "strings" + "github.com/opencloud-eu/opencloud/pkg/conversions" "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) @@ -25,17 +25,12 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { 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 { + w, _ := getFirstValue(meta, "tiff:ImageWidth") + h, _ := getFirstValue(meta, "tiff:ImageLength") + preview := &Preview{Width: conversions.StringToInt32(w, 0), Height: conversions.StringToInt32(h, 0)} + if preview.Width <= 0 || preview.Height <= 0 { 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 } diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index fc0c3e41ec..911893c7f9 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -181,13 +181,28 @@ var _ = Describe("Tika", func() { Expect(err).ToNot(HaveOccurred()) Expect(doc.Audio).ToNot(BeNil()) Expect(doc.Audio.Title).To(Equal(libregraph.PtrString("Sucker"))) - // 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("takes the front cover from a Tika 4.1 response listing the back cover first", func() { + fullResponse = `[ + {"Content-Type": "audio/mpeg", "xmpDM:audioCompressor": "MP3", "tk:embedded-depth": "0"}, + {"Content-Type": "image/jpeg", "dc:description": "Cover (back)", "dc:title": "some back art", "tiff:ImageWidth": "300", "tiff:ImageLength": "200", "tk:embedded-depth": "1", "tk:embedded-resource-type": "INLINE"}, + {"Content-Type": "image/jpeg", "dc:description": "Cover (front)", "dc:title": "some front art", "tiff:ImageWidth": "500", "tiff:ImageLength": "400", "tk:embedded-depth": "1", "tk:embedded-resource-type": "THUMBNAIL"} + ]` + + doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 1, + MimeType: "audio/mpeg", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(doc.Preview).To(Equal(&content.Preview{Width: 500, Height: 400})) + }) + It("adds no audio facet to non-audio documents", func() { fullResponse = `[{"Content-Type": "application/pdf", "dc:title": "quarterly report"}]` diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 747f1dd856..1fb04229d3 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -79,9 +79,6 @@ 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), @@ -89,6 +86,7 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, Video: copyFacet[searchMessage.Video](resource.Video), MotionPhoto: copyFacet[searchMessage.MotionPhoto](resource.MotionPhoto), LivePhoto: copyFacet[searchMessage.LivePhoto](resource.LivePhoto), + Preview: copyFacet[searchMessage.Preview](resource.Preview), }, } diff --git a/services/search/pkg/parity/README.md b/services/search/pkg/parity/README.md index 186a48badb..8930996565 100644 --- a/services/search/pkg/parity/README.md +++ b/services/search/pkg/parity/README.md @@ -715,3 +715,5 @@ Fixtures: | METADATA-01 | `*song*` reads `Audio` | all 16 fields unchanged | all 16 fields unchanged | all 16 fields unchanged | ✅ | | METADATA-02 | `*team*` reads `Location` | all 3 fields unchanged | all 3 fields unchanged | all 3 fields unchanged | ✅ | | METADATA-03 | `*team*` reads `Audio` | none | none | none | ✅ | +| METADATA-04 | `*song*` reads `Preview` | 500x400 | 500x400 | 500x400 | ✅ | +| METADATA-05 | `*team*` reads `Preview` | none | none | none | ✅ | diff --git a/services/search/pkg/parity/fixtures_test.go b/services/search/pkg/parity/fixtures_test.go index 7f1569ebbe..2b6fc39990 100644 --- a/services/search/pkg/parity/fixtures_test.go +++ b/services/search/pkg/parity/fixtures_test.go @@ -54,6 +54,10 @@ func withLocation(location *libregraph.GeoCoordinates) fixtureOption { return func(r *search.Resource) { r.Location = location } } +func withPreview(preview *content.Preview) fixtureOption { + return func(r *search.Resource) { r.Preview = preview } +} + func fixtureDoc(name string, opts ...fixtureOption) search.Resource { mtime := fixtureNow r := search.Resource{ diff --git a/services/search/pkg/parity/response_metadata_test.go b/services/search/pkg/parity/response_metadata_test.go index e4bbca5e34..7845e8060f 100644 --- a/services/search/pkg/parity/response_metadata_test.go +++ b/services/search/pkg/parity/response_metadata_test.go @@ -6,6 +6,7 @@ import ( libregraph "github.com/opencloud-eu/libre-graph-api-go" searchMessage "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0" + "github.com/opencloud-eu/opencloud/services/search/pkg/content" "github.com/opencloud-eu/opencloud/services/search/pkg/search" ) @@ -31,6 +32,7 @@ func metadataGroup() responseGroup { TrackCount: libregraph.PtrInt32(99), Year: libregraph.PtrInt32(2004), }), + withPreview(&content.Preview{Width: 500, Height: 400}), ) team := fixtureDoc("team.jpg", @@ -116,6 +118,27 @@ func metadataGroup() responseGroup { return "none" } + return "set" + }), + }, + { + id: 4, query: `*song*`, reads: "Preview", want: []string{"500x400"}, + read: reads(func(m *searchMessage.Match) string { + preview := m.GetEntity().GetPreview() + if preview == nil { + return "none" + } + + return fmt.Sprintf("%dx%d", preview.GetWidth(), preview.GetHeight()) + }), + }, + { + id: 5, query: `*team*`, reads: "Preview", want: []string{"none"}, + read: reads(func(m *searchMessage.Match) string { + if m.GetEntity().GetPreview() == nil { + return "none" + } + return "set" }), }, diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index 5db825bc9e..f2ee2c01cb 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -10,6 +10,7 @@ import ( typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + libregraph "github.com/opencloud-eu/libre-graph-api-go" revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" @@ -25,6 +26,7 @@ import ( contentMocks "github.com/opencloud-eu/opencloud/services/search/pkg/content/mocks" "github.com/opencloud-eu/opencloud/services/search/pkg/search" engineMocks "github.com/opencloud-eu/opencloud/services/search/pkg/search/mocks" + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) var _ = Describe("Searchprovider", func() { @@ -175,6 +177,47 @@ var _ = Describe("Searchprovider", func() { Entry("./", "./", false), Entry("/", "/", false), ) + + DescribeTable("stores the metadata the thumbnail service reads its source dimensions from", + func(doc content.Document, wantWidth, wantHeight int32) { + info := &sprovider.ResourceInfo{ + Id: &sprovider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"}, + ParentId: &sprovider.ResourceId{StorageId: "storageid", OpaqueId: "parentopaqueid"}, + Path: "./file", + MimeType: doc.MimeType, + } + gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&sprovider.StatResponse{ + Status: status.NewOK(context.Background()), + Info: info, + }, nil) + gatewayClient.On("GetPath", mock.Anything, mock.Anything).Return(&sprovider.GetPathResponse{ + Status: status.NewOK(context.Background()), + Path: info.GetPath(), + }, nil) + extractor.On("Extract", mock.Anything, mock.Anything, mock.Anything).Return(doc, nil) + indexClient.On("Upsert", mock.Anything, mock.Anything).Return(nil) + + var stored *sprovider.ArbitraryMetadata + gatewayClient.On("SetArbitraryMetadata", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + stored = args.Get(1).(*sprovider.SetArbitraryMetadataRequest).GetArbitraryMetadata() + }).Return(&sprovider.SetArbitraryMetadataResponse{Status: status.NewOK(context.Background())}, nil) + + s.UpsertItem(&sprovider.Reference{ResourceId: info.GetId()}) + + info.ArbitraryMetadata = stored + Expect(thumbnail.HasPreview(info)).To(BeTrue()) + w, h := thumbnail.SourceDimensions(info) + Expect([]int32{w, h}).To(Equal([]int32{wantWidth, wantHeight})) + }, + Entry("audio cover", content.Document{ + MimeType: "audio/mpeg", + Preview: &content.Preview{Width: 500, Height: 400}, + }, int32(500), int32(400)), + Entry("image", content.Document{ + MimeType: "image/jpeg", + Image: &libregraph.Image{Width: libregraph.PtrInt32(300), Height: libregraph.PtrInt32(200)}, + }, int32(300), int32(200)), + ) }) DescribeTable("IsHidden", diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go index 20ceb9e219..8487f5ed68 100644 --- a/services/webdav/pkg/thumbnail/haspreview.go +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -16,6 +16,12 @@ const ( PreviewHeightKey = "oc.preview.height" ) +// the image facet an image writes about itself at index time +const ( + imageWidthKey = "libre.graph.image.width" + imageHeightKey = "libre.graph.image.height" +) + // EmbeddedSourceTypes carry their preview inside the file, audio cover art for // one, so whether a preview exists is a property of the file and not of its // type. The index answers that, through the dimensions it stored. @@ -72,7 +78,7 @@ func SourceDimensions(md *provider.ResourceInfo) (int32, int32) { // an image renders itself, so the image facet holds the size meta := md.GetArbitraryMetadata().GetMetadata() - return conversions.StringToInt32(meta["libre.graph.image.width"], 0), conversions.StringToInt32(meta["libre.graph.image.height"], 0) + return conversions.StringToInt32(meta[imageWidthKey], 0), conversions.StringToInt32(meta[imageHeightKey], 0) } // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). From 58a8b1d390ef0313ca574f7c0ade68604fbd6301 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 15 Oct 2026 16:45:28 +0200 Subject: [PATCH 12/18] test(search): drop the Tika cover order test --- services/search/pkg/content/tika_test.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 911893c7f9..a004d3c0bf 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -187,22 +187,6 @@ var _ = Describe("Tika", func() { Expect(doc.Preview.Height).To(Equal(int32(400))) }) - It("takes the front cover from a Tika 4.1 response listing the back cover first", func() { - fullResponse = `[ - {"Content-Type": "audio/mpeg", "xmpDM:audioCompressor": "MP3", "tk:embedded-depth": "0"}, - {"Content-Type": "image/jpeg", "dc:description": "Cover (back)", "dc:title": "some back art", "tiff:ImageWidth": "300", "tiff:ImageLength": "200", "tk:embedded-depth": "1", "tk:embedded-resource-type": "INLINE"}, - {"Content-Type": "image/jpeg", "dc:description": "Cover (front)", "dc:title": "some front art", "tiff:ImageWidth": "500", "tiff:ImageLength": "400", "tk:embedded-depth": "1", "tk:embedded-resource-type": "THUMBNAIL"} - ]` - - doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ - Type: provider.ResourceType_RESOURCE_TYPE_FILE, - Size: 1, - MimeType: "audio/mpeg", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(doc.Preview).To(Equal(&content.Preview{Width: 500, Height: 400})) - }) - It("adds no audio facet to non-audio documents", func() { fullResponse = `[{"Content-Type": "application/pdf", "dc:title": "quarterly report"}]` From 928cd18f5f9fc43a253fab41166df5c28874710a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 13:35:04 +0200 Subject: [PATCH 13/18] feat(search): index the type an embedded preview is in oc.preview now carries the cover's own content type next to its dimensions, so webdav knows before it builds the cache key what a thumbnail of it is written in: a png cover stays a png instead of being re-encoded to the jpeg the container's type maps to. --- .../opencloud/messages/search/v0/search.pb.go | 419 +++++++++--------- .../services/search/v0/search.swagger.json | 5 +- .../opencloud/messages/search/v0/search.proto | 6 +- .../pkg/bleve/testdata/mapping.golden.json | 39 ++ services/search/pkg/content/content.go | 19 +- services/search/pkg/content/tika_preview.go | 6 +- .../search/pkg/content/tika_preview_test.go | 4 +- .../opensearch/testdata/resource.golden.json | 11 + services/search/pkg/search/service_test.go | 4 +- services/webdav/pkg/thumbnail/haspreview.go | 9 + .../webdav/pkg/thumbnail/workflow/workflow.go | 16 +- .../pkg/thumbnail/workflow/workflow_test.go | 32 ++ 12 files changed, 347 insertions(+), 223 deletions(-) diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 88290ad957..21da479e45 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.go @@ -361,15 +361,17 @@ 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. +// Preview describes an embedded preview (e.g. audio cover art): its dimensions +// and the type it is in. Its presence signals that a preview is available for +// the resource, its type says what a thumbnail of it is written in. type Preview struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Width *int32 `protobuf:"varint,1,opt,name=width,proto3,oneof" json:"width,omitempty"` - Height *int32 `protobuf:"varint,2,opt,name=height,proto3,oneof" json:"height,omitempty"` + 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"` + ContentType *string `protobuf:"bytes,3,opt,name=contentType,proto3,oneof" json:"contentType,omitempty"` } func (x *Preview) Reset() { @@ -418,6 +420,13 @@ func (x *Preview) GetHeight() int32 { return 0 } +func (x *Preview) GetContentType() string { + if x != nil && x.ContentType != nil { + return *x.ContentType + } + return "" +} + type GeoCoordinates struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1214,206 +1223,210 @@ 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, 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, 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, + 0x67, 0x68, 0x74, 0x22, 0x8d, 0x01, 0x0a, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, + 0x19, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, + 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, + 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, + 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0b, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x88, 0x01, 0x01, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, + 0x67, 0x68, 0x74, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, + 0x79, 0x70, 0x65, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, + 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, + 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, + 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, + 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, + 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, + 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, + 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, + 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, + 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, + 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, + 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, + 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, + 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, + 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, + 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, 0x52, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, + 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, + 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, + 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, + 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, + 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, + 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, + 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, + 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, 0x52, 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, + 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x45, 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, + 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, + 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, + 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, + 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, + 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, + 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, + 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, + 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, + 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, + 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, + 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, + 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, + 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, + 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, + 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, + 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, + 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, + 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, + 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, + 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, + 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, + 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, + 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, + 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, + 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, + 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, + 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, + 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, + 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, + 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, + 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, + 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, + 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, + 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, + 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, + 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, + 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, + 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, + 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, + 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, + 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, + 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, + 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, + 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, + 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, + 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, + 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, 0x02, 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, + 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, + 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, + 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, + 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, + 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x48, 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, + 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, + 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, + 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, + 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, + 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, + 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, + 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, + 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x22, 0x8a, 0x09, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, + 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, + 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, + 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, + 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, + 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, + 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, + 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, + 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, + 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, + 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, + 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, + 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, + 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 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, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, - 0x45, 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, - 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, - 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x3f, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, - 0x77, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, - 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, - 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x07, - 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, - 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, - 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, - 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, - 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, - 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, - 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x0c, 0x72, + 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x05, 0x69, + 0x6d, 0x61, 0x67, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, + 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x39, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x18, + 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x74, + 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x14, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, + 0x4b, 0x0a, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x15, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, + 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x39, 0x0a, 0x05, + 0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, + 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x45, 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, + 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, + 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, + 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x3f, + 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, + 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x22, + 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, + 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, + 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, + 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index 5e48993af2..d56665cfe2 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -506,9 +506,12 @@ "height": { "type": "integer", "format": "int32" + }, + "contentType": { + "type": "string" } }, - "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." + "description": "Preview describes an embedded preview (e.g. audio cover art): its dimensions\nand the type it is in. Its presence signals that a preview is available for\nthe resource, its type says what a thumbnail of it is written in." }, "v0Reference": { "type": "object", diff --git a/protogen/proto/opencloud/messages/search/v0/search.proto b/protogen/proto/opencloud/messages/search/v0/search.proto index 41bcb5136b..28176dad54 100644 --- a/protogen/proto/opencloud/messages/search/v0/search.proto +++ b/protogen/proto/opencloud/messages/search/v0/search.proto @@ -41,11 +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. +// Preview describes an embedded preview (e.g. audio cover art): its dimensions +// and the type it is in. Its presence signals that a preview is available for +// the resource, its type says what a thumbnail of it is written in. message Preview { optional int32 width = 1; optional int32 height = 2; + optional string contentType = 3; } message GeoCoordinates { diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index 511d86359f..b9ea3d1f96 100644 --- a/services/search/pkg/bleve/testdata/mapping.golden.json +++ b/services/search/pkg/bleve/testdata/mapping.golden.json @@ -1057,6 +1057,45 @@ "enabled": true, "dynamic": true, "properties": { + "contentType": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "store": true, + "index": true, + "include_term_vectors": true, + "include_in_all": true, + "docvalues": true + } + ] + }, + "contentType_lowercase": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "keyword", + "index": true, + "include_term_vectors": true + } + ] + }, + "contentType_words": { + "enabled": true, + "dynamic": true, + "fields": [ + { + "type": "text", + "analyzer": "words", + "index": true, + "include_term_vectors": true + } + ] + }, "height": { "enabled": true, "dynamic": true, diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index af48607751..f6965372b4 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -33,19 +33,26 @@ type Document struct { Preview *Preview `json:"preview,omitempty"` } -// 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. +// Preview describes an embedded preview (e.g. audio cover art): its dimensions +// and the type it is in. Internal signal, not a Graph facet; its presence marks +// that a preview exists, and the type says what a thumbnail of it is written in. type Preview struct { - Width int32 `json:"width"` - Height int32 `json:"height"` + Width int32 `json:"width"` + Height int32 `json:"height"` + ContentType string `json:"contentType,omitempty"` } // ToMap flows Preview through the shared facet flattening, under oc.preview. func (p Preview) ToMap() (map[string]interface{}, error) { - return map[string]interface{}{ + m := map[string]interface{}{ "width": p.Width, "height": p.Height, - }, nil + } + if p.ContentType != "" { + m["contentType"] = p.ContentType + } + + return m, nil } func CleanString(content, langCode string) string { diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index e709a6c598..ef9224775c 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -27,7 +27,11 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { } w, _ := getFirstValue(meta, "tiff:ImageWidth") h, _ := getFirstValue(meta, "tiff:ImageLength") - preview := &Preview{Width: conversions.StringToInt32(w, 0), Height: conversions.StringToInt32(h, 0)} + preview := &Preview{ + Width: conversions.StringToInt32(w, 0), + Height: conversions.StringToInt32(h, 0), + ContentType: ct, + } if preview.Width <= 0 || preview.Height <= 0 { continue } diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index f57055d768..fd78764a42 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -17,7 +17,7 @@ var _ = Describe("getPreview", func() { It("returns the embedded cover dimensions for audio", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) Expect(p).ToNot(BeNil()) - Expect(*p).To(Equal(Preview{Width: 500, Height: 400})) + Expect(*p).To(Equal(Preview{Width: 500, Height: 400, ContentType: "image/jpeg"})) }) It("returns nil when the audio has no cover", func() { @@ -39,7 +39,7 @@ var _ = Describe("getPreview", func() { } p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) Expect(p).ToNot(BeNil()) - Expect(*p).To(Equal(Preview{Width: 64, Height: 40})) + Expect(*p).To(Equal(Preview{Width: 64, Height: 40, ContentType: "image/jpeg"}), "the type decides what a thumbnail of the cover is written in") }) It("is gated to embedded-preview types", func() { diff --git a/services/search/pkg/opensearch/testdata/resource.golden.json b/services/search/pkg/opensearch/testdata/resource.golden.json index 584ca6ea6d..c7c0e31d56 100644 --- a/services/search/pkg/opensearch/testdata/resource.golden.json +++ b/services/search/pkg/opensearch/testdata/resource.golden.json @@ -285,6 +285,17 @@ }, "preview": { "properties": { + "contentType": { + "type": "keyword" + }, + "contentType_lowercase": { + "doc_values": false, + "type": "keyword" + }, + "contentType_words": { + "analyzer": "words", + "type": "text" + }, "height": { "type": "integer" }, diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index f2ee2c01cb..7a522dae47 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -26,7 +26,7 @@ import ( contentMocks "github.com/opencloud-eu/opencloud/services/search/pkg/content/mocks" "github.com/opencloud-eu/opencloud/services/search/pkg/search" engineMocks "github.com/opencloud-eu/opencloud/services/search/pkg/search/mocks" - "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" + "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) var _ = Describe("Searchprovider", func() { @@ -205,7 +205,7 @@ var _ = Describe("Searchprovider", func() { s.UpsertItem(&sprovider.Reference{ResourceId: info.GetId()}) info.ArbitraryMetadata = stored - Expect(thumbnail.HasPreview(info)).To(BeTrue()) + Expect(thumbnail.NewSourceTypes(nil).HasPreview(info)).To(BeTrue()) w, h := thumbnail.SourceDimensions(info) Expect([]int32{w, h}).To(Equal([]int32{wantWidth, wantHeight})) }, diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go index 8487f5ed68..baae006a7a 100644 --- a/services/webdav/pkg/thumbnail/haspreview.go +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -14,6 +14,9 @@ import ( const ( PreviewWidthKey = "oc.preview.width" PreviewHeightKey = "oc.preview.height" + // the type of the embedded image itself, which decides what a thumbnail of + // it is written in + PreviewContentTypeKey = "oc.preview.contentType" ) // the image facet an image writes about itself at index time @@ -81,6 +84,12 @@ func SourceDimensions(md *provider.ResourceInfo) (int32, int32) { return conversions.StringToInt32(meta[imageWidthKey], 0), conversions.StringToInt32(meta[imageHeightKey], 0) } +// PreviewContentType is the type of the embedded image the index saw, empty +// when nothing is stored. +func PreviewContentType(md *provider.ResourceInfo) string { + return md.GetArbitraryMetadata().GetMetadata()[PreviewContentTypeKey] +} + // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index f1a747ce90..3caae60029 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -429,12 +429,16 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R mimeType := info.GetMimeType() // The thumbnail is returned in the type the source type maps to, asked for - // by the name the generator's filter uses. What an extractor hands on is - // re-encoded to that type, because the request has to decide it: a cache - // hit knows the key, not the bytes. Keeping a png cover a png needs the - // embedded type before the key is built, which is what the preview presence - // work puts into the file's metadata. - outputType, outputFormat := w.outputFor(mimeType) + // by the name the generator's filter uses. For a file that carries its + // preview inside, the type that counts is the embedded image's own, which + // the index stored when it saw it: that way a png cover stays a png, and + // the type is still known before the cache key is built. Without it, the + // container's type decides and the cover is re-encoded. + typeForOutput := mimeType + if embedded := thumbnail.PreviewContentType(info); embedded != "" { + typeForOutput = embedded + } + outputType, outputFormat := w.outputFor(typeForOutput) // The operation is resolved up front so it can be part of the cache key: a // fill and a fit-in request with the same dimensions produce different images diff --git a/services/webdav/pkg/thumbnail/workflow/workflow_test.go b/services/webdav/pkg/thumbnail/workflow/workflow_test.go index 61560f33b7..255b7f8e5c 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow_test.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow_test.go @@ -192,6 +192,7 @@ func setupPreprocessTest( mimeType string, filePath string, downloadBody []byte, + metadata ...map[string]string, ) (*ThumbnailWorkflow, *capturedUpload) { wf, err := NewWorkflow( WithGeneratorURL("http://generator-test"), @@ -236,6 +237,15 @@ func setupPreprocessTest( MimeType: mimeType, Size: uint64(len(downloadBody)), Checksum: &providerv1beta1.ResourceChecksum{Sum: "1f0c9f1b6e4b4b2a9c1d7f3e8a5b6c7d8e9f0a1b"}, + ArbitraryMetadata: &providerv1beta1.ArbitraryMetadata{ + Metadata: func() map[string]string { + if len(metadata) == 0 { + return nil + } + + return metadata[0] + }(), + }, }, }, nil) @@ -945,6 +955,28 @@ var _ = Describe("ThumbnailWorkflow", func() { "1f/0c/9f1b6e4b4b2a9c1d7f3e8a5b6c7d8e9f0a1b/64x64-fill.jpg"), ) + It("writes a cover in the type the index saw it in", func() { + // an mp3 says nothing about its cover; the index does, so the png + // stays a png instead of being re-encoded to the container's jpeg + mp3Bytes, err := os.ReadFile(filepath.Join("..", "..", "preprocessor", "test_assets", "empty.mp3")) + Expect(err).ToNot(HaveOccurred()) + + wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "cover.mp3", mp3Bytes, + map[string]string{thumbnail.PreviewContentTypeKey: "image/png"}) + + tr := &requests.ThumbnailRequest{ + Ref: spaceRef("storageid$spaceid!opaqueid", "cover.mp3"), + Filename: "cover.mp3", + Width: 64, + Height: 64, + } + + result, err := wf2.Execute(context.Background(), tr, testToken, logger) + Expect(err).ToNot(HaveOccurred()) + Expect(captured.path).To(ContainSubstring("filters:format(png)")) + Expect(result.ContentType).To(Equal("image/png")) + }) + It("follows a configured chain into a format the built-in generator cannot write", func() { wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "image/png", "img.png", []byte("source-bytes")) wf2.typeMapping = thumbnail.NewTypeMapping([]string{"image/png:image/webp"}) From 1b68448f31ae33b03f647a568c342bc178765f13 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 14:07:55 +0200 Subject: [PATCH 14/18] fix(webdav): no indexed cover, no preview A file that carries its preview inside is only previewable once the extraction has seen it: dimensions and the type it is in. Without that nothing here could say what a thumbnail would be, so the mapping no longer pins audio to jpeg and the workflow refuses instead of guessing. --- services/webdav/pkg/service/v0/search.go | 3 ++- services/webdav/pkg/thumbnail/haspreview.go | 6 ++++-- services/webdav/pkg/thumbnail/haspreview_test.go | 10 ++++++++-- services/webdav/pkg/thumbnail/typemapping.go | 3 --- .../webdav/pkg/thumbnail/typemapping_test.go | 12 +++++++++++- .../webdav/pkg/thumbnail/workflow/workflow.go | 16 ++++++++++++---- .../pkg/thumbnail/workflow/workflow_test.go | 16 ++++++++++++---- 7 files changed, 49 insertions(+), 17 deletions(-) diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index 1886522bb5..9442f3b6d9 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -222,7 +222,8 @@ 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)) - hasPreview := sourceTypes.HasPreviewForMimeType(match.Entity.MimeType, match.Entity.GetPreview() != nil) + preview := match.Entity.GetPreview() + hasPreview := sourceTypes.HasPreviewForMimeType(match.Entity.MimeType, preview.GetContentType() != "") if hasPreview { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go index baae006a7a..8442357427 100644 --- a/services/webdav/pkg/thumbnail/haspreview.go +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -47,14 +47,16 @@ func IsEmbeddedSourceType(contentType string) bool { // HasPreview reports whether a preview can be produced for the resource: a type // that always has one says so by itself, a type that carries its preview inside -// the file only when the index saw one. +// the file only when the index saw one, with its size and the type it is in. +// Without that, nothing here could say what a thumbnail of it would be, and +// guessing is what this replaces. func (t SourceTypes) HasPreview(md *provider.ResourceInfo) bool { if md == nil { return false } w, h := PreviewDimensions(md) - return t.HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0) + return t.HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0 && PreviewContentType(md) != "") } // HasPreviewForMimeType is HasPreview for callers that only have the mimetype diff --git a/services/webdav/pkg/thumbnail/haspreview_test.go b/services/webdav/pkg/thumbnail/haspreview_test.go index 02637da70b..0d60ab98c2 100644 --- a/services/webdav/pkg/thumbnail/haspreview_test.go +++ b/services/webdav/pkg/thumbnail/haspreview_test.go @@ -25,11 +25,17 @@ var _ = Describe("HasPreview", func() { Entry("an image always has one", resource("image/png", nil), true), Entry("a type nobody makes a preview of has none", resource("application/zip", nil), false), Entry("audio without indexed dimensions has none", resource("audio/mpeg", nil), false), - Entry("audio with indexed dimensions has one", + Entry("audio the index saw, with its type, has one", + resource("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + thumbnail.PreviewContentTypeKey: "image/png", + }), true), + Entry("audio the index measured but could not type has none", resource("audio/mpeg", map[string]string{ thumbnail.PreviewWidthKey: "600", thumbnail.PreviewHeightKey: "600", - }), true), + }), false), Entry("audio with zero dimensions has none", resource("audio/mpeg", map[string]string{ thumbnail.PreviewWidthKey: "0", diff --git a/services/webdav/pkg/thumbnail/typemapping.go b/services/webdav/pkg/thumbnail/typemapping.go index dc7eefbd1a..f833ac5591 100644 --- a/services/webdav/pkg/thumbnail/typemapping.go +++ b/services/webdav/pkg/thumbnail/typemapping.go @@ -24,9 +24,6 @@ var DefaultTypeMapping = []string{ "image/vnd.microsoft.icon:image/png", "image/x-icon:image/png", "text/plain:image/jpeg", - "audio/flac:image/jpeg", - "audio/mpeg:image/jpeg", - "audio/ogg:image/jpeg", "application/vnd.geogebra.slides:image/jpeg", "application/vnd.geogebra.pinboard:image/jpeg", } diff --git a/services/webdav/pkg/thumbnail/typemapping_test.go b/services/webdav/pkg/thumbnail/typemapping_test.go index 4557b75fb1..cfeee4ebad 100644 --- a/services/webdav/pkg/thumbnail/typemapping_test.go +++ b/services/webdav/pkg/thumbnail/typemapping_test.go @@ -11,12 +11,22 @@ import ( var _ = Describe("TypeMapping", func() { m := thumbnail.NewTypeMapping(nil) - It("answers for every type a preview is made of", func() { + It("answers for every type whose output does not depend on the file", func() { for _, mimeType := range thumbnail.NewSourceTypes(nil).List() { + if thumbnail.IsEmbeddedSourceType(mimeType) { + continue + } Expect(m.TargetFor(mimeType)).ToNot(BeEmpty(), "%s has no output type", mimeType) } }) + It("says nothing about a type that carries its preview inside", func() { + // the embedded image's own type decides, and the index knows it + for _, mimeType := range thumbnail.EmbeddedSourceTypes { + Expect(m.TargetFor(mimeType)).To(BeEmpty(), "%s should follow the embedded type", mimeType) + } + }) + It("keeps a format that carries alpha or animation", func() { Expect(m.TargetFor("image/png")).To(Equal("image/png")) Expect(m.TargetFor("image/gif")).To(Equal("image/gif")) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index 3caae60029..c9569c8f7a 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -321,7 +321,7 @@ func (w *ThumbnailWorkflow) statSource(ctx context.Context, ref *providerv1beta1 return nil, ErrPermissionDenied } - if !w.supportsMimeType(info.GetMimeType()) { + if !w.canPreview(info) { return nil, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType()) } @@ -571,9 +571,17 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta return bytes.NewReader(img.Data), img.ContentType, func() {}, nil } -// supportsMimeType reports whether a preview is attempted for the type: the -// announced source types plus whatever a configured Tika takes. -func (w *ThumbnailWorkflow) supportsMimeType(mimeType string) bool { +// canPreview reports whether a preview is attempted for the resource. A type +// that carries its preview inside the file needs the index to have seen it, +// with the type it is in, because nothing else here could say what a thumbnail +// of it would be. Everything else is answered by its type alone, the announced +// source types plus whatever a configured Tika takes. +func (w *ThumbnailWorkflow) canPreview(info *providerv1beta1.ResourceInfo) bool { + mimeType := info.GetMimeType() + if thumbnail.IsEmbeddedSourceType(mimeType) { + return w.sourceTypes.HasPreview(info) + } + return w.sourceTypes.Supports(mimeType) || w.tika.Supports(mimeType) } diff --git a/services/webdav/pkg/thumbnail/workflow/workflow_test.go b/services/webdav/pkg/thumbnail/workflow/workflow_test.go index 255b7f8e5c..470b265e47 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow_test.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow_test.go @@ -285,6 +285,15 @@ func setupPreprocessTest( return wf, captured } +// indexedCover is what the search extraction leaves on a file whose cover it saw. +func indexedCover(contentType string) map[string]string { + return map[string]string{ + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + thumbnail.PreviewContentTypeKey: contentType, + } +} + type capturedUpload struct { path string filename string @@ -834,7 +843,7 @@ var _ = Describe("ThumbnailWorkflow", func() { mp3Bytes, err := os.ReadFile(filepath.Join("..", "..", "preprocessor", "test_assets", "empty.mp3")) Expect(err).ToNot(HaveOccurred()) - wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "song.mp3", mp3Bytes) + wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "song.mp3", mp3Bytes, indexedCover("image/jpeg")) tr := &requests.ThumbnailRequest{ Ref: spaceRef("storageid$spaceid!opaqueid", "song.mp3"), @@ -907,7 +916,7 @@ var _ = Describe("ThumbnailWorkflow", func() { }) It("returns an error when the file cannot be converted to an image", func() { - wf2, _ := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "bad.mp3", []byte("not a real audio file")) + wf2, _ := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "bad.mp3", []byte("not a real audio file"), indexedCover("image/jpeg")) tr := &requests.ThumbnailRequest{ Ref: spaceRef("storageid$spaceid!opaqueid", "bad.mp3"), @@ -961,8 +970,7 @@ var _ = Describe("ThumbnailWorkflow", func() { mp3Bytes, err := os.ReadFile(filepath.Join("..", "..", "preprocessor", "test_assets", "empty.mp3")) Expect(err).ToNot(HaveOccurred()) - wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "cover.mp3", mp3Bytes, - map[string]string{thumbnail.PreviewContentTypeKey: "image/png"}) + wf2, captured := setupPreprocessTest(GinkgoT(), gatewayClient, logger, testCache, "audio/mpeg", "cover.mp3", mp3Bytes, indexedCover("image/png")) tr := &requests.ThumbnailRequest{ Ref: spaceRef("storageid$spaceid!opaqueid", "cover.mp3"), From 2b6468f7e8881b0aca7699391d00340afcef54e5 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 14:26:56 +0200 Subject: [PATCH 15/18] fix(search): store the embedded preview's media type without parameters --- services/search/pkg/content/tika_preview.go | 10 +++++++++- services/search/pkg/content/tika_preview_test.go | 11 +++++++++++ services/search/pkg/search/service_test.go | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index ef9224775c..c82e7a58ed 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -1,6 +1,7 @@ package content import ( + "mime" "strings" "github.com/opencloud-eu/opencloud/pkg/conversions" @@ -27,10 +28,17 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { } w, _ := getFirstValue(meta, "tiff:ImageWidth") h, _ := getFirstValue(meta, "tiff:ImageLength") + // Tika answers with a full header value, parameters included; what is + // stored is the media type alone, the way a file's own type is stored + mediaType, _, err := mime.ParseMediaType(ct) + if err != nil { + continue + } + preview := &Preview{ Width: conversions.StringToInt32(w, 0), Height: conversions.StringToInt32(h, 0), - ContentType: ct, + ContentType: mediaType, } if preview.Width <= 0 || preview.Height <= 0 { continue diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index fd78764a42..5ec86f66d4 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -13,6 +13,11 @@ var _ = Describe("getPreview", func() { "tiff:ImageLength": {"400"}, } coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} + coverWithParams := map[string][]string{ + "Content-Type": {"image/jpeg; quality=90"}, + "tiff:ImageWidth": {"80"}, + "tiff:ImageLength": {"60"}, + } It("returns the embedded cover dimensions for audio", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) @@ -20,6 +25,12 @@ var _ = Describe("getPreview", func() { Expect(*p).To(Equal(Preview{Width: 500, Height: 400, ContentType: "image/jpeg"})) }) + It("stores the media type without the parameters Tika sends", func() { + p := getPreview("audio/mpeg", []map[string][]string{audio, coverWithParams}) + Expect(p).ToNot(BeNil()) + Expect(p.ContentType).To(Equal("image/jpeg")) + }) + It("returns nil when the audio has no cover", func() { Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil()) }) diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index 7a522dae47..95de9aeeb4 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -211,7 +211,7 @@ var _ = Describe("Searchprovider", func() { }, Entry("audio cover", content.Document{ MimeType: "audio/mpeg", - Preview: &content.Preview{Width: 500, Height: 400}, + Preview: &content.Preview{Width: 500, Height: 400, ContentType: "image/jpeg"}, }, int32(500), int32(400)), Entry("image", content.Document{ MimeType: "image/jpeg", From 66cfa4b268898e741670070195716ed524a6c550 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 15:52:18 +0200 Subject: [PATCH 16/18] refactor: call the preview's type mimeType, as everywhere else --- .../opencloud/messages/search/v0/search.pb.go | 407 +++++++++--------- .../services/search/v0/search.swagger.json | 2 +- .../opencloud/messages/search/v0/search.proto | 2 +- .../pkg/bleve/testdata/mapping.golden.json | 26 +- services/search/pkg/content/content.go | 10 +- services/search/pkg/content/tika_preview.go | 6 +- .../search/pkg/content/tika_preview_test.go | 6 +- .../opensearch/testdata/resource.golden.json | 12 +- services/search/pkg/search/service_test.go | 2 +- services/webdav/pkg/service/v0/search.go | 2 +- services/webdav/pkg/thumbnail/haspreview.go | 38 +- .../webdav/pkg/thumbnail/haspreview_test.go | 6 +- .../webdav/pkg/thumbnail/workflow/workflow.go | 2 +- .../pkg/thumbnail/workflow/workflow_test.go | 6 +- 14 files changed, 265 insertions(+), 262 deletions(-) diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index 21da479e45..1373dc8c48 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.go @@ -369,9 +369,9 @@ type Preview struct { 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"` - ContentType *string `protobuf:"bytes,3,opt,name=contentType,proto3,oneof" json:"contentType,omitempty"` + Width *int32 `protobuf:"varint,1,opt,name=width,proto3,oneof" json:"width,omitempty"` + Height *int32 `protobuf:"varint,2,opt,name=height,proto3,oneof" json:"height,omitempty"` + MimeType *string `protobuf:"bytes,3,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"` } func (x *Preview) Reset() { @@ -420,9 +420,9 @@ func (x *Preview) GetHeight() int32 { return 0 } -func (x *Preview) GetContentType() string { - if x != nil && x.ContentType != nil { - return *x.ContentType +func (x *Preview) GetMimeType() string { + if x != nil && x.MimeType != nil { + return *x.MimeType } return "" } @@ -1223,210 +1223,209 @@ var file_opencloud_messages_search_v0_search_proto_rawDesc = []byte{ 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, - 0x67, 0x68, 0x74, 0x22, 0x8d, 0x01, 0x0a, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, + 0x67, 0x68, 0x74, 0x22, 0x84, 0x01, 0x0a, 0x07, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x06, 0x68, 0x65, - 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0b, - 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x88, 0x01, 0x01, 0x42, 0x08, - 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, - 0x67, 0x68, 0x74, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x54, - 0x79, 0x70, 0x65, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, - 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, - 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x48, 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, - 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, - 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, - 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, - 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, - 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, - 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, - 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, - 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, - 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, - 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, - 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, 0x52, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, - 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, - 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, - 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, - 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, - 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, - 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, - 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, - 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, 0x52, 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, - 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x45, 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, - 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, - 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, - 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, - 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, - 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, - 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, - 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, - 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, - 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, - 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, - 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, - 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, - 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, - 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, - 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, - 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, - 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, - 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, - 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, - 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, - 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, - 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, - 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, - 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, - 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, - 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, - 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, - 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, - 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, - 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, - 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, - 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, - 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, - 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, - 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, - 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, - 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, - 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, - 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, - 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, - 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, - 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, - 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, - 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, - 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, - 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, - 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, 0x02, 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, - 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, - 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, - 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, - 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, - 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x48, 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, - 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, - 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, - 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, - 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, - 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, - 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, - 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x22, 0x8a, 0x09, 0x0a, 0x06, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, - 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, - 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, - 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, - 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, - 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, - 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, - 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, - 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, - 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, - 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, - 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, - 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, - 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, - 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 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, + 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, + 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x08, 0x6d, 0x69, 0x6d, + 0x65, 0x54, 0x79, 0x70, 0x65, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x77, 0x69, 0x64, + 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x0b, 0x0a, + 0x09, 0x5f, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x22, 0x9d, 0x01, 0x0a, 0x0e, 0x47, + 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x12, 0x1f, 0x0a, + 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x48, + 0x00, 0x52, 0x08, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, + 0x0a, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x01, + 0x48, 0x01, 0x52, 0x08, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, 0x01, 0x01, 0x12, + 0x21, 0x0a, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x01, 0x48, 0x02, 0x52, 0x09, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x88, + 0x01, 0x01, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x61, 0x6c, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, + 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x74, 0x69, 0x74, 0x75, 0x64, 0x65, 0x42, 0x0c, 0x0a, 0x0a, + 0x5f, 0x6c, 0x6f, 0x6e, 0x67, 0x69, 0x74, 0x75, 0x64, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x50, + 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x23, 0x0a, 0x0a, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x61, + 0x6b, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x63, 0x61, 0x6d, 0x65, + 0x72, 0x61, 0x4d, 0x61, 0x6b, 0x65, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x63, 0x61, 0x6d, + 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, + 0x52, 0x0b, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, 0x6f, 0x64, 0x65, 0x6c, 0x88, 0x01, 0x01, + 0x12, 0x35, 0x0a, 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, + 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x02, 0x48, 0x02, 0x52, + 0x13, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, + 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, + 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x02, 0x48, 0x03, 0x52, 0x11, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, + 0x6d, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x66, 0x4e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x02, 0x48, 0x04, 0x52, 0x07, 0x66, + 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x66, 0x6f, 0x63, + 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x02, 0x48, 0x05, + 0x52, 0x0b, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, + 0x12, 0x15, 0x0a, 0x03, 0x69, 0x73, 0x6f, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x48, 0x06, 0x52, + 0x03, 0x69, 0x73, 0x6f, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x69, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x48, 0x07, 0x52, 0x0b, + 0x6f, 0x72, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x45, + 0x0a, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, + 0x70, 0x48, 0x08, 0x52, 0x0d, 0x74, 0x61, 0x6b, 0x65, 0x6e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, + 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, + 0x4d, 0x61, 0x6b, 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x63, 0x61, 0x6d, 0x65, 0x72, 0x61, 0x4d, + 0x6f, 0x64, 0x65, 0x6c, 0x42, 0x16, 0x0a, 0x14, 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, + 0x65, 0x44, 0x65, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x74, 0x6f, 0x72, 0x42, 0x14, 0x0a, 0x12, + 0x5f, 0x65, 0x78, 0x70, 0x6f, 0x73, 0x75, 0x72, 0x65, 0x4e, 0x75, 0x6d, 0x65, 0x72, 0x61, 0x74, + 0x6f, 0x72, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x66, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x0e, + 0x0a, 0x0c, 0x5f, 0x66, 0x6f, 0x63, 0x61, 0x6c, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x06, + 0x0a, 0x04, 0x5f, 0x69, 0x73, 0x6f, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x69, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x74, 0x61, 0x6b, 0x65, 0x6e, + 0x44, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x9b, 0x04, 0x0a, 0x05, 0x56, 0x69, 0x64, + 0x65, 0x6f, 0x12, 0x33, 0x0a, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, + 0x65, 0x72, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, + 0x52, 0x12, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, + 0x6d, 0x70, 0x6c, 0x65, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x48, 0x01, + 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x88, + 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, 0x6f, 0x72, 0x6d, 0x61, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0b, 0x61, 0x75, 0x64, 0x69, 0x6f, + 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x15, 0x61, 0x75, 0x64, + 0x69, 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, + 0x6e, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, 0x03, 0x52, 0x15, 0x61, 0x75, 0x64, 0x69, + 0x6f, 0x53, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, + 0x64, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x05, 0x48, 0x04, 0x52, 0x07, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, + 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x03, 0x48, 0x05, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x06, 0x66, 0x6f, 0x75, 0x72, 0x43, 0x43, 0x88, 0x01, + 0x01, 0x12, 0x21, 0x0a, 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x01, 0x48, 0x07, 0x52, 0x09, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, + 0x65, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x09, + 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x88, 0x01, + 0x01, 0x12, 0x19, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, + 0x48, 0x09, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x15, 0x0a, 0x13, + 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x42, 0x69, 0x74, 0x73, 0x50, 0x65, 0x72, 0x53, 0x61, 0x6d, + 0x70, 0x6c, 0x65, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x43, 0x68, 0x61, + 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x46, + 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x53, + 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x42, + 0x0a, 0x0a, 0x08, 0x5f, 0x62, 0x69, 0x74, 0x72, 0x61, 0x74, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, + 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x66, 0x6f, 0x75, + 0x72, 0x43, 0x43, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x52, 0x61, 0x74, + 0x65, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x42, 0x08, 0x0a, 0x06, + 0x5f, 0x77, 0x69, 0x64, 0x74, 0x68, 0x22, 0xc4, 0x01, 0x0a, 0x0b, 0x4d, 0x6f, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x17, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, + 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, + 0x73, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x09, 0x76, 0x69, 0x64, 0x65, 0x6f, + 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x42, 0x1a, 0x0a, 0x18, 0x5f, 0x70, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x74, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x55, 0x73, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x69, 0x7a, 0x65, 0x22, 0xb9, 0x02, + 0x0a, 0x09, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x21, 0x0a, 0x09, 0x63, + 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2f, + 0x0a, 0x10, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, + 0x55, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x10, 0x73, 0x74, 0x69, 0x6c, + 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x55, 0x73, 0x88, 0x01, 0x01, 0x12, + 0x17, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x6f, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, + 0x04, 0x61, 0x75, 0x74, 0x6f, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0d, 0x76, 0x69, 0x74, 0x61, + 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x48, + 0x03, 0x52, 0x0d, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, + 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x16, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, + 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x03, 0x48, 0x04, 0x52, 0x16, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, + 0x63, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x88, 0x01, 0x01, + 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x42, 0x13, + 0x0a, 0x11, 0x5f, 0x73, 0x74, 0x69, 0x6c, 0x6c, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x54, 0x69, 0x6d, + 0x65, 0x55, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x42, 0x10, 0x0a, 0x0e, + 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x19, + 0x0a, 0x17, 0x5f, 0x76, 0x69, 0x74, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x53, 0x63, 0x6f, 0x72, 0x69, + 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x8a, 0x09, 0x0a, 0x06, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, + 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, + 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, + 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, + 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x75, 0x64, 0x69, 0x6f, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, - 0x6f, 0x12, 0x48, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x10, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, - 0x73, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x4e, 0x0a, 0x0e, 0x72, - 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, - 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x0c, 0x72, - 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x05, 0x69, - 0x6d, 0x61, 0x67, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, + 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, + 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x1e, 0x0a, + 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x39, 0x0a, + 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x41, 0x75, 0x64, 0x69, + 0x6f, 0x52, 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x12, 0x48, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, - 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x39, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x18, - 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, - 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, - 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x74, - 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x14, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, - 0x4b, 0x0a, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x15, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, - 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, - 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x39, 0x0a, 0x05, - 0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, - 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, - 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, - 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x45, 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, - 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x47, 0x65, 0x6f, 0x43, 0x6f, 0x6f, + 0x72, 0x64, 0x69, 0x6e, 0x61, 0x74, 0x65, 0x73, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x4e, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x69, 0x74, 0x65, + 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, - 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, - 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x3f, - 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, - 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x22, - 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, - 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, - 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, - 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, - 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, - 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x49, 0x44, 0x52, 0x0c, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x74, 0x65, 0x6d, + 0x49, 0x64, 0x12, 0x39, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x12, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x39, 0x0a, + 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x13, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x68, 0x6f, 0x74, + 0x6f, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x66, 0x61, 0x76, 0x6f, + 0x72, 0x69, 0x74, 0x65, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x09, 0x52, 0x09, 0x66, 0x61, 0x76, + 0x6f, 0x72, 0x69, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, + 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x4d, 0x6f, 0x74, 0x69, 0x6f, + 0x6e, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x0b, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x68, + 0x6f, 0x74, 0x6f, 0x12, 0x39, 0x0a, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x16, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, + 0x30, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x12, 0x45, + 0x0a, 0x09, 0x6c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x18, 0x17, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x4c, 0x69, 0x76, 0x65, 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x52, 0x09, 0x6c, 0x69, 0x76, 0x65, + 0x50, 0x68, 0x6f, 0x74, 0x6f, 0x12, 0x3f, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, + 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, + 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, + 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x50, 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x52, 0x07, 0x70, + 0x72, 0x65, 0x76, 0x69, 0x65, 0x77, 0x22, 0x5b, 0x0a, 0x05, 0x4d, 0x61, 0x74, 0x63, 0x68, 0x12, + 0x3c, 0x0a, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x24, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x45, + 0x6e, 0x74, 0x69, 0x74, 0x79, 0x52, 0x06, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x02, 0x52, 0x05, 0x73, 0x63, + 0x6f, 0x72, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x65, 0x75, 0x2f, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x67, 0x65, + 0x6e, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2f, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2f, + 0x76, 0x30, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index d56665cfe2..10a35e6dc6 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -507,7 +507,7 @@ "type": "integer", "format": "int32" }, - "contentType": { + "mimeType": { "type": "string" } }, diff --git a/protogen/proto/opencloud/messages/search/v0/search.proto b/protogen/proto/opencloud/messages/search/v0/search.proto index 28176dad54..af83d1db88 100644 --- a/protogen/proto/opencloud/messages/search/v0/search.proto +++ b/protogen/proto/opencloud/messages/search/v0/search.proto @@ -47,7 +47,7 @@ message Image { message Preview { optional int32 width = 1; optional int32 height = 2; - optional string contentType = 3; + optional string mimeType = 3; } message GeoCoordinates { diff --git a/services/search/pkg/bleve/testdata/mapping.golden.json b/services/search/pkg/bleve/testdata/mapping.golden.json index b9ea3d1f96..7e73d2d23c 100644 --- a/services/search/pkg/bleve/testdata/mapping.golden.json +++ b/services/search/pkg/bleve/testdata/mapping.golden.json @@ -1057,55 +1057,55 @@ "enabled": true, "dynamic": true, "properties": { - "contentType": { + "height": { "enabled": true, "dynamic": true, "fields": [ { - "type": "text", - "analyzer": "keyword", + "type": "number", "store": true, "index": true, - "include_term_vectors": true, "include_in_all": true, "docvalues": true } ] }, - "contentType_lowercase": { + "mimeType": { "enabled": true, "dynamic": true, "fields": [ { "type": "text", "analyzer": "keyword", + "store": true, "index": true, - "include_term_vectors": true + "include_term_vectors": true, + "include_in_all": true, + "docvalues": true } ] }, - "contentType_words": { + "mimeType_lowercase": { "enabled": true, "dynamic": true, "fields": [ { "type": "text", - "analyzer": "words", + "analyzer": "keyword", "index": true, "include_term_vectors": true } ] }, - "height": { + "mimeType_words": { "enabled": true, "dynamic": true, "fields": [ { - "type": "number", - "store": true, + "type": "text", + "analyzer": "words", "index": true, - "include_in_all": true, - "docvalues": true + "include_term_vectors": true } ] }, diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index f6965372b4..e02eec6c0b 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -37,9 +37,9 @@ type Document struct { // and the type it is in. Internal signal, not a Graph facet; its presence marks // that a preview exists, and the type says what a thumbnail of it is written in. type Preview struct { - Width int32 `json:"width"` - Height int32 `json:"height"` - ContentType string `json:"contentType,omitempty"` + Width int32 `json:"width"` + Height int32 `json:"height"` + MimeType string `json:"mimeType,omitempty"` } // ToMap flows Preview through the shared facet flattening, under oc.preview. @@ -48,8 +48,8 @@ func (p Preview) ToMap() (map[string]interface{}, error) { "width": p.Width, "height": p.Height, } - if p.ContentType != "" { - m["contentType"] = p.ContentType + if p.MimeType != "" { + m["mimeType"] = p.MimeType } return m, nil diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index c82e7a58ed..763b5ddb4c 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -36,9 +36,9 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { } preview := &Preview{ - Width: conversions.StringToInt32(w, 0), - Height: conversions.StringToInt32(h, 0), - ContentType: mediaType, + Width: conversions.StringToInt32(w, 0), + Height: conversions.StringToInt32(h, 0), + MimeType: mediaType, } if preview.Width <= 0 || preview.Height <= 0 { continue diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index 5ec86f66d4..6f613c41c0 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -22,13 +22,13 @@ var _ = Describe("getPreview", func() { It("returns the embedded cover dimensions for audio", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) Expect(p).ToNot(BeNil()) - Expect(*p).To(Equal(Preview{Width: 500, Height: 400, ContentType: "image/jpeg"})) + Expect(*p).To(Equal(Preview{Width: 500, Height: 400, MimeType: "image/jpeg"})) }) It("stores the media type without the parameters Tika sends", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, coverWithParams}) Expect(p).ToNot(BeNil()) - Expect(p.ContentType).To(Equal("image/jpeg")) + Expect(p.MimeType).To(Equal("image/jpeg")) }) It("returns nil when the audio has no cover", func() { @@ -50,7 +50,7 @@ var _ = Describe("getPreview", func() { } p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) Expect(p).ToNot(BeNil()) - Expect(*p).To(Equal(Preview{Width: 64, Height: 40, ContentType: "image/jpeg"}), "the type decides what a thumbnail of the cover is written in") + Expect(*p).To(Equal(Preview{Width: 64, Height: 40, MimeType: "image/jpeg"}), "the type decides what a thumbnail of the cover is written in") }) It("is gated to embedded-preview types", func() { diff --git a/services/search/pkg/opensearch/testdata/resource.golden.json b/services/search/pkg/opensearch/testdata/resource.golden.json index c7c0e31d56..6893b4aec8 100644 --- a/services/search/pkg/opensearch/testdata/resource.golden.json +++ b/services/search/pkg/opensearch/testdata/resource.golden.json @@ -285,20 +285,20 @@ }, "preview": { "properties": { - "contentType": { + "height": { + "type": "integer" + }, + "mimeType": { "type": "keyword" }, - "contentType_lowercase": { + "mimeType_lowercase": { "doc_values": false, "type": "keyword" }, - "contentType_words": { + "mimeType_words": { "analyzer": "words", "type": "text" }, - "height": { - "type": "integer" - }, "width": { "type": "integer" } diff --git a/services/search/pkg/search/service_test.go b/services/search/pkg/search/service_test.go index 95de9aeeb4..67aeda56fa 100644 --- a/services/search/pkg/search/service_test.go +++ b/services/search/pkg/search/service_test.go @@ -211,7 +211,7 @@ var _ = Describe("Searchprovider", func() { }, Entry("audio cover", content.Document{ MimeType: "audio/mpeg", - Preview: &content.Preview{Width: 500, Height: 400, ContentType: "image/jpeg"}, + Preview: &content.Preview{Width: 500, Height: 400, MimeType: "image/jpeg"}, }, int32(500), int32(400)), Entry("image", content.Document{ MimeType: "image/jpeg", diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index 9442f3b6d9..0ca5606c7b 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -223,7 +223,7 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType)) preview := match.Entity.GetPreview() - hasPreview := sourceTypes.HasPreviewForMimeType(match.Entity.MimeType, preview.GetContentType() != "") + hasPreview := sourceTypes.HasPreviewForMimeType(match.Entity.MimeType, thumbnail.HasIndexedPreview(preview.GetWidth(), preview.GetHeight(), preview.GetMimeType())) if hasPreview { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { diff --git a/services/webdav/pkg/thumbnail/haspreview.go b/services/webdav/pkg/thumbnail/haspreview.go index 8442357427..c05ae82496 100644 --- a/services/webdav/pkg/thumbnail/haspreview.go +++ b/services/webdav/pkg/thumbnail/haspreview.go @@ -9,14 +9,14 @@ import ( "github.com/opencloud-eu/opencloud/pkg/conversions" ) -// Arbitrary-metadata keys holding an embedded preview's dimensions (e.g. audio -// cover art), written at index time. Their presence signals a preview exists. +// Arbitrary-metadata keys holding a preview's dimensions and type, written at +// index time. Their presence signals a preview exists. const ( PreviewWidthKey = "oc.preview.width" PreviewHeightKey = "oc.preview.height" - // the type of the embedded image itself, which decides what a thumbnail of - // it is written in - PreviewContentTypeKey = "oc.preview.contentType" + // the type of the preview image, which decides what a thumbnail of it is + // written in + PreviewMimeTypeKey = "oc.preview.mimeType" ) // the image facet an image writes about itself at index time @@ -34,10 +34,10 @@ var EmbeddedSourceTypes = []string{ "audio/ogg", } -// IsEmbeddedSourceType reports whether the type carries its preview inside the -// file, which is what the index has to answer for. -func IsEmbeddedSourceType(contentType string) bool { - parsed, _, err := mime.ParseMediaType(contentType) +// IsEmbeddedSourceType reports whether the index has to answer for this type, +// because its preview sits inside the file. +func IsEmbeddedSourceType(mimeType string) bool { + parsed, _, err := mime.ParseMediaType(mimeType) if err != nil { return false } @@ -47,16 +47,14 @@ func IsEmbeddedSourceType(contentType string) bool { // HasPreview reports whether a preview can be produced for the resource: a type // that always has one says so by itself, a type that carries its preview inside -// the file only when the index saw one, with its size and the type it is in. -// Without that, nothing here could say what a thumbnail of it would be, and -// guessing is what this replaces. +// the file only when the index saw one. func (t SourceTypes) HasPreview(md *provider.ResourceInfo) bool { if md == nil { return false } w, h := PreviewDimensions(md) - return t.HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0 && PreviewContentType(md) != "") + return t.HasPreviewForMimeType(md.GetMimeType(), HasIndexedPreview(w, h, PreviewMimeType(md))) } // HasPreviewForMimeType is HasPreview for callers that only have the mimetype @@ -86,10 +84,16 @@ func SourceDimensions(md *provider.ResourceInfo) (int32, int32) { return conversions.StringToInt32(meta[imageWidthKey], 0), conversions.StringToInt32(meta[imageHeightKey], 0) } -// PreviewContentType is the type of the embedded image the index saw, empty -// when nothing is stored. -func PreviewContentType(md *provider.ResourceInfo) string { - return md.GetArbitraryMetadata().GetMetadata()[PreviewContentTypeKey] +// PreviewMimeType is the type of the preview image the index saw, empty when +// nothing is stored. +func PreviewMimeType(md *provider.ResourceInfo) string { + return md.GetArbitraryMetadata().GetMetadata()[PreviewMimeTypeKey] +} + +// HasIndexedPreview reports whether the index stored a usable preview: without +// its size and its type, nothing here could say what a thumbnail of it is. +func HasIndexedPreview(width, height int32, mimeType string) bool { + return width > 0 && height > 0 && mimeType != "" } // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). diff --git a/services/webdav/pkg/thumbnail/haspreview_test.go b/services/webdav/pkg/thumbnail/haspreview_test.go index 0d60ab98c2..e6c7f7f110 100644 --- a/services/webdav/pkg/thumbnail/haspreview_test.go +++ b/services/webdav/pkg/thumbnail/haspreview_test.go @@ -27,9 +27,9 @@ var _ = Describe("HasPreview", func() { Entry("audio without indexed dimensions has none", resource("audio/mpeg", nil), false), Entry("audio the index saw, with its type, has one", resource("audio/mpeg", map[string]string{ - thumbnail.PreviewWidthKey: "600", - thumbnail.PreviewHeightKey: "600", - thumbnail.PreviewContentTypeKey: "image/png", + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + thumbnail.PreviewMimeTypeKey: "image/png", }), true), Entry("audio the index measured but could not type has none", resource("audio/mpeg", map[string]string{ diff --git a/services/webdav/pkg/thumbnail/workflow/workflow.go b/services/webdav/pkg/thumbnail/workflow/workflow.go index c9569c8f7a..1db90a4485 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow.go @@ -435,7 +435,7 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R // the type is still known before the cache key is built. Without it, the // container's type decides and the cover is re-encoded. typeForOutput := mimeType - if embedded := thumbnail.PreviewContentType(info); embedded != "" { + if embedded := thumbnail.PreviewMimeType(info); embedded != "" { typeForOutput = embedded } outputType, outputFormat := w.outputFor(typeForOutput) diff --git a/services/webdav/pkg/thumbnail/workflow/workflow_test.go b/services/webdav/pkg/thumbnail/workflow/workflow_test.go index 470b265e47..318f33a631 100644 --- a/services/webdav/pkg/thumbnail/workflow/workflow_test.go +++ b/services/webdav/pkg/thumbnail/workflow/workflow_test.go @@ -288,9 +288,9 @@ func setupPreprocessTest( // indexedCover is what the search extraction leaves on a file whose cover it saw. func indexedCover(contentType string) map[string]string { return map[string]string{ - thumbnail.PreviewWidthKey: "600", - thumbnail.PreviewHeightKey: "600", - thumbnail.PreviewContentTypeKey: contentType, + thumbnail.PreviewWidthKey: "600", + thumbnail.PreviewHeightKey: "600", + thumbnail.PreviewMimeTypeKey: contentType, } } From 7aff838ffb9489bf297194d3494e821f9695d87a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 17:01:55 +0200 Subject: [PATCH 17/18] refactor(search): take the cover Tika marks as the thumbnail --- services/search/pkg/content/tika_preview.go | 33 ++++++------- .../search/pkg/content/tika_preview_test.go | 47 ++++++++++++------- services/search/pkg/content/tika_test.go | 2 +- 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index 763b5ddb4c..1a5c57741a 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -8,33 +8,33 @@ import ( "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) -// frontCoverDescription is the picture type Tika reports (as dc:description) for -// the front cover, matching the thumbnailer's cover selection. -const frontCoverDescription = "Cover (front)" +// thumbnailResourceType is how Tika marks the embedded image it considers the +// file's thumbnail. The unpacker's thumbnail preset extracts that same image, +// so the index reports what the thumbnailer serves. +const thumbnailResourceType = "THUMBNAIL" -// 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 the types that carry their preview inside the file. +// getPreview returns the size and type of the preview image Tika found inside +// the file. It only runs for the types that carry their preview inside. func getPreview(mimeType string, metas []map[string][]string) *Preview { if !thumbnail.IsEmbeddedSourceType(mimeType) { return nil } - var first *Preview for _, meta := range metas { + if resourceType, _ := getFirstValue(meta, "tk:embedded-resource-type"); resourceType != thumbnailResourceType { + continue + } ct, err := getFirstValue(meta, "Content-Type") if err != nil || !strings.HasPrefix(ct, "image/") { continue } - w, _ := getFirstValue(meta, "tiff:ImageWidth") - h, _ := getFirstValue(meta, "tiff:ImageLength") // Tika answers with a full header value, parameters included; what is // stored is the media type alone, the way a file's own type is stored mediaType, _, err := mime.ParseMediaType(ct) if err != nil { continue } - + w, _ := getFirstValue(meta, "tiff:ImageWidth") + h, _ := getFirstValue(meta, "tiff:ImageLength") preview := &Preview{ Width: conversions.StringToInt32(w, 0), Height: conversions.StringToInt32(h, 0), @@ -43,12 +43,9 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { if preview.Width <= 0 || preview.Height <= 0 { continue } - if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { - return preview - } - if first == nil { - first = preview - } + + return preview } - return first + + return nil } diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index 6f613c41c0..d9e6084317 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -8,18 +8,23 @@ import ( var _ = Describe("getPreview", func() { audio := map[string][]string{"Content-Type": {"audio/mpeg"}} cover := map[string][]string{ - "Content-Type": {"image/jpeg"}, - "tiff:ImageWidth": {"500"}, - "tiff:ImageLength": {"400"}, + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"500"}, + "tiff:ImageLength": {"400"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + coverNoDims := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, } - coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} coverWithParams := map[string][]string{ - "Content-Type": {"image/jpeg; quality=90"}, - "tiff:ImageWidth": {"80"}, - "tiff:ImageLength": {"60"}, + "Content-Type": {"image/jpeg; quality=90"}, + "tiff:ImageWidth": {"80"}, + "tiff:ImageLength": {"60"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, } - It("returns the embedded cover dimensions for audio", func() { + It("returns the cover Tika marks as the thumbnail", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) Expect(p).ToNot(BeNil()) Expect(*p).To(Equal(Preview{Width: 500, Height: 400, MimeType: "image/jpeg"})) @@ -35,22 +40,32 @@ var _ = Describe("getPreview", func() { Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil()) }) - It("returns nil when the cover lacks dimensions", func() { + It("returns nil when the marked cover lacks dimensions", func() { Expect(getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims})).To(BeNil()) }) - It("prefers the front cover over an earlier back cover", func() { - back := map[string][]string{ - "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (back)"}, + It("leaves the selection to Tika, whatever the order", func() { + inline := map[string][]string{ + "Content-Type": {"image/jpeg"}, "dc:description": {"Other"}, "tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"}, + "tk:embedded-resource-type": {"INLINE"}, } - front := map[string][]string{ - "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (front)"}, + marked := map[string][]string{ + "Content-Type": {"image/png"}, "dc:description": {"Cover (front)"}, "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, } - p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) + p := getPreview("audio/mpeg", []map[string][]string{audio, inline, marked}) Expect(p).ToNot(BeNil()) - Expect(*p).To(Equal(Preview{Width: 64, Height: 40, MimeType: "image/jpeg"}), "the type decides what a thumbnail of the cover is written in") + Expect(*p).To(Equal(Preview{Width: 64, Height: 40, MimeType: "image/png"}), "the type decides what a thumbnail of the cover is written in") + }) + + It("ignores an embedded image Tika does not mark", func() { + unmarked := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"90"}, "tiff:ImageLength": {"90"}, + } + Expect(getPreview("audio/mpeg", []map[string][]string{audio, unmarked})).To(BeNil()) }) It("is gated to embedded-preview types", func() { diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index a004d3c0bf..63122237d7 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -171,7 +171,7 @@ var _ = Describe("Tika", func() { }) 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"}]` + fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500", "tiff:ImageLength": "400", "tk:embedded-resource-type": "THUMBNAIL"}]` doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ Type: provider.ResourceType_RESOURCE_TYPE_FILE, From a860742d942c0da735744f35e8b8f485d7486eb4 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Mon, 19 Oct 2026 17:09:37 +0200 Subject: [PATCH 18/18] feat(search): ask Tika for the thumbnail preset, take the image it marks --- services/search/pkg/content/tika.go | 81 +++++++++++++++++-- services/search/pkg/content/tika_preview.go | 23 +++--- .../search/pkg/content/tika_preview_test.go | 29 ++++--- services/search/pkg/content/tika_test.go | 20 ++++- 4 files changed, 124 insertions(+), 29 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index bc18482f4f..86612e461b 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -2,9 +2,11 @@ package content import ( "context" + "encoding/json" "fmt" "io" "net/http" + "reflect" "slices" "strings" @@ -17,6 +19,11 @@ import ( "github.com/opencloud-eu/opencloud/services/search/pkg/config" ) +// rmetaThumbnailPath asks for the recursive metadata with Tika's thumbnail +// preset, so Tika names the image a client would show and renders one where +// the format needs that, a pdf page for one. +const rmetaThumbnailPath = "/rmeta/preset/thumbnail/text" + // Tika is used to extract content from a resource, // it uses apache tika to retrieve all the data. type Tika struct { @@ -72,13 +79,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } - data, err := t.Retrieve(ctx, ri.Id) - if err != nil { - return doc, err - } - defer data.Close() - - metas, err := t.tika.MetaRecursive(ctx, data) + metas, err := t.metaRecursive(ctx, ri.Id) if err != nil { return doc, err } @@ -118,7 +119,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.MotionPhoto = t.getMotionPhoto(m0, metas[i+1]) } - doc.Preview = getPreview(ri.GetMimeType(), metas) + doc.Preview = getPreview(metas) if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords { doc.Content = CleanString(doc.Content, langCode) @@ -127,6 +128,70 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } +// metaRecursive is Tika's recursive metadata, asked for with the thumbnail +// preset so the index learns about the same image the thumbnailer serves. +func (t Tika) metaRecursive(ctx context.Context, id *provider.ResourceId) ([]map[string][]string, error) { + data, err := t.Retrieve(ctx, id) + if err != nil { + return nil, err + } + defer data.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, t.tikaURL+rmetaThumbnailPath, data) + if err != nil { + return nil, err + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, fmt.Errorf("tika serves no %s, its configuration has to activate the thumbnail preset", rmetaThumbnailPath) + default: + return nil, fmt.Errorf("tika %s: %s", rmetaThumbnailPath, res.Status) + } + + return decodeMetas(res.Body) +} + +// decodeMetas reads Tika's per-part metadata, whose values are a string or a +// list of them. +func decodeMetas(r io.Reader) ([]map[string][]string, error) { + var parts []map[string]any + if err := json.NewDecoder(r).Decode(&parts); err != nil { + return nil, err + } + + metas := make([]map[string][]string, 0, len(parts)) + for _, part := range parts { + meta := make(map[string][]string, len(part)) + for key, value := range part { + switch typed := value.(type) { + case string: + meta[key] = []string{typed} + case []any: + for _, entry := range typed { + s, ok := entry.(string) + if !ok { + return nil, fmt.Errorf("field %q holds %v of type %T, want a string", key, entry, entry) + } + meta[key] = append(meta[key], s) + } + default: + return nil, fmt.Errorf("field %q holds %v of type %v, want a string or a list of them", key, value, reflect.TypeOf(value)) + } + } + metas = append(metas, meta) + } + + return metas, nil +} + // detectLanguage asks tika for the language of content. Tika 4 moved the // endpoint from /language/string to /language, so try the new path first and // fall back for an older tika. diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index 1a5c57741a..4f7d68c344 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -2,25 +2,26 @@ package content import ( "mime" + "slices" "strings" "github.com/opencloud-eu/opencloud/pkg/conversions" - "github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail" ) -// thumbnailResourceType is how Tika marks the embedded image it considers the -// file's thumbnail. The unpacker's thumbnail preset extracts that same image, -// so the index reports what the thumbnailer serves. -const thumbnailResourceType = "THUMBNAIL" +// thumbnailResourceTypes are how Tika marks the image it considers the file's +// thumbnail: one stored inside the file, an audio cover or a document's own +// thumbnail, or one Tika rendered for the purpose, a pdf's first page. The +// unpacker's thumbnail preset extracts that same image, so the index reports +// what the thumbnailer serves. A file that needs no such image, an image +// itself for one, has no marked part. +var thumbnailResourceTypes = []string{"THUMBNAIL", "RENDERING"} // getPreview returns the size and type of the preview image Tika found inside -// the file. It only runs for the types that carry their preview inside. -func getPreview(mimeType string, metas []map[string][]string) *Preview { - if !thumbnail.IsEmbeddedSourceType(mimeType) { - return nil - } +// the file, or nil when Tika marked none. +func getPreview(metas []map[string][]string) *Preview { for _, meta := range metas { - if resourceType, _ := getFirstValue(meta, "tk:embedded-resource-type"); resourceType != thumbnailResourceType { + resourceType, _ := getFirstValue(meta, "tk:embedded-resource-type") + if !slices.Contains(thumbnailResourceTypes, resourceType) { continue } ct, err := getFirstValue(meta, "Content-Type") diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index d9e6084317..b8195fbf1a 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -25,23 +25,23 @@ var _ = Describe("getPreview", func() { } It("returns the cover Tika marks as the thumbnail", func() { - p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) + p := getPreview([]map[string][]string{audio, cover}) Expect(p).ToNot(BeNil()) Expect(*p).To(Equal(Preview{Width: 500, Height: 400, MimeType: "image/jpeg"})) }) It("stores the media type without the parameters Tika sends", func() { - p := getPreview("audio/mpeg", []map[string][]string{audio, coverWithParams}) + p := getPreview([]map[string][]string{audio, coverWithParams}) Expect(p).ToNot(BeNil()) Expect(p.MimeType).To(Equal("image/jpeg")) }) It("returns nil when the audio has no cover", func() { - Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil()) + Expect(getPreview([]map[string][]string{audio})).To(BeNil()) }) It("returns nil when the marked cover lacks dimensions", func() { - Expect(getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims})).To(BeNil()) + Expect(getPreview([]map[string][]string{audio, coverNoDims})).To(BeNil()) }) It("leaves the selection to Tika, whatever the order", func() { @@ -55,7 +55,7 @@ var _ = Describe("getPreview", func() { "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, "tk:embedded-resource-type": {"THUMBNAIL"}, } - p := getPreview("audio/mpeg", []map[string][]string{audio, inline, marked}) + p := getPreview([]map[string][]string{audio, inline, marked}) Expect(p).ToNot(BeNil()) Expect(*p).To(Equal(Preview{Width: 64, Height: 40, MimeType: "image/png"}), "the type decides what a thumbnail of the cover is written in") }) @@ -65,11 +65,22 @@ var _ = Describe("getPreview", func() { "Content-Type": {"image/jpeg"}, "tiff:ImageWidth": {"90"}, "tiff:ImageLength": {"90"}, } - Expect(getPreview("audio/mpeg", []map[string][]string{audio, unmarked})).To(BeNil()) + Expect(getPreview([]map[string][]string{audio, unmarked})).To(BeNil()) }) - 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()) + It("takes a document's stored thumbnail the same way", func() { + odt := map[string][]string{"Content-Type": {"application/vnd.oasis.opendocument.text"}} + stored := map[string][]string{ + "Content-Type": {"image/png"}, + "tiff:ImageWidth": {"362"}, "tiff:ImageLength": {"512"}, + "tk:embedded-resource-type": {"THUMBNAIL"}, + } + p := getPreview([]map[string][]string{odt, stored}) + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 362, Height: 512, MimeType: "image/png"})) + }) + + It("returns nil for a file Tika marks nothing in, an image for one", func() { + Expect(getPreview([]map[string][]string{{"Content-Type": {"image/png"}, "tiff:ImageWidth": {"10"}, "tiff:ImageLength": {"10"}}})).To(BeNil()) }) }) diff --git a/services/search/pkg/content/tika_test.go b/services/search/pkg/content/tika_test.go index 63122237d7..83a076751b 100644 --- a/services/search/pkg/content/tika_test.go +++ b/services/search/pkg/content/tika_test.go @@ -25,6 +25,7 @@ var _ = Describe("Tika", func() { var ( body string fullResponse string + presetCalls int language string tika4Language bool version string @@ -38,6 +39,7 @@ var _ = Describe("Tika", func() { tika4Language = false version = "" fullResponse = "" + presetCalls = 0 srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { out := "" switch req.URL.Path { @@ -52,7 +54,8 @@ var _ = Describe("Tika", func() { } case "/language/string": out = language - case "/rmeta/text": + case "/rmeta/preset/thumbnail/text": + presetCalls++ if fullResponse != "" { out = fullResponse } else { @@ -187,6 +190,21 @@ var _ = Describe("Tika", func() { Expect(doc.Preview.Height).To(Equal(int32(400))) }) + It("asks for the thumbnail preset, so Tika names the image", func() { + fullResponse = `[{"Content-Type": "application/pdf", "tk:content": "text"}, {"Content-Type": "image/png", "tiff:ImageWidth": "181", "tiff:ImageLength": "255", "tk:embedded-resource-type": "RENDERING"}]` + + doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{ + Type: provider.ResourceType_RESOURCE_TYPE_FILE, + Size: 1, + MimeType: "application/pdf", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(presetCalls).To(Equal(1)) + Expect(doc.Preview).ToNot(BeNil()) + Expect(doc.Preview.Width).To(Equal(int32(181))) + Expect(doc.Preview.MimeType).To(Equal("image/png")) + }) + It("adds no audio facet to non-audio documents", func() { fullResponse = `[{"Content-Type": "application/pdf", "dc:title": "quarterly report"}]`