From 338ed402ed5fda0519801499aa5171edd1c8d938 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 17:34:35 +0200 Subject: [PATCH 01/20] feat(thumbnails): split preview mime types + add HasPreview Split SupportedMimeTypes into UnconditionalPreviewMimeTypes (preview follows from the mimetype) and EmbeddedPreviewMimeTypes (audio cover art, may be absent); SupportedMimeTypes stays as their union for the generator. Add HasPreview(md) as the single source of truth: unconditional types are always true, embedded types depend on stored oc.preview dimensions. --- .../thumbnails/pkg/thumbnail/haspreview.go | 60 +++++++++++++++++++ .../pkg/thumbnail/haspreview_test.go | 55 +++++++++++++++++ .../thumbnails/pkg/thumbnail/mimetypes.go | 33 +++++----- .../pkg/thumbnail/mimetypes_common.go | 26 ++++++++ .../pkg/thumbnail/mimetypes_vips.go | 35 +++++------ 5 files changed, 172 insertions(+), 37 deletions(-) create mode 100644 services/thumbnails/pkg/thumbnail/haspreview.go create mode 100644 services/thumbnails/pkg/thumbnail/haspreview_test.go create mode 100644 services/thumbnails/pkg/thumbnail/mimetypes_common.go diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go new file mode 100644 index 0000000000..5af6c07b8e --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -0,0 +1,60 @@ +package thumbnail + +import ( + "strconv" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +// Arbitrary-metadata keys under which the content extraction pipeline stores the +// dimensions of an embedded preview (for example audio cover art). These are an +// internal signal, not a Microsoft Graph facet. Their presence means the file +// carries an embedded preview. +const ( + PreviewWidthKey = "oc.preview.width" + PreviewHeightKey = "oc.preview.height" +) + +// HasPreview reports whether a thumbnail/preview can be produced for the given +// resource. For unconditional types it follows from the mimetype alone. For +// embedded-preview types (audio cover art) it depends on whether an embedded +// preview was detected at index time, signalled by the stored preview +// dimensions. Files that have not been indexed yet report no preview rather than +// promising one that would fail to render. +func HasPreview(md *provider.ResourceInfo) bool { + if md == nil { + return false + } + + mimeType := md.GetMimeType() + if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { + return true + } + if _, ok := EmbeddedPreviewMimeTypes[mimeType]; ok { + w, h := PreviewDimensions(md) + return w > 0 && h > 0 + } + return false +} + +// PreviewDimensions returns the stored dimensions of a resource's embedded +// preview, or (0, 0) if none were recorded. Only meaningful for +// EmbeddedPreviewMimeTypes. +func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { + meta := md.GetArbitraryMetadata().GetMetadata() + if meta == nil { + return 0, 0 + } + return parseInt32(meta[PreviewWidthKey]), parseInt32(meta[PreviewHeightKey]) +} + +func parseInt32(s string) int32 { + if s == "" { + return 0 + } + v, err := strconv.ParseInt(s, 10, 32) + if err != nil { + return 0 + } + return int32(v) +} diff --git a/services/thumbnails/pkg/thumbnail/haspreview_test.go b/services/thumbnails/pkg/thumbnail/haspreview_test.go new file mode 100644 index 0000000000..7d446ef70c --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/haspreview_test.go @@ -0,0 +1,55 @@ +package thumbnail + +import ( + "testing" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +func resourceInfo(mime string, meta map[string]string) *provider.ResourceInfo { + ri := &provider.ResourceInfo{MimeType: mime} + if meta != nil { + ri.ArbitraryMetadata = &provider.ArbitraryMetadata{Metadata: meta} + } + return ri +} + +func TestHasPreview(t *testing.T) { + cases := []struct { + name string + md *provider.ResourceInfo + want bool + }{ + {"nil", nil, false}, + {"unconditional image", resourceInfo("image/png", nil), true}, + {"unconditional text", resourceInfo("text/plain", nil), true}, + {"unsupported type", resourceInfo("application/pdf", nil), false}, + {"audio without preview dims", resourceInfo("audio/mpeg", nil), false}, + {"audio with empty dims", resourceInfo("audio/mpeg", map[string]string{ + PreviewWidthKey: "0", PreviewHeightKey: "0", + }), false}, + {"audio with preview dims", resourceInfo("audio/mpeg", map[string]string{ + PreviewWidthKey: "500", PreviewHeightKey: "500", + }), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := HasPreview(tc.md); got != tc.want { + t.Errorf("HasPreview(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +func TestSupportedMimeTypesIsUnion(t *testing.T) { + for k := range UnconditionalPreviewMimeTypes { + if _, ok := SupportedMimeTypes[k]; !ok { + t.Errorf("SupportedMimeTypes missing unconditional type %q", k) + } + } + for k := range EmbeddedPreviewMimeTypes { + if _, ok := SupportedMimeTypes[k]; !ok { + t.Errorf("SupportedMimeTypes missing embedded type %q", k) + } + } +} diff --git a/services/thumbnails/pkg/thumbnail/mimetypes.go b/services/thumbnails/pkg/thumbnail/mimetypes.go index 02a91e22f5..5dc32cbe74 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes.go @@ -2,21 +2,18 @@ package thumbnail -var ( - // SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer. - SupportedMimeTypes = map[string]struct{}{ - "image/png": {}, - "image/jpg": {}, - "image/jpeg": {}, - "image/gif": {}, - "image/bmp": {}, - "image/x-ms-bmp": {}, - "image/tiff": {}, - "text/plain": {}, - "audio/flac": {}, - "audio/mpeg": {}, - "audio/ogg": {}, - "application/vnd.geogebra.slides": {}, - "application/vnd.geogebra.pinboard": {}, - } -) +// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows +// from the mimetype alone: the thumbnailer can always render a preview from the +// content, so a preview is guaranteed to exist. +var UnconditionalPreviewMimeTypes = map[string]struct{}{ + "image/png": {}, + "image/jpg": {}, + "image/jpeg": {}, + "image/gif": {}, + "image/bmp": {}, + "image/x-ms-bmp": {}, + "image/tiff": {}, + "text/plain": {}, + "application/vnd.geogebra.slides": {}, + "application/vnd.geogebra.pinboard": {}, +} diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_common.go b/services/thumbnails/pkg/thumbnail/mimetypes_common.go new file mode 100644 index 0000000000..a774dd7d68 --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/mimetypes_common.go @@ -0,0 +1,26 @@ +package thumbnail + +// EmbeddedPreviewMimeTypes are mimetypes whose preview is an embedded resource +// (for example audio cover art) that may or may not be present. Preview +// availability cannot be derived from the mimetype alone and must be determined +// per file (see HasPreview). +var EmbeddedPreviewMimeTypes = map[string]struct{}{ + "audio/flac": {}, + "audio/mpeg": {}, + "audio/ogg": {}, +} + +// SupportedMimeTypes contains all mimetypes the thumbnailer can produce a +// thumbnail for: the union of the unconditional and embedded preview types. +// The generator gates on this union; preview availability per file is decided +// by HasPreview. +var SupportedMimeTypes = func() map[string]struct{} { + m := make(map[string]struct{}, len(UnconditionalPreviewMimeTypes)+len(EmbeddedPreviewMimeTypes)) + for k := range UnconditionalPreviewMimeTypes { + m[k] = struct{}{} + } + for k := range EmbeddedPreviewMimeTypes { + m[k] = struct{}{} + } + return m +}() diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go index b94fafafaf..677ae43d86 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go @@ -2,22 +2,19 @@ package thumbnail -var ( - // SupportedMimeTypes contains an all mimetypes which are supported by the thumbnailer. - SupportedMimeTypes = map[string]struct{}{ - "image/png": {}, - "image/jpg": {}, - "image/jpeg": {}, - "image/gif": {}, - "image/bmp": {}, - "image/x-ms-bmp": {}, - "image/tiff": {}, - "text/plain": {}, - "audio/flac": {}, - "audio/mpeg": {}, - "audio/ogg": {}, - "application/vnd.geogebra.slides": {}, - "application/vnd.geogebra.pinboard": {}, - "image/webp": {}, - } -) +// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows +// from the mimetype alone: the thumbnailer can always render a preview from the +// content, so a preview is guaranteed to exist. +var UnconditionalPreviewMimeTypes = map[string]struct{}{ + "image/png": {}, + "image/jpg": {}, + "image/jpeg": {}, + "image/gif": {}, + "image/bmp": {}, + "image/x-ms-bmp": {}, + "image/tiff": {}, + "image/webp": {}, + "text/plain": {}, + "application/vnd.geogebra.slides": {}, + "application/vnd.geogebra.pinboard": {}, +} From e2b7574ad76dc6f934621da38ce6685b94bb4d0a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 17:42:49 +0200 Subject: [PATCH 02/20] feat(search): index embedded preview dimensions as oc.preview For embedded-preview mime types (audio cover art), extract the embedded cover's dimensions from Tika's recursive metadata (requires TIKA-4801) and store them as oc.preview.width/height in the search index and arbitrary metadata, routed through the shared facet-to-metadata flattening. Presence of the dimensions is the preview-presence signal consumed by thumbnail.HasPreview. --- services/search/pkg/content/content.go | 21 ++++++++++ services/search/pkg/content/tika.go | 32 +++++++++++++++ .../search/pkg/content/tika_preview_test.go | 40 +++++++++++++++++++ services/search/pkg/search/service.go | 1 + 4 files changed, 94 insertions(+) create mode 100644 services/search/pkg/content/tika_preview_test.go diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index a789847244..8f9d97551e 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -27,6 +27,27 @@ type Document struct { Image *libregraph.Image `json:"image,omitempty"` Location *libregraph.GeoCoordinates `json:"location,omitempty"` Photo *libregraph.Photo `json:"photo,omitempty"` + Preview *Preview `json:"preview,omitempty"` +} + +// Preview holds the dimensions of an embedded preview image (for example audio +// cover art) for content types whose thumbnail is embedded rather than rendered +// and may therefore be absent. It is an internal signal, not a Microsoft Graph +// facet: its presence marks that a preview exists for the resource. +type Preview struct { + Width int32 `json:"width"` + Height int32 `json:"height"` +} + +// ToMap lets Preview flow through the same facet-to-metadata flattening as the +// Microsoft Graph facets, so it is stored under the oc.preview. prefix (keys +// oc.preview.width / oc.preview.height, matching thumbnail.PreviewWidthKey / +// thumbnail.PreviewHeightKey). Preview is not itself a Graph facet. +func (p Preview) ToMap() (map[string]interface{}, error) { + return map[string]interface{}{ + "width": p.Width, + "height": p.Height, + }, nil } func CleanString(content, langCode string) string { diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 25e733ff65..44521f299d 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -3,6 +3,7 @@ package content import ( "context" "fmt" + "strconv" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -12,6 +13,7 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/search/pkg/config" + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) // Tika is used to extract content from a resource, @@ -93,9 +95,39 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, doc.Audio = t.getAudio(meta) } + doc.Preview = getPreview(ri.GetMimeType(), metas) + if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords { doc.Content = CleanString(doc.Content, langCode) } return doc, nil } + +// getPreview extracts the dimensions of an embedded preview image for content +// whose thumbnail is embedded rather than rendered (audio cover art). Tika with +// TIKA-4801 surfaces the embedded cover as an additional image entry carrying +// tiff dimensions. It only runs for EmbeddedPreviewMimeTypes; unconditional +// types have their preview availability decided by the mimetype alone. +func getPreview(mimeType string, metas []map[string][]string) *Preview { + if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { + return nil + } + for _, meta := range metas { + ct, err := getFirstValue(meta, "Content-Type") + if err != nil || !strings.HasPrefix(ct, "image/") { + continue + } + w, wErr := getFirstValue(meta, "tiff:ImageWidth") + h, hErr := getFirstValue(meta, "tiff:ImageLength") + if wErr != nil || hErr != nil { + continue + } + width, wErr := strconv.ParseInt(w, 10, 32) + height, hErr := strconv.ParseInt(h, 10, 32) + if wErr == nil && hErr == nil && width > 0 && height > 0 { + return &Preview{Width: int32(width), Height: int32(height)} + } + } + return nil +} diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go new file mode 100644 index 0000000000..d626a6c1f6 --- /dev/null +++ b/services/search/pkg/content/tika_preview_test.go @@ -0,0 +1,40 @@ +package content + +import "testing" + +func TestGetPreview(t *testing.T) { + audio := map[string][]string{"Content-Type": {"audio/mpeg"}} + cover := map[string][]string{ + "Content-Type": {"image/jpeg"}, + "tiff:ImageWidth": {"500"}, + "tiff:ImageLength": {"400"}, + } + coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} + + t.Run("audio with embedded cover returns dims", func(t *testing.T) { + p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) + if p == nil || p.Width != 500 || p.Height != 400 { + t.Fatalf("expected 500x400, got %+v", p) + } + }) + + t.Run("audio without cover returns nil", func(t *testing.T) { + if p := getPreview("audio/mpeg", []map[string][]string{audio}); p != nil { + t.Fatalf("expected nil, got %+v", p) + } + }) + + t.Run("audio with cover lacking dims returns nil", func(t *testing.T) { + if p := getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims}); p != nil { + t.Fatalf("expected nil, got %+v", p) + } + }) + + t.Run("non-embedded type is gated out", func(t *testing.T) { + // an image file is unconditional; its preview is not driven by oc.preview, + // so getPreview must return nil even though an image meta is present. + if p := getPreview("image/png", []map[string][]string{cover}); p != nil { + t.Fatalf("expected nil for non-embedded type, got %+v", p) + } + }) +} diff --git a/services/search/pkg/search/service.go b/services/search/pkg/search/service.go index 2c8221b2a7..1b190710b7 100644 --- a/services/search/pkg/search/service.go +++ b/services/search/pkg/search/service.go @@ -633,6 +633,7 @@ func (s *Service) doUpsertItem(ref *provider.Reference, batch BatchOperator) { facetToMetadata(metadata, doc.Image, "libre.graph.image.") facetToMetadata(metadata, doc.Location, "libre.graph.location.") facetToMetadata(metadata, doc.Photo, "libre.graph.photo.") + facetToMetadata(metadata, doc.Preview, "oc.preview.") if len(metadata) == 0 { return } From 0cd17375994da93715aeed201d5ca92e4d1e51d1 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 17:46:08 +0200 Subject: [PATCH 03/20] fix(search): derive facets from container metadata only With embedded resources now surfaced by Tika (cover art via TIKA-4801), the per-meta facet loop let an embedded image overwrite the container's facets: audio files ended up with a bogus image facet (the cover's dimensions) and a wiped audio facet. Take audio/image/photo/location facets from the container entry only; embedded cover dimensions are captured as the preview instead. --- services/search/pkg/content/tika.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 44521f299d..922c1f3b11 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -79,7 +79,12 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if err != nil { return doc, err } + if len(metas) == 0 { + return doc, nil + } + // Title and content are aggregated across the container and all embedded + // resources (e.g. text embedded in a document). for _, meta := range metas { if title, err := getFirstValue(meta, "title"); err == nil { doc.Title = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Title, title)) @@ -88,13 +93,17 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, if content, err := getFirstValue(meta, "X-TIKA:content"); err == nil { doc.Content = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Content, content)) } - - doc.Location = t.getLocation(meta) - doc.Image = t.getImage(meta) - doc.Photo = t.getPhoto(meta) - doc.Audio = t.getAudio(meta) } + // Facets describe the resource itself, so they are taken from the container + // (the first entry). Its embedded resources, such as audio cover art, must + // not leak into them; the cover's dimensions become the preview instead. + container := metas[0] + doc.Location = t.getLocation(container) + doc.Image = t.getImage(container) + doc.Photo = t.getPhoto(container) + doc.Audio = t.getAudio(container) + doc.Preview = getPreview(ri.GetMimeType(), metas) if langCode, _ := t.tika.LanguageString(ctx, doc.Content); langCode != "" && t.CleanStopWords { From 9b838142fe05a91899c35fd282407c7f74cbe64e Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 18:00:57 +0200 Subject: [PATCH 04/20] feat(search): prefer the front cover for the preview dimensions getPreview now prefers the embedded image tagged as the front cover (dc:description "Cover (front)") and falls back to the first embedded image, matching the thumbnailer's cover selection so the reported oc.preview dimensions belong to the picture that actually gets rendered. --- services/search/pkg/content/tika.go | 29 ++++++++++++++----- .../search/pkg/content/tika_preview_test.go | 15 ++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index 922c1f3b11..c2a687da1a 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -113,15 +113,23 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } -// getPreview extracts the dimensions of an embedded preview image for content +// frontCoverDescription is the picture type Tika reports (as dc:description) for +// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. +const frontCoverDescription = "Cover (front)" + +// getPreview extracts the dimensions of the embedded preview image for content // whose thumbnail is embedded rather than rendered (audio cover art). Tika with -// TIKA-4801 surfaces the embedded cover as an additional image entry carrying -// tiff dimensions. It only runs for EmbeddedPreviewMimeTypes; unconditional -// types have their preview availability decided by the mimetype alone. +// TIKA-4801 surfaces embedded covers as image entries carrying tiff dimensions +// and the picture type as dc:description. It prefers the front cover and falls +// back to the first embedded image, matching the thumbnailer's cover selection, +// so the reported dimensions belong to the picture that actually gets rendered. +// It only runs for EmbeddedPreviewMimeTypes; unconditional types have their +// preview availability decided by the mimetype alone. func getPreview(mimeType string, metas []map[string][]string) *Preview { if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { return nil } + var first *Preview for _, meta := range metas { ct, err := getFirstValue(meta, "Content-Type") if err != nil || !strings.HasPrefix(ct, "image/") { @@ -134,9 +142,16 @@ func getPreview(mimeType string, metas []map[string][]string) *Preview { } width, wErr := strconv.ParseInt(w, 10, 32) height, hErr := strconv.ParseInt(h, 10, 32) - if wErr == nil && hErr == nil && width > 0 && height > 0 { - return &Preview{Width: int32(width), Height: int32(height)} + if wErr != nil || hErr != nil || width <= 0 || height <= 0 { + continue + } + preview := &Preview{Width: int32(width), Height: int32(height)} + if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { + return preview + } + if first == nil { + first = preview } } - return nil + return first } diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index d626a6c1f6..6bd069d8cc 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -30,6 +30,21 @@ func TestGetPreview(t *testing.T) { } }) + t.Run("prefers the front cover over an earlier back cover", func(t *testing.T) { + back := map[string][]string{ + "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (back)"}, + "tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"}, + } + front := map[string][]string{ + "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (front)"}, + "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, + } + p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) + if p == nil || p.Width != 64 || p.Height != 40 { + t.Fatalf("expected front cover 64x40, got %+v", p) + } + }) + t.Run("non-embedded type is gated out", func(t *testing.T) { // an image file is unconditional; its preview is not driven by oc.preview, // so getPreview must return nil even though an image meta is present. From 0962e85cda4d1a304c78b733842769b13ffd7991 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 18:09:32 +0200 Subject: [PATCH 05/20] feat(graph): thumbnails relationship on driveItem listings Add a shared helper that builds the thumbnails relationship from a resource's info, gated on thumbnail.HasPreview so audio without embedded cover art no longer advertises a preview that fails to render. Reported dimensions are aspect-correct (fit into the box) rather than square, with a source thumbnail carrying the native dimensions (audio cover from oc.preview, images from the image facet). Wired into the drive children listings on $expand=thumbnails. --- services/graph/pkg/service/v0/driveitems.go | 2 + services/graph/pkg/service/v0/thumbnails.go | 133 ++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 services/graph/pkg/service/v0/thumbnails.go diff --git a/services/graph/pkg/service/v0/driveitems.go b/services/graph/pkg/service/v0/driveitems.go index 1acd7b9215..6f4f6172c7 100644 --- a/services/graph/pkg/service/v0/driveitems.go +++ b/services/graph/pkg/service/v0/driveitems.go @@ -209,6 +209,7 @@ func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } + g.setDriveItemsThumbnails(r, files, lRes.GetInfos()) render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: files}) @@ -341,6 +342,7 @@ func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) { errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } + g.setDriveItemsThumbnails(r, files, res.GetInfos()) render.Status(r, http.StatusOK) render.JSON(w, r, &ListResponse{Value: files}) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go new file mode 100644 index 0000000000..4f355012fd --- /dev/null +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -0,0 +1,133 @@ +package svc + +import ( + "fmt" + "math" + "net/http" + "strconv" + "strings" + + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + libregraph "github.com/opencloud-eu/libre-graph-api-go" + "github.com/opencloud-eu/reva/v2/pkg/storagespace" + + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" +) + +// thumbnailsExpanded reports whether the request asked for the thumbnails +// relationship to be expanded ($expand=thumbnails). +func thumbnailsExpanded(r *http.Request) bool { + return strings.Contains(r.URL.Query().Get("$expand"), "thumbnails") +} + +// setDriveItemsThumbnails populates the thumbnails relationship on driveItems +// (1:1 with infos) when the request asked for it. Resource infos carry the +// mimetype and the arbitrary metadata (image / oc.preview dimensions) that drive +// the decision, so no extra stat is needed. +func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.DriveItem, infos []*provider.ResourceInfo) { + if !thumbnailsExpanded(r) { + return + } + for i := range items { + if i < len(infos) { + setDriveItemThumbnails(items[i], infos[i], g.config.Commons.OpenCloudURL) + } + } +} + +// Bounding boxes for the driveItem thumbnails relationship. The thumbnails +// endpoint scales the source to fit these preserving aspect ratio (scalingup=0). +// +// Note: the endpoint rounds a requested box up to the nearest configured +// THUMBNAILS_RESOLUTIONS via ClosestMatch, so the reported dimensions are +// aspect-correct for the requested box but not necessarily the exact served +// resolution. Making them exact would require the resolution list to be +// available in the graph service; this is a possible future refinement and is +// no worse than the previous behaviour (which reported no dimensions at all). +const ( + thumbnailBoxSmall = 36 + thumbnailBoxMedium = 48 + thumbnailBoxLarge = 96 +) + +// setDriveItemThumbnails populates the thumbnails relationship of a driveItem +// from its resource info when a preview is available. It is a no-op when no +// preview exists (for example audio without embedded cover art), which keeps the +// relationship honest instead of advertising a preview that fails to render. +func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string) { + if set := previewThumbnailSet(res, baseURL); set != nil { + item.SetThumbnails([]libregraph.ThumbnailSet{*set}) + } +} + +// previewThumbnailSet builds the thumbnails relationship entry for a resource, +// or nil when no preview is available. When the source dimensions are known the +// reported thumbnail dimensions are aspect-correct rather than square, and a +// source (original) thumbnail carrying the native dimensions is included. +func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet { + if !thumbnail.HasPreview(res) { + return nil + } + + base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", + baseURL, storagespace.FormatResourceID(res.GetId())) + srcW, srcH := previewSourceDimensions(res) + + set := &libregraph.ThumbnailSet{ + Small: previewThumbnail(base, thumbnailBoxSmall, srcW, srcH), + Medium: previewThumbnail(base, thumbnailBoxMedium, srcW, srcH), + Large: previewThumbnail(base, thumbnailBoxLarge, srcW, srcH), + } + if srcW > 0 && srcH > 0 { + url := fmt.Sprintf("%s&x=%d&y=%d", base, srcW, srcH) + set.Source = &libregraph.Thumbnail{Url: &url, Width: &srcW, Height: &srcH} + } + return set +} + +func previewThumbnail(base string, box, srcW, srcH int32) *libregraph.Thumbnail { + url := fmt.Sprintf("%s&x=%d&y=%d", base, box, box) + t := &libregraph.Thumbnail{Url: &url} + if srcW > 0 && srcH > 0 { + w, h := fitBox(srcW, srcH, box) + t.Width, t.Height = &w, &h + } + return t +} + +// previewSourceDimensions returns the native dimensions of a resource's preview: +// the embedded cover dimensions for audio (from oc.preview), or the image facet +// dimensions for images. Zero when unknown. +func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { + if w, h := thumbnail.PreviewDimensions(res); w > 0 && h > 0 { + return w, h + } + meta := res.GetArbitraryMetadata().GetMetadata() + return parsePreviewInt(meta["libre.graph.image.width"]), parsePreviewInt(meta["libre.graph.image.height"]) +} + +// fitBox scales (w, h) to fit within a box×box square preserving aspect ratio, +// never upscaling, matching the thumbnails endpoint (scalingup=0). +func fitBox(w, h, box int32) (int32, int32) { + if w <= box && h <= box { + return w, h + } + scale := math.Min(float64(box)/float64(w), float64(box)/float64(h)) + rw := int32(math.Round(float64(w) * scale)) + rh := int32(math.Round(float64(h) * scale)) + if rw < 1 { + rw = 1 + } + if rh < 1 { + rh = 1 + } + return rw, rh +} + +func parsePreviewInt(s string) int32 { + v, err := strconv.ParseInt(s, 10, 32) + if err != nil { + return 0 + } + return int32(v) +} From 04e5a921ba009ce75443c393dae7cb36124a0df9 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 18:23:02 +0200 Subject: [PATCH 06/20] feat(search): honest has-preview for audio in search results Add a preview facet to the search message entity carrying the embedded preview dimensions, mapped from the index in both engines. The webdav search report now derives oc:has-preview via thumbnail.HasPreviewForMimeType: unconditional types stay true, embedded-preview types (audio) are true only when a cover was indexed, instead of the blanket mimetype match that reported a preview for every audio file. --- .../opencloud/messages/search/v0/search.pb.go | 375 +++++++++++------- .../messages/search/v0/search.pb.web.go | 36 ++ .../services/search/v0/search.swagger.json | 17 + .../opencloud/messages/search/v0/search.proto | 8 + services/search/pkg/bleve/backend.go | 1 + .../opensearch/internal/convert/opensearch.go | 1 + .../thumbnails/pkg/thumbnail/haspreview.go | 14 +- services/webdav/pkg/service/v0/search.go | 3 +- 8 files changed, 307 insertions(+), 148 deletions(-) diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.go b/protogen/gen/opencloud/messages/search/v0/search.pb.go index e5c74946ea..6f23518834 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 { @@ -560,12 +617,13 @@ type Entity struct { Image *Image `protobuf:"bytes,18,opt,name=image,proto3" json:"image,omitempty"` Photo *Photo `protobuf:"bytes,19,opt,name=photo,proto3" json:"photo,omitempty"` Favorites []string `protobuf:"bytes,20,rep,name=favorites,proto3" json:"favorites,omitempty"` + Preview *Preview `protobuf:"bytes,21,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[6] + mi := &file_opencloud_messages_search_v0_search_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -578,7 +636,7 @@ func (x *Entity) String() string { func (*Entity) ProtoMessage() {} func (x *Entity) 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 { @@ -591,7 +649,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{6} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{7} } func (x *Entity) GetRef() *Reference { @@ -734,6 +792,13 @@ func (x *Entity) GetFavorites() []string { return nil } +func (x *Entity) GetPreview() *Preview { + if x != nil { + return x.Preview + } + return nil +} + type Match struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -748,7 +813,7 @@ type Match struct { func (x *Match) Reset() { *x = Match{} 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) } @@ -761,7 +826,7 @@ func (x *Match) String() string { func (*Match) ProtoMessage() {} func (x *Match) 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 { @@ -774,7 +839,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{7} + return file_opencloud_messages_search_v0_search_proto_rawDescGZIP(), []int{8} } func (x *Match) GetEntity() *Entity { @@ -864,118 +929,127 @@ 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, 0xfa, 0x06, 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, 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, + 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, 0xbb, 0x07, 0x0a, 0x06, 0x45, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x12, 0x39, 0x0a, 0x03, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, + 0x2e, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x03, 0x72, 0x65, 0x66, 0x12, + 0x38, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, + 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, + 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, + 0x67, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, + 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x48, 0x0a, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x6d, 0x6f, + 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x6c, + 0x61, 0x73, 0x74, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x12, + 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x12, + 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, + 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x07, 0x64, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x24, 0x0a, 0x0d, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x68, 0x61, 0x72, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x4e, 0x61, + 0x6d, 0x65, 0x12, 0x45, 0x0a, 0x09, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, + 0x64, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x73, 0x65, 0x61, 0x72, 0x63, + 0x68, 0x2e, 0x76, 0x30, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x44, 0x52, + 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x61, 0x67, + 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x1e, 0x0a, + 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x68, 0x69, 0x67, 0x68, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x39, 0x0a, + 0x05, 0x61, 0x75, 0x64, 0x69, 0x6f, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6f, + 0x70, 0x65, 0x6e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2e, 0x6d, 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, 0x3f, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x18, 0x15, 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 ( @@ -990,36 +1064,38 @@ 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, 8) +var file_opencloud_messages_search_v0_search_proto_msgTypes = make([]protoimpl.MessageInfo, 9) 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 - (*Entity)(nil), // 6: opencloud.messages.search.v0.Entity - (*Match)(nil), // 7: opencloud.messages.search.v0.Match - (*timestamppb.Timestamp)(nil), // 8: 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 + (*Entity)(nil), // 7: opencloud.messages.search.v0.Entity + (*Match)(nil), // 8: opencloud.messages.search.v0.Match + (*timestamppb.Timestamp)(nil), // 9: 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 - 8, // 1: opencloud.messages.search.v0.Photo.takenDateTime:type_name -> google.protobuf.Timestamp + 9, // 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 - 8, // 4: opencloud.messages.search.v0.Entity.last_modified_time:type_name -> google.protobuf.Timestamp + 9, // 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 - 6, // 11: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 6, // 10: opencloud.messages.search.v0.Entity.photo:type_name -> opencloud.messages.search.v0.Photo + 4, // 11: opencloud.messages.search.v0.Entity.preview:type_name -> opencloud.messages.search.v0.Preview + 7, // 12: opencloud.messages.search.v0.Match.entity:type_name -> opencloud.messages.search.v0.Entity + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_opencloud_messages_search_v0_search_proto_init() } @@ -1077,7 +1153,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: @@ -1089,7 +1165,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: @@ -1101,7 +1177,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.(*Entity); i { + switch v := v.(*Photo); i { case 0: return &v.state case 1: @@ -1113,6 +1189,18 @@ func file_opencloud_messages_search_v0_search_proto_init() { } } file_opencloud_messages_search_v0_search_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Entity); i { + 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[8].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Match); i { case 0: return &v.state @@ -1129,13 +1217,14 @@ func file_opencloud_messages_search_v0_search_proto_init() { file_opencloud_messages_search_v0_search_proto_msgTypes[3].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[4].OneofWrappers = []interface{}{} file_opencloud_messages_search_v0_search_proto_msgTypes[5].OneofWrappers = []interface{}{} + file_opencloud_messages_search_v0_search_proto_msgTypes[6].OneofWrappers = []interface{}{} 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: 8, + NumMessages: 9, NumExtensions: 0, NumServices: 0, }, diff --git a/protogen/gen/opencloud/messages/search/v0/search.pb.web.go b/protogen/gen/opencloud/messages/search/v0/search.pb.web.go index 22c3e08238..a1720ffefb 100644 --- a/protogen/gen/opencloud/messages/search/v0/search.pb.web.go +++ b/protogen/gen/opencloud/messages/search/v0/search.pb.web.go @@ -154,6 +154,42 @@ func (m *Image) UnmarshalJSON(b []byte) error { var _ json.Unmarshaler = (*Image)(nil) +// PreviewJSONMarshaler describes the default jsonpb.Marshaler used by all +// instances of Preview. This struct is safe to replace or modify but +// should not be done so concurrently. +var PreviewJSONMarshaler = new(jsonpb.Marshaler) + +// MarshalJSON satisfies the encoding/json Marshaler interface. This method +// uses the more correct jsonpb package to correctly marshal the message. +func (m *Preview) MarshalJSON() ([]byte, error) { + if m == nil { + return json.Marshal(nil) + } + + buf := &bytes.Buffer{} + + if err := PreviewJSONMarshaler.Marshal(buf, m); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +var _ json.Marshaler = (*Preview)(nil) + +// PreviewJSONUnmarshaler describes the default jsonpb.Unmarshaler used by all +// instances of Preview. This struct is safe to replace or modify but +// should not be done so concurrently. +var PreviewJSONUnmarshaler = new(jsonpb.Unmarshaler) + +// UnmarshalJSON satisfies the encoding/json Unmarshaler interface. This method +// uses the more correct jsonpb package to correctly unmarshal the message. +func (m *Preview) UnmarshalJSON(b []byte) error { + return PreviewJSONUnmarshaler.Unmarshal(bytes.NewReader(b), m) +} + +var _ json.Unmarshaler = (*Preview)(nil) + // GeoCoordinatesJSONMarshaler describes the default jsonpb.Marshaler used by all // instances of GeoCoordinates. This struct is safe to replace or modify but // should not be done so concurrently. diff --git a/protogen/gen/opencloud/services/search/v0/search.swagger.json b/protogen/gen/opencloud/services/search/v0/search.swagger.json index a8d7b9bd8f..5b020aefb6 100644 --- a/protogen/gen/opencloud/services/search/v0/search.swagger.json +++ b/protogen/gen/opencloud/services/search/v0/search.swagger.json @@ -288,6 +288,9 @@ "items": { "type": "string" } + }, + "preview": { + "$ref": "#/definitions/v0Preview" } } }, @@ -391,6 +394,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 4633a2cee7..57a57fffef 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; @@ -80,6 +87,7 @@ message Entity { Image image = 18; Photo photo = 19; repeated string favorites = 20; + Preview preview = 21; } message Match { diff --git a/services/search/pkg/bleve/backend.go b/services/search/pkg/bleve/backend.go index b4088918fe..cf74bc8a03 100644 --- a/services/search/pkg/bleve/backend.go +++ b/services/search/pkg/bleve/backend.go @@ -141,6 +141,7 @@ func (b *Backend) Search(_ context.Context, sir *searchService.SearchIndexReques Image: hitToFacet[searchMessage.Image](hit.Fields, "image"), Location: hitToFacet[searchMessage.GeoCoordinates](hit.Fields, "location"), Photo: hitToFacet[searchMessage.Photo](hit.Fields, "photo"), + Preview: hitToFacet[searchMessage.Preview](hit.Fields, "preview"), }, } diff --git a/services/search/pkg/opensearch/internal/convert/opensearch.go b/services/search/pkg/opensearch/internal/convert/opensearch.go index 01c662096c..864ea9f24e 100644 --- a/services/search/pkg/opensearch/internal/convert/opensearch.go +++ b/services/search/pkg/opensearch/internal/convert/opensearch.go @@ -82,6 +82,7 @@ func OpenSearchHitToMatch(hit opensearchgoAPI.SearchHit) (*searchMessage.Match, Image: copyFacet[searchMessage.Image](resource.Image), Location: copyFacet[searchMessage.GeoCoordinates](resource.Location), Photo: copyFacet[searchMessage.Photo](resource.Photo), + Preview: copyFacet[searchMessage.Preview](resource.Preview), }, } diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go index 5af6c07b8e..6b9136eacc 100644 --- a/services/thumbnails/pkg/thumbnail/haspreview.go +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -25,14 +25,22 @@ func HasPreview(md *provider.ResourceInfo) bool { if md == nil { return false } + w, h := PreviewDimensions(md) + return HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0) +} - mimeType := md.GetMimeType() +// HasPreviewForMimeType reports whether a preview can be produced for a resource +// of the given mimetype. For unconditional types it follows from the mimetype +// alone; for embedded-preview types (audio cover art) it depends on +// hasEmbeddedPreview, i.e. whether an embedded preview was detected at index +// time. Callers that only have the mimetype and a presence signal (for example +// search results) use this instead of HasPreview. +func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { return true } if _, ok := EmbeddedPreviewMimeTypes[mimeType]; ok { - w, h := PreviewDimensions(md) - return w > 0 && h > 0 + return hasEmbeddedPreview } return false } diff --git a/services/webdav/pkg/service/v0/search.go b/services/webdav/pkg/service/v0/search.go index e2f6a3f4a4..8da71806d9 100644 --- a/services/webdav/pkg/service/v0/search.go +++ b/services/webdav/pkg/service/v0/search.go @@ -222,8 +222,7 @@ func matchToPropResponse(ctx context.Context, davPrefix, publicURL string, match propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:permissions", match.Entity.Permissions)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:highlights", match.Entity.Highlights)) propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("d:getcontenttype", match.Entity.MimeType)) - _, isSupportedMimeType := thumbnail.SupportedMimeTypes[match.Entity.MimeType] - if isSupportedMimeType { + if thumbnail.HasPreviewForMimeType(match.Entity.MimeType, match.Entity.GetPreview() != nil) { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "1")) } else { propstatOK.Prop = append(propstatOK.Prop, prop.Escaped("oc:has-preview", "0")) From aeb98ec4305e3cd2a8d5f84b8a62dfef3a34f16f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:02:50 +0200 Subject: [PATCH 07/20] docs: shorten comments --- services/graph/pkg/service/v0/thumbnails.go | 41 ++++++------------- services/search/pkg/content/content.go | 12 ++---- services/search/pkg/content/tika.go | 12 ++---- .../thumbnails/pkg/thumbnail/haspreview.go | 22 +++------- 4 files changed, 27 insertions(+), 60 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 4f355012fd..9f4fb2599d 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -14,16 +14,12 @@ import ( "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) -// thumbnailsExpanded reports whether the request asked for the thumbnails -// relationship to be expanded ($expand=thumbnails). func thumbnailsExpanded(r *http.Request) bool { return strings.Contains(r.URL.Query().Get("$expand"), "thumbnails") } -// setDriveItemsThumbnails populates the thumbnails relationship on driveItems -// (1:1 with infos) when the request asked for it. Resource infos carry the -// mimetype and the arbitrary metadata (image / oc.preview dimensions) that drive -// the decision, so no extra stat is needed. +// setDriveItemsThumbnails sets the thumbnails relationship on driveItems (1:1 +// with infos) when $expand=thumbnails was requested. func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.DriveItem, infos []*provider.ResourceInfo) { if !thumbnailsExpanded(r) { return @@ -35,35 +31,26 @@ func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.Driv } } -// Bounding boxes for the driveItem thumbnails relationship. The thumbnails -// endpoint scales the source to fit these preserving aspect ratio (scalingup=0). -// -// Note: the endpoint rounds a requested box up to the nearest configured -// THUMBNAILS_RESOLUTIONS via ClosestMatch, so the reported dimensions are -// aspect-correct for the requested box but not necessarily the exact served -// resolution. Making them exact would require the resolution list to be -// available in the graph service; this is a possible future refinement and is -// no worse than the previous behaviour (which reported no dimensions at all). +// Thumbnail bounding boxes for the driveItem thumbnails relationship. Reported +// dimensions are aspect-correct for the box, not the exact ClosestMatch served +// resolution (that would need THUMBNAILS_RESOLUTIONS in graph; possible follow-up). const ( thumbnailBoxSmall = 36 thumbnailBoxMedium = 48 thumbnailBoxLarge = 96 ) -// setDriveItemThumbnails populates the thumbnails relationship of a driveItem -// from its resource info when a preview is available. It is a no-op when no -// preview exists (for example audio without embedded cover art), which keeps the -// relationship honest instead of advertising a preview that fails to render. +// setDriveItemThumbnails sets the thumbnails relationship from a resource's info, +// or leaves it empty when no preview is available (keeping it honest). func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string) { if set := previewThumbnailSet(res, baseURL); set != nil { item.SetThumbnails([]libregraph.ThumbnailSet{*set}) } } -// previewThumbnailSet builds the thumbnails relationship entry for a resource, -// or nil when no preview is available. When the source dimensions are known the -// reported thumbnail dimensions are aspect-correct rather than square, and a -// source (original) thumbnail carrying the native dimensions is included. +// previewThumbnailSet builds the thumbnails relationship entry, or nil when no +// preview is available. Dimensions are aspect-correct when the source size is +// known, plus a source thumbnail with the native dimensions. func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet { if !thumbnail.HasPreview(res) { return nil @@ -95,9 +82,8 @@ func previewThumbnail(base string, box, srcW, srcH int32) *libregraph.Thumbnail return t } -// previewSourceDimensions returns the native dimensions of a resource's preview: -// the embedded cover dimensions for audio (from oc.preview), or the image facet -// dimensions for images. Zero when unknown. +// previewSourceDimensions returns the native preview size: the cover for audio +// (oc.preview) or the image facet for images. Zero when unknown. func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { if w, h := thumbnail.PreviewDimensions(res); w > 0 && h > 0 { return w, h @@ -106,8 +92,7 @@ func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { return parsePreviewInt(meta["libre.graph.image.width"]), parsePreviewInt(meta["libre.graph.image.height"]) } -// fitBox scales (w, h) to fit within a box×box square preserving aspect ratio, -// never upscaling, matching the thumbnails endpoint (scalingup=0). +// fitBox scales (w, h) into a box×box square, preserving aspect and never upscaling. func fitBox(w, h, box int32) (int32, int32) { if w <= box && h <= box { return w, h diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index 8f9d97551e..138eb42100 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -30,19 +30,15 @@ type Document struct { Preview *Preview `json:"preview,omitempty"` } -// Preview holds the dimensions of an embedded preview image (for example audio -// cover art) for content types whose thumbnail is embedded rather than rendered -// and may therefore be absent. It is an internal signal, not a Microsoft Graph -// facet: its presence marks that a preview exists for the resource. +// Preview holds the dimensions of an embedded preview (e.g. audio cover art). +// Internal signal, not a Graph facet; its presence marks that a preview exists. type Preview struct { Width int32 `json:"width"` Height int32 `json:"height"` } -// ToMap lets Preview flow through the same facet-to-metadata flattening as the -// Microsoft Graph facets, so it is stored under the oc.preview. prefix (keys -// oc.preview.width / oc.preview.height, matching thumbnail.PreviewWidthKey / -// thumbnail.PreviewHeightKey). Preview is not itself a Graph facet. +// ToMap lets Preview flow through the shared facet-to-metadata flattening (under +// the oc.preview. prefix). Preview is not itself a Graph facet. func (p Preview) ToMap() (map[string]interface{}, error) { return map[string]interface{}{ "width": p.Width, diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index c2a687da1a..e955b7ba47 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -117,14 +117,10 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, // the front cover, matching the thumbnailer's selection in the dhowden/tag fork. const frontCoverDescription = "Cover (front)" -// getPreview extracts the dimensions of the embedded preview image for content -// whose thumbnail is embedded rather than rendered (audio cover art). Tika with -// TIKA-4801 surfaces embedded covers as image entries carrying tiff dimensions -// and the picture type as dc:description. It prefers the front cover and falls -// back to the first embedded image, matching the thumbnailer's cover selection, -// so the reported dimensions belong to the picture that actually gets rendered. -// It only runs for EmbeddedPreviewMimeTypes; unconditional types have their -// preview availability decided by the mimetype alone. +// getPreview returns the dimensions of an audio file's embedded cover art from +// Tika's recursive metadata, preferring the front cover (dc:description) and +// falling back to the first image, matching the thumbnailer's selection. It only +// runs for EmbeddedPreviewMimeTypes. func getPreview(mimeType string, metas []map[string][]string) *Preview { if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { return nil diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go index 6b9136eacc..30db1e32a2 100644 --- a/services/thumbnails/pkg/thumbnail/haspreview.go +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -6,21 +6,15 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" ) -// Arbitrary-metadata keys under which the content extraction pipeline stores the -// dimensions of an embedded preview (for example audio cover art). These are an -// internal signal, not a Microsoft Graph facet. Their presence means the file -// carries an embedded preview. +// Arbitrary-metadata keys holding an embedded preview's dimensions (e.g. audio +// cover art), written at index time. Their presence signals a preview exists. const ( PreviewWidthKey = "oc.preview.width" PreviewHeightKey = "oc.preview.height" ) -// HasPreview reports whether a thumbnail/preview can be produced for the given -// resource. For unconditional types it follows from the mimetype alone. For -// embedded-preview types (audio cover art) it depends on whether an embedded -// preview was detected at index time, signalled by the stored preview -// dimensions. Files that have not been indexed yet report no preview rather than -// promising one that would fail to render. +// HasPreview reports whether a preview can be produced for the resource: +// unconditional types by mimetype, embedded-preview types by stored dimensions. func HasPreview(md *provider.ResourceInfo) bool { if md == nil { return false @@ -29,12 +23,8 @@ func HasPreview(md *provider.ResourceInfo) bool { return HasPreviewForMimeType(md.GetMimeType(), w > 0 && h > 0) } -// HasPreviewForMimeType reports whether a preview can be produced for a resource -// of the given mimetype. For unconditional types it follows from the mimetype -// alone; for embedded-preview types (audio cover art) it depends on -// hasEmbeddedPreview, i.e. whether an embedded preview was detected at index -// time. Callers that only have the mimetype and a presence signal (for example -// search results) use this instead of HasPreview. +// HasPreviewForMimeType is HasPreview for callers that only have the mimetype +// and a presence signal (e.g. search results) rather than a full ResourceInfo. func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { return true From 863ffcc20325fbaa7b32e4dc400fecf96c58a8ad Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:24:47 +0200 Subject: [PATCH 08/20] refactor(graph): generic shouldExpand(r, relation) --- services/graph/pkg/service/v0/thumbnails.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 9f4fb2599d..63ff211e7c 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -14,14 +14,15 @@ import ( "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) -func thumbnailsExpanded(r *http.Request) bool { - return strings.Contains(r.URL.Query().Get("$expand"), "thumbnails") +// shouldExpand reports whether the request asked to expand the given relationship. +func shouldExpand(r *http.Request, relation string) bool { + return strings.Contains(r.URL.Query().Get("$expand"), relation) } // setDriveItemsThumbnails sets the thumbnails relationship on driveItems (1:1 // with infos) when $expand=thumbnails was requested. func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.DriveItem, infos []*provider.ResourceInfo) { - if !thumbnailsExpanded(r) { + if !shouldExpand(r, "thumbnails") { return } for i := range items { From 5730d7bf3c7053c2f5811134c5e7afd419ee4cd9 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:27:24 +0200 Subject: [PATCH 09/20] docs: tighten comments --- services/graph/pkg/service/v0/thumbnails.go | 22 ++++++++----------- services/search/pkg/content/content.go | 3 +-- services/search/pkg/content/tika.go | 8 +++---- .../thumbnails/pkg/thumbnail/haspreview.go | 5 ++--- .../thumbnails/pkg/thumbnail/mimetypes.go | 5 ++--- .../pkg/thumbnail/mimetypes_common.go | 11 +++------- .../pkg/thumbnail/mimetypes_vips.go | 5 ++--- 7 files changed, 22 insertions(+), 37 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 63ff211e7c..4d44f3bc49 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -14,13 +14,12 @@ import ( "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) -// shouldExpand reports whether the request asked to expand the given relationship. func shouldExpand(r *http.Request, relation string) bool { return strings.Contains(r.URL.Query().Get("$expand"), relation) } -// setDriveItemsThumbnails sets the thumbnails relationship on driveItems (1:1 -// with infos) when $expand=thumbnails was requested. +// setDriveItemsThumbnails expands the thumbnails relationship on driveItems that +// are 1:1 with infos. func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.DriveItem, infos []*provider.ResourceInfo) { if !shouldExpand(r, "thumbnails") { return @@ -32,26 +31,23 @@ func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.Driv } } -// Thumbnail bounding boxes for the driveItem thumbnails relationship. Reported -// dimensions are aspect-correct for the box, not the exact ClosestMatch served -// resolution (that would need THUMBNAILS_RESOLUTIONS in graph; possible follow-up). +// Requested box sizes. The reported dimensions are aspect-correct for the box, +// not the ClosestMatch-served resolution (that would need THUMBNAILS_RESOLUTIONS +// here; follow-up). const ( thumbnailBoxSmall = 36 thumbnailBoxMedium = 48 thumbnailBoxLarge = 96 ) -// setDriveItemThumbnails sets the thumbnails relationship from a resource's info, -// or leaves it empty when no preview is available (keeping it honest). func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string) { if set := previewThumbnailSet(res, baseURL); set != nil { item.SetThumbnails([]libregraph.ThumbnailSet{*set}) } } -// previewThumbnailSet builds the thumbnails relationship entry, or nil when no -// preview is available. Dimensions are aspect-correct when the source size is -// known, plus a source thumbnail with the native dimensions. +// previewThumbnailSet returns nil when no preview is available. Dimensions are +// aspect-correct when the source size is known, plus a source (original) entry. func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet { if !thumbnail.HasPreview(res) { return nil @@ -83,8 +79,8 @@ func previewThumbnail(base string, box, srcW, srcH int32) *libregraph.Thumbnail return t } -// previewSourceDimensions returns the native preview size: the cover for audio -// (oc.preview) or the image facet for images. Zero when unknown. +// previewSourceDimensions: audio cover from oc.preview, images from the image +// facet. Zero when unknown. func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { if w, h := thumbnail.PreviewDimensions(res); w > 0 && h > 0 { return w, h diff --git a/services/search/pkg/content/content.go b/services/search/pkg/content/content.go index 138eb42100..87b9e2501d 100644 --- a/services/search/pkg/content/content.go +++ b/services/search/pkg/content/content.go @@ -37,8 +37,7 @@ type Preview struct { Height int32 `json:"height"` } -// ToMap lets Preview flow through the shared facet-to-metadata flattening (under -// the oc.preview. prefix). Preview is not itself a Graph facet. +// ToMap flows Preview through the shared facet flattening, under oc.preview. func (p Preview) ToMap() (map[string]interface{}, error) { return map[string]interface{}{ "width": p.Width, diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index e955b7ba47..e3e569588f 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -83,8 +83,7 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } - // Title and content are aggregated across the container and all embedded - // resources (e.g. text embedded in a document). + // Title and content aggregate across the container and embedded resources. for _, meta := range metas { if title, err := getFirstValue(meta, "title"); err == nil { doc.Title = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Title, title)) @@ -95,9 +94,8 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, } } - // Facets describe the resource itself, so they are taken from the container - // (the first entry). Its embedded resources, such as audio cover art, must - // not leak into them; the cover's dimensions become the preview instead. + // Facets come from the container (first entry) only; embedded resources like + // audio cover art must not leak in (the cover becomes the preview instead). container := metas[0] doc.Location = t.getLocation(container) doc.Image = t.getImage(container) diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/haspreview.go index 30db1e32a2..f3cff6d71e 100644 --- a/services/thumbnails/pkg/thumbnail/haspreview.go +++ b/services/thumbnails/pkg/thumbnail/haspreview.go @@ -35,9 +35,8 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { return false } -// PreviewDimensions returns the stored dimensions of a resource's embedded -// preview, or (0, 0) if none were recorded. Only meaningful for -// EmbeddedPreviewMimeTypes. +// PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). +// Only meaningful for EmbeddedPreviewMimeTypes. func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { meta := md.GetArbitraryMetadata().GetMetadata() if meta == nil { diff --git a/services/thumbnails/pkg/thumbnail/mimetypes.go b/services/thumbnails/pkg/thumbnail/mimetypes.go index 5dc32cbe74..390cec01c7 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes.go @@ -2,9 +2,8 @@ package thumbnail -// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows -// from the mimetype alone: the thumbnailer can always render a preview from the -// content, so a preview is guaranteed to exist. +// UnconditionalPreviewMimeTypes always have a preview: the thumbnailer renders +// one from the content. var UnconditionalPreviewMimeTypes = map[string]struct{}{ "image/png": {}, "image/jpg": {}, diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_common.go b/services/thumbnails/pkg/thumbnail/mimetypes_common.go index a774dd7d68..96f047c713 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes_common.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes_common.go @@ -1,19 +1,14 @@ package thumbnail -// EmbeddedPreviewMimeTypes are mimetypes whose preview is an embedded resource -// (for example audio cover art) that may or may not be present. Preview -// availability cannot be derived from the mimetype alone and must be determined -// per file (see HasPreview). +// EmbeddedPreviewMimeTypes have an embedded preview (e.g. audio cover art) that +// may be absent; availability is decided per file (see HasPreview). var EmbeddedPreviewMimeTypes = map[string]struct{}{ "audio/flac": {}, "audio/mpeg": {}, "audio/ogg": {}, } -// SupportedMimeTypes contains all mimetypes the thumbnailer can produce a -// thumbnail for: the union of the unconditional and embedded preview types. -// The generator gates on this union; preview availability per file is decided -// by HasPreview. +// SupportedMimeTypes is the union of both preview sets; the generator gates on it. var SupportedMimeTypes = func() map[string]struct{} { m := make(map[string]struct{}, len(UnconditionalPreviewMimeTypes)+len(EmbeddedPreviewMimeTypes)) for k := range UnconditionalPreviewMimeTypes { diff --git a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go index 677ae43d86..929718e7ad 100644 --- a/services/thumbnails/pkg/thumbnail/mimetypes_vips.go +++ b/services/thumbnails/pkg/thumbnail/mimetypes_vips.go @@ -2,9 +2,8 @@ package thumbnail -// UnconditionalPreviewMimeTypes are mimetypes whose preview availability follows -// from the mimetype alone: the thumbnailer can always render a preview from the -// content, so a preview is guaranteed to exist. +// UnconditionalPreviewMimeTypes always have a preview: the thumbnailer renders +// one from the content. var UnconditionalPreviewMimeTypes = map[string]struct{}{ "image/png": {}, "image/jpg": {}, From 8d0b8ed3d7af4ed2141652a38c05236534fb19ee Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:31:20 +0200 Subject: [PATCH 10/20] refactor: move getPreview to tika_preview.go, rename has_preview.go --- services/search/pkg/content/tika.go | 41 ---------------- services/search/pkg/content/tika_preview.go | 47 +++++++++++++++++++ .../{haspreview.go => has_preview.go} | 0 ...haspreview_test.go => has_preview_test.go} | 0 4 files changed, 47 insertions(+), 41 deletions(-) create mode 100644 services/search/pkg/content/tika_preview.go rename services/thumbnails/pkg/thumbnail/{haspreview.go => has_preview.go} (100%) rename services/thumbnails/pkg/thumbnail/{haspreview_test.go => has_preview_test.go} (100%) diff --git a/services/search/pkg/content/tika.go b/services/search/pkg/content/tika.go index e3e569588f..c086ab69d2 100644 --- a/services/search/pkg/content/tika.go +++ b/services/search/pkg/content/tika.go @@ -3,7 +3,6 @@ package content import ( "context" "fmt" - "strconv" "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -13,7 +12,6 @@ import ( "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/services/search/pkg/config" - "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) // Tika is used to extract content from a resource, @@ -110,42 +108,3 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document, return doc, nil } - -// frontCoverDescription is the picture type Tika reports (as dc:description) for -// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. -const frontCoverDescription = "Cover (front)" - -// getPreview returns the dimensions of an audio file's embedded cover art from -// Tika's recursive metadata, preferring the front cover (dc:description) and -// falling back to the first image, matching the thumbnailer's selection. It only -// runs for EmbeddedPreviewMimeTypes. -func getPreview(mimeType string, metas []map[string][]string) *Preview { - if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { - return nil - } - var first *Preview - for _, meta := range metas { - ct, err := getFirstValue(meta, "Content-Type") - if err != nil || !strings.HasPrefix(ct, "image/") { - continue - } - w, wErr := getFirstValue(meta, "tiff:ImageWidth") - h, hErr := getFirstValue(meta, "tiff:ImageLength") - if wErr != nil || hErr != nil { - continue - } - width, wErr := strconv.ParseInt(w, 10, 32) - height, hErr := strconv.ParseInt(h, 10, 32) - if wErr != nil || hErr != nil || width <= 0 || height <= 0 { - continue - } - preview := &Preview{Width: int32(width), Height: int32(height)} - if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { - return preview - } - if first == nil { - first = preview - } - } - return first -} diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go new file mode 100644 index 0000000000..a8d3280de2 --- /dev/null +++ b/services/search/pkg/content/tika_preview.go @@ -0,0 +1,47 @@ +package content + +import ( + "strconv" + "strings" + + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" +) + +// frontCoverDescription is the picture type Tika reports (as dc:description) for +// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. +const frontCoverDescription = "Cover (front)" + +// getPreview returns the dimensions of an audio file's embedded cover art from +// Tika's recursive metadata, preferring the front cover (dc:description) and +// falling back to the first image, matching the thumbnailer's selection. It only +// runs for EmbeddedPreviewMimeTypes. +func getPreview(mimeType string, metas []map[string][]string) *Preview { + if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok { + return nil + } + var first *Preview + for _, meta := range metas { + ct, err := getFirstValue(meta, "Content-Type") + if err != nil || !strings.HasPrefix(ct, "image/") { + continue + } + w, wErr := getFirstValue(meta, "tiff:ImageWidth") + h, hErr := getFirstValue(meta, "tiff:ImageLength") + if wErr != nil || hErr != nil { + continue + } + width, wErr := strconv.ParseInt(w, 10, 32) + height, hErr := strconv.ParseInt(h, 10, 32) + if wErr != nil || hErr != nil || width <= 0 || height <= 0 { + continue + } + preview := &Preview{Width: int32(width), Height: int32(height)} + if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription { + return preview + } + if first == nil { + first = preview + } + } + return first +} diff --git a/services/thumbnails/pkg/thumbnail/haspreview.go b/services/thumbnails/pkg/thumbnail/has_preview.go similarity index 100% rename from services/thumbnails/pkg/thumbnail/haspreview.go rename to services/thumbnails/pkg/thumbnail/has_preview.go diff --git a/services/thumbnails/pkg/thumbnail/haspreview_test.go b/services/thumbnails/pkg/thumbnail/has_preview_test.go similarity index 100% rename from services/thumbnails/pkg/thumbnail/haspreview_test.go rename to services/thumbnails/pkg/thumbnail/has_preview_test.go From 8a10d2a240ba7d9ad77c7577493baf55fb40df63 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:42:18 +0200 Subject: [PATCH 11/20] refactor: add conversions.StringToInt32, compact HasPreview, drop dup --- pkg/conversions/strings.go | 7 ++++ services/graph/pkg/service/v0/thumbnails.go | 12 +----- .../thumbnails/pkg/thumbnail/has_preview.go | 41 +++++-------------- 3 files changed, 19 insertions(+), 41 deletions(-) diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index 63cc68f2a7..c3dfed5ed4 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -1,6 +1,7 @@ package conversions import ( + "strconv" "strings" ) @@ -14,3 +15,9 @@ func StringToSliceString(src string, sep string) []string { return parts } + +// StringToInt32 parses s as a base-10 int32, returning 0 for empty or invalid input. +func StringToInt32(s string) int32 { + v, _ := strconv.ParseInt(s, 10, 32) + return int32(v) +} diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 4d44f3bc49..5e63dc17ed 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -4,13 +4,13 @@ import ( "fmt" "math" "net/http" - "strconv" "strings" provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" libregraph "github.com/opencloud-eu/libre-graph-api-go" "github.com/opencloud-eu/reva/v2/pkg/storagespace" + "github.com/opencloud-eu/opencloud/pkg/conversions" "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) @@ -86,7 +86,7 @@ func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { return w, h } meta := res.GetArbitraryMetadata().GetMetadata() - return parsePreviewInt(meta["libre.graph.image.width"]), parsePreviewInt(meta["libre.graph.image.height"]) + return conversions.StringToInt32(meta["libre.graph.image.width"]), conversions.StringToInt32(meta["libre.graph.image.height"]) } // fitBox scales (w, h) into a box×box square, preserving aspect and never upscaling. @@ -105,11 +105,3 @@ func fitBox(w, h, box int32) (int32, int32) { } return rw, rh } - -func parsePreviewInt(s string) int32 { - v, err := strconv.ParseInt(s, 10, 32) - if err != nil { - return 0 - } - return int32(v) -} diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index f3cff6d71e..c7f82bedc6 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -1,57 +1,36 @@ package thumbnail import ( - "strconv" - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + + "github.com/opencloud-eu/opencloud/pkg/conversions" ) -// Arbitrary-metadata keys holding an embedded preview's dimensions (e.g. audio -// cover art), written at index time. Their presence signals a preview exists. +// Arbitrary-metadata keys for an embedded preview's dimensions, written at index +// time; their presence signals a preview exists. const ( PreviewWidthKey = "oc.preview.width" PreviewHeightKey = "oc.preview.height" ) -// HasPreview reports whether a preview can be produced for the resource: -// unconditional types by mimetype, embedded-preview types by stored dimensions. +// HasPreview reports whether a preview can be produced for the resource. func HasPreview(md *provider.ResourceInfo) bool { - if md == nil { - return false - } w, h := PreviewDimensions(md) return 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. +// HasPreviewForMimeType is HasPreview for callers with only the mimetype and a +// presence signal (e.g. search results). func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { if _, ok := UnconditionalPreviewMimeTypes[mimeType]; ok { return true } - if _, ok := EmbeddedPreviewMimeTypes[mimeType]; ok { - return hasEmbeddedPreview - } - return false + _, embedded := EmbeddedPreviewMimeTypes[mimeType] + return embedded && hasEmbeddedPreview } // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). -// Only meaningful for EmbeddedPreviewMimeTypes. func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { meta := md.GetArbitraryMetadata().GetMetadata() - if meta == nil { - return 0, 0 - } - return parseInt32(meta[PreviewWidthKey]), parseInt32(meta[PreviewHeightKey]) -} - -func parseInt32(s string) int32 { - if s == "" { - return 0 - } - v, err := strconv.ParseInt(s, 10, 32) - if err != nil { - return 0 - } - return int32(v) + return conversions.StringToInt32(meta[PreviewWidthKey]), conversions.StringToInt32(meta[PreviewHeightKey]) } From 0ebc6620b432afa0a5139c7f6f78044ab03f0ea0 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:42:18 +0200 Subject: [PATCH 12/20] test: use ginkgo/gomega for the preview tests --- .../search/pkg/content/tika_preview_test.go | 44 ++++++------ .../pkg/thumbnail/has_preview_test.go | 68 ++++++++----------- .../pkg/thumbnail/thumbnail_suite_test.go | 13 ++++ 3 files changed, 61 insertions(+), 64 deletions(-) create mode 100644 services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go diff --git a/services/search/pkg/content/tika_preview_test.go b/services/search/pkg/content/tika_preview_test.go index 6bd069d8cc..f57055d768 100644 --- a/services/search/pkg/content/tika_preview_test.go +++ b/services/search/pkg/content/tika_preview_test.go @@ -1,8 +1,11 @@ package content -import "testing" +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) -func TestGetPreview(t *testing.T) { +var _ = Describe("getPreview", func() { audio := map[string][]string{"Content-Type": {"audio/mpeg"}} cover := map[string][]string{ "Content-Type": {"image/jpeg"}, @@ -11,26 +14,21 @@ func TestGetPreview(t *testing.T) { } coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}} - t.Run("audio with embedded cover returns dims", func(t *testing.T) { + It("returns the embedded cover dimensions for audio", func() { p := getPreview("audio/mpeg", []map[string][]string{audio, cover}) - if p == nil || p.Width != 500 || p.Height != 400 { - t.Fatalf("expected 500x400, got %+v", p) - } + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 500, Height: 400})) }) - t.Run("audio without cover returns nil", func(t *testing.T) { - if p := getPreview("audio/mpeg", []map[string][]string{audio}); p != nil { - t.Fatalf("expected nil, got %+v", p) - } + It("returns nil when the audio has no cover", func() { + Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil()) }) - t.Run("audio with cover lacking dims returns nil", func(t *testing.T) { - if p := getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims}); p != nil { - t.Fatalf("expected nil, got %+v", p) - } + It("returns nil when the cover lacks dimensions", func() { + Expect(getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims})).To(BeNil()) }) - t.Run("prefers the front cover over an earlier back cover", func(t *testing.T) { + It("prefers the front cover over an earlier back cover", func() { back := map[string][]string{ "Content-Type": {"image/jpeg"}, "dc:description": {"Cover (back)"}, "tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"}, @@ -40,16 +38,12 @@ func TestGetPreview(t *testing.T) { "tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"}, } p := getPreview("audio/mpeg", []map[string][]string{audio, back, front}) - if p == nil || p.Width != 64 || p.Height != 40 { - t.Fatalf("expected front cover 64x40, got %+v", p) - } + Expect(p).ToNot(BeNil()) + Expect(*p).To(Equal(Preview{Width: 64, Height: 40})) }) - t.Run("non-embedded type is gated out", func(t *testing.T) { - // an image file is unconditional; its preview is not driven by oc.preview, - // so getPreview must return nil even though an image meta is present. - if p := getPreview("image/png", []map[string][]string{cover}); p != nil { - t.Fatalf("expected nil for non-embedded type, got %+v", p) - } + It("is gated to embedded-preview types", func() { + // an image is unconditional; its preview is not driven by oc.preview. + Expect(getPreview("image/png", []map[string][]string{cover})).To(BeNil()) }) -} +}) diff --git a/services/thumbnails/pkg/thumbnail/has_preview_test.go b/services/thumbnails/pkg/thumbnail/has_preview_test.go index 7d446ef70c..271e091cca 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview_test.go +++ b/services/thumbnails/pkg/thumbnail/has_preview_test.go @@ -1,9 +1,11 @@ -package thumbnail +package thumbnail_test import ( - "testing" - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) func resourceInfo(mime string, meta map[string]string) *provider.ResourceInfo { @@ -14,42 +16,30 @@ func resourceInfo(mime string, meta map[string]string) *provider.ResourceInfo { return ri } -func TestHasPreview(t *testing.T) { - cases := []struct { - name string - md *provider.ResourceInfo - want bool - }{ - {"nil", nil, false}, - {"unconditional image", resourceInfo("image/png", nil), true}, - {"unconditional text", resourceInfo("text/plain", nil), true}, - {"unsupported type", resourceInfo("application/pdf", nil), false}, - {"audio without preview dims", resourceInfo("audio/mpeg", nil), false}, - {"audio with empty dims", resourceInfo("audio/mpeg", map[string]string{ - PreviewWidthKey: "0", PreviewHeightKey: "0", - }), false}, - {"audio with preview dims", resourceInfo("audio/mpeg", map[string]string{ - PreviewWidthKey: "500", PreviewHeightKey: "500", - }), true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := HasPreview(tc.md); got != tc.want { - t.Errorf("HasPreview(%s) = %v, want %v", tc.name, got, tc.want) - } - }) - } -} +var _ = Describe("HasPreview", func() { + DescribeTable("preview availability", + func(md *provider.ResourceInfo, want bool) { + Expect(thumbnail.HasPreview(md)).To(Equal(want)) + }, + Entry("nil", nil, false), + Entry("unconditional image", resourceInfo("image/png", nil), true), + Entry("unconditional text", resourceInfo("text/plain", nil), true), + Entry("unsupported type", resourceInfo("application/pdf", nil), false), + Entry("audio without preview dims", resourceInfo("audio/mpeg", nil), false), + Entry("audio with zero dims", resourceInfo("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "0", thumbnail.PreviewHeightKey: "0", + }), false), + Entry("audio with preview dims", resourceInfo("audio/mpeg", map[string]string{ + thumbnail.PreviewWidthKey: "500", thumbnail.PreviewHeightKey: "500", + }), true), + ) -func TestSupportedMimeTypesIsUnion(t *testing.T) { - for k := range UnconditionalPreviewMimeTypes { - if _, ok := SupportedMimeTypes[k]; !ok { - t.Errorf("SupportedMimeTypes missing unconditional type %q", k) + It("SupportedMimeTypes is the union of both preview sets", func() { + for k := range thumbnail.UnconditionalPreviewMimeTypes { + Expect(thumbnail.SupportedMimeTypes).To(HaveKey(k)) } - } - for k := range EmbeddedPreviewMimeTypes { - if _, ok := SupportedMimeTypes[k]; !ok { - t.Errorf("SupportedMimeTypes missing embedded type %q", k) + for k := range thumbnail.EmbeddedPreviewMimeTypes { + Expect(thumbnail.SupportedMimeTypes).To(HaveKey(k)) } - } -} + }) +}) diff --git a/services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go b/services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go new file mode 100644 index 0000000000..7217b6a146 --- /dev/null +++ b/services/thumbnails/pkg/thumbnail/thumbnail_suite_test.go @@ -0,0 +1,13 @@ +package thumbnail_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestThumbnail(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Thumbnail Suite") +} From fe6a23a7c2fac449a3105337f5b4ca71e383bf32 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:45:00 +0200 Subject: [PATCH 13/20] refactor: StringToInt32 returns the parse error --- pkg/conversions/strings.go | 8 ++++---- services/graph/pkg/service/v0/thumbnails.go | 4 +++- services/thumbnails/pkg/thumbnail/has_preview.go | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index c3dfed5ed4..d0e6a8e7e0 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -16,8 +16,8 @@ func StringToSliceString(src string, sep string) []string { return parts } -// StringToInt32 parses s as a base-10 int32, returning 0 for empty or invalid input. -func StringToInt32(s string) int32 { - v, _ := strconv.ParseInt(s, 10, 32) - return int32(v) +// StringToInt32 parses s as a base-10 int32. +func StringToInt32(s string) (int32, error) { + v, err := strconv.ParseInt(s, 10, 32) + return int32(v), err } diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 5e63dc17ed..894431e791 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -86,7 +86,9 @@ func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { return w, h } meta := res.GetArbitraryMetadata().GetMetadata() - return conversions.StringToInt32(meta["libre.graph.image.width"]), conversions.StringToInt32(meta["libre.graph.image.height"]) + w, _ := conversions.StringToInt32(meta["libre.graph.image.width"]) + h, _ := conversions.StringToInt32(meta["libre.graph.image.height"]) + return w, h } // fitBox scales (w, h) into a box×box square, preserving aspect and never upscaling. diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index c7f82bedc6..308764501c 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -32,5 +32,7 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { meta := md.GetArbitraryMetadata().GetMetadata() - return conversions.StringToInt32(meta[PreviewWidthKey]), conversions.StringToInt32(meta[PreviewHeightKey]) + width, _ = conversions.StringToInt32(meta[PreviewWidthKey]) + height, _ = conversions.StringToInt32(meta[PreviewHeightKey]) + return } From 836737460ec2a6ce5163615088ce20865c444c6b Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:46:43 +0200 Subject: [PATCH 14/20] refactor: drop named returns in PreviewDimensions --- services/thumbnails/pkg/thumbnail/has_preview.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index 308764501c..85c2924eb8 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -30,9 +30,9 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { } // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). -func PreviewDimensions(md *provider.ResourceInfo) (width, height int32) { +func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() - width, _ = conversions.StringToInt32(meta[PreviewWidthKey]) - height, _ = conversions.StringToInt32(meta[PreviewHeightKey]) - return + w, _ := conversions.StringToInt32(meta[PreviewWidthKey]) + h, _ := conversions.StringToInt32(meta[PreviewHeightKey]) + return w, h } From e019284e326be7e3b583b2f19cd0cb34884dda1c Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:51:06 +0200 Subject: [PATCH 15/20] refactor: StringToInt32 takes an explicit fallback --- pkg/conversions/strings.go | 9 ++++++--- services/graph/pkg/service/v0/thumbnails.go | 4 ++-- services/thumbnails/pkg/thumbnail/has_preview.go | 4 +--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pkg/conversions/strings.go b/pkg/conversions/strings.go index d0e6a8e7e0..a03b7d963b 100644 --- a/pkg/conversions/strings.go +++ b/pkg/conversions/strings.go @@ -16,8 +16,11 @@ func StringToSliceString(src string, sep string) []string { return parts } -// StringToInt32 parses s as a base-10 int32. -func StringToInt32(s string) (int32, error) { +// StringToInt32 parses s as a base-10 int32, returning fallback on any error. +func StringToInt32(s string, fallback int32) int32 { v, err := strconv.ParseInt(s, 10, 32) - return int32(v), err + if err != nil { + return fallback + } + return int32(v) } diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 894431e791..536c18d181 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -86,8 +86,8 @@ func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { return w, h } meta := res.GetArbitraryMetadata().GetMetadata() - w, _ := conversions.StringToInt32(meta["libre.graph.image.width"]) - h, _ := conversions.StringToInt32(meta["libre.graph.image.height"]) + w := conversions.StringToInt32(meta["libre.graph.image.width"], 0) + h := conversions.StringToInt32(meta["libre.graph.image.height"], 0) return w, h } diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index 85c2924eb8..ff1322e7cd 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -32,7 +32,5 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // PreviewDimensions returns the stored embedded-preview dimensions, or (0, 0). func PreviewDimensions(md *provider.ResourceInfo) (int32, int32) { meta := md.GetArbitraryMetadata().GetMetadata() - w, _ := conversions.StringToInt32(meta[PreviewWidthKey]) - h, _ := conversions.StringToInt32(meta[PreviewHeightKey]) - return w, h + return conversions.StringToInt32(meta[PreviewWidthKey], 0), conversions.StringToInt32(meta[PreviewHeightKey], 0) } From 6c5a1c30c4f8cdc84fbdf4441791a65ea12df81a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:54:04 +0200 Subject: [PATCH 16/20] refactor: split PreviewDimensions onto multiple lines --- services/thumbnails/pkg/thumbnail/has_preview.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/thumbnails/pkg/thumbnail/has_preview.go b/services/thumbnails/pkg/thumbnail/has_preview.go index ff1322e7cd..6bd418a5b7 100644 --- a/services/thumbnails/pkg/thumbnail/has_preview.go +++ b/services/thumbnails/pkg/thumbnail/has_preview.go @@ -32,5 +32,7 @@ func HasPreviewForMimeType(mimeType string, hasEmbeddedPreview bool) bool { // 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) + w := conversions.StringToInt32(meta[PreviewWidthKey], 0) + h := conversions.StringToInt32(meta[PreviewHeightKey], 0) + return w, h } From 891323a679f41c59aff562d8b09766995dc13173 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:55:32 +0200 Subject: [PATCH 17/20] docs: drop fork mention in comment --- services/search/pkg/content/tika_preview.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/search/pkg/content/tika_preview.go b/services/search/pkg/content/tika_preview.go index a8d3280de2..76a238e562 100644 --- a/services/search/pkg/content/tika_preview.go +++ b/services/search/pkg/content/tika_preview.go @@ -8,7 +8,7 @@ import ( ) // frontCoverDescription is the picture type Tika reports (as dc:description) for -// the front cover, matching the thumbnailer's selection in the dhowden/tag fork. +// the front cover, matching the thumbnailer's cover selection. const frontCoverDescription = "Cover (front)" // getPreview returns the dimensions of an audio file's embedded cover art from From 74e0ceb1bac07d2f405282da60dcb54a201e4b09 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 20:58:22 +0200 Subject: [PATCH 18/20] docs: trim comments in graph thumbnails --- services/graph/pkg/service/v0/thumbnails.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 536c18d181..065bca5d56 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -18,8 +18,6 @@ func shouldExpand(r *http.Request, relation string) bool { return strings.Contains(r.URL.Query().Get("$expand"), relation) } -// setDriveItemsThumbnails expands the thumbnails relationship on driveItems that -// are 1:1 with infos. func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.DriveItem, infos []*provider.ResourceInfo) { if !shouldExpand(r, "thumbnails") { return @@ -31,9 +29,8 @@ func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.Driv } } -// Requested box sizes. The reported dimensions are aspect-correct for the box, -// not the ClosestMatch-served resolution (that would need THUMBNAILS_RESOLUTIONS -// here; follow-up). +// Reported dims are aspect-fit for the box, not the ClosestMatch-served +// resolution (would need THUMBNAILS_RESOLUTIONS here; follow-up). const ( thumbnailBoxSmall = 36 thumbnailBoxMedium = 48 @@ -46,8 +43,6 @@ func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceIn } } -// previewThumbnailSet returns nil when no preview is available. Dimensions are -// aspect-correct when the source size is known, plus a source (original) entry. func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet { if !thumbnail.HasPreview(res) { return nil @@ -79,8 +74,7 @@ func previewThumbnail(base string, box, srcW, srcH int32) *libregraph.Thumbnail return t } -// previewSourceDimensions: audio cover from oc.preview, images from the image -// facet. Zero when unknown. +// previewSourceDimensions: audio cover from oc.preview, images from the image facet. func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { if w, h := thumbnail.PreviewDimensions(res); w > 0 && h > 0 { return w, h From 231cf4966aaa8407806edc6ddd2f6659eda2d30d Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 21:04:55 +0200 Subject: [PATCH 19/20] refactor(graph): drop unverifiable thumbnail dims, keep source dims --- services/graph/pkg/service/v0/thumbnails.go | 46 ++++++--------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 065bca5d56..370a9015dd 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -2,7 +2,6 @@ package svc import ( "fmt" - "math" "net/http" "strings" @@ -29,8 +28,10 @@ func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.Driv } } -// Reported dims are aspect-fit for the box, not the ClosestMatch-served -// resolution (would need THUMBNAILS_RESOLUTIONS here; follow-up). +// Requested box sizes for the thumbnail URLs. Small/medium/large carry no +// dimensions: the endpoint rounds the box to a configured resolution and fits +// within it, so the served size is not known here. Only the source dimensions +// (below) are exact. const ( thumbnailBoxSmall = 36 thumbnailBoxMedium = 48 @@ -50,28 +51,22 @@ func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, storagespace.FormatResourceID(res.GetId())) - srcW, srcH := previewSourceDimensions(res) set := &libregraph.ThumbnailSet{ - Small: previewThumbnail(base, thumbnailBoxSmall, srcW, srcH), - Medium: previewThumbnail(base, thumbnailBoxMedium, srcW, srcH), - Large: previewThumbnail(base, thumbnailBoxLarge, srcW, srcH), + Small: previewThumbnail(base, thumbnailBoxSmall), + Medium: previewThumbnail(base, thumbnailBoxMedium), + Large: previewThumbnail(base, thumbnailBoxLarge), } - if srcW > 0 && srcH > 0 { - url := fmt.Sprintf("%s&x=%d&y=%d", base, srcW, srcH) - set.Source = &libregraph.Thumbnail{Url: &url, Width: &srcW, Height: &srcH} + if w, h := previewSourceDimensions(res); w > 0 && h > 0 { + url := fmt.Sprintf("%s&x=%d&y=%d", base, w, h) + set.Source = &libregraph.Thumbnail{Url: &url, Width: &w, Height: &h} } return set } -func previewThumbnail(base string, box, srcW, srcH int32) *libregraph.Thumbnail { +func previewThumbnail(base string, box int32) *libregraph.Thumbnail { url := fmt.Sprintf("%s&x=%d&y=%d", base, box, box) - t := &libregraph.Thumbnail{Url: &url} - if srcW > 0 && srcH > 0 { - w, h := fitBox(srcW, srcH, box) - t.Width, t.Height = &w, &h - } - return t + return &libregraph.Thumbnail{Url: &url} } // previewSourceDimensions: audio cover from oc.preview, images from the image facet. @@ -84,20 +79,3 @@ func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) { h := conversions.StringToInt32(meta["libre.graph.image.height"], 0) return w, h } - -// fitBox scales (w, h) into a box×box square, preserving aspect and never upscaling. -func fitBox(w, h, box int32) (int32, int32) { - if w <= box && h <= box { - return w, h - } - scale := math.Min(float64(box)/float64(w), float64(box)/float64(h)) - rw := int32(math.Round(float64(w) * scale)) - rh := int32(math.Round(float64(h) * scale)) - if rw < 1 { - rw = 1 - } - if rh < 1 { - rh = 1 - } - return rw, rh -} From 72d4dcb872b59af38606e913290ccbd9ce78ca67 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 30 Jul 2026 21:07:32 +0200 Subject: [PATCH 20/20] refactor(graph): rename shouldExpand param to relationship --- services/graph/pkg/service/v0/thumbnails.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/graph/pkg/service/v0/thumbnails.go b/services/graph/pkg/service/v0/thumbnails.go index 370a9015dd..8acd1e9374 100644 --- a/services/graph/pkg/service/v0/thumbnails.go +++ b/services/graph/pkg/service/v0/thumbnails.go @@ -13,8 +13,8 @@ import ( "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail" ) -func shouldExpand(r *http.Request, relation string) bool { - return strings.Contains(r.URL.Query().Get("$expand"), relation) +func shouldExpand(r *http.Request, relationship string) bool { + return strings.Contains(r.URL.Query().Get("$expand"), relationship) } func (g Graph) setDriveItemsThumbnails(r *http.Request, items []*libregraph.DriveItem, infos []*provider.ResourceInfo) {