Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
338ed40
feat(thumbnails): split preview mime types + add HasPreview
dschmidt Jul 30, 2026
e2b7574
feat(search): index embedded preview dimensions as oc.preview
dschmidt Jul 30, 2026
0cd1737
fix(search): derive facets from container metadata only
dschmidt Jul 30, 2026
9b83814
feat(search): prefer the front cover for the preview dimensions
dschmidt Jul 30, 2026
0962e85
feat(graph): thumbnails relationship on driveItem listings
dschmidt Jul 30, 2026
04e5a92
feat(search): honest has-preview for audio in search results
dschmidt Jul 30, 2026
aeb98ec
docs: shorten comments
dschmidt Jul 30, 2026
863ffcc
refactor(graph): generic shouldExpand(r, relation)
dschmidt Jul 30, 2026
5730d7b
docs: tighten comments
dschmidt Jul 30, 2026
8d0b8ed
refactor: move getPreview to tika_preview.go, rename has_preview.go
dschmidt Jul 30, 2026
8a10d2a
refactor: add conversions.StringToInt32, compact HasPreview, drop dup
dschmidt Jul 30, 2026
0ebc662
test: use ginkgo/gomega for the preview tests
dschmidt Jul 30, 2026
fe6a23a
refactor: StringToInt32 returns the parse error
dschmidt Jul 30, 2026
8367374
refactor: drop named returns in PreviewDimensions
dschmidt Jul 30, 2026
e019284
refactor: StringToInt32 takes an explicit fallback
dschmidt Jul 30, 2026
6c5a1c3
refactor: split PreviewDimensions onto multiple lines
dschmidt Jul 30, 2026
891323a
docs: drop fork mention in comment
dschmidt Jul 30, 2026
74e0ceb
docs: trim comments in graph thumbnails
dschmidt Jul 30, 2026
231cf49
refactor(graph): drop unverifiable thumbnail dims, keep source dims
dschmidt Jul 30, 2026
72d4dcb
refactor(graph): rename shouldExpand param to relationship
dschmidt Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pkg/conversions/strings.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package conversions

import (
"strconv"
"strings"
)

Expand All @@ -14,3 +15,12 @@ func StringToSliceString(src string, sep string) []string {

return parts
}

// StringToInt32 parses s as a base-10 int32, returning fallback on any error.
func StringToInt32(s string, fallback int32) int32 {
v, err := strconv.ParseInt(s, 10, 32)
if err != nil {
return fallback
}
return int32(v)
}
375 changes: 232 additions & 143 deletions protogen/gen/opencloud/messages/search/v0/search.pb.go

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions protogen/gen/opencloud/messages/search/v0/search.pb.web.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions protogen/gen/opencloud/services/search/v0/search.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@
"items": {
"type": "string"
}
},
"preview": {
"$ref": "#/definitions/v0Preview"
}
}
},
Expand Down Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions protogen/proto/opencloud/messages/search/v0/search.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,6 +87,7 @@ message Entity {
Image image = 18;
Photo photo = 19;
repeated string favorites = 20;
Preview preview = 21;
}

message Match {
Expand Down
2 changes: 2 additions & 0 deletions services/graph/pkg/service/v0/driveitems.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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})
Expand Down
81 changes: 81 additions & 0 deletions services/graph/pkg/service/v0/thumbnails.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package svc

import (
"fmt"
"net/http"
"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"
)

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) {
if !shouldExpand(r, "thumbnails") {
return
}
for i := range items {
if i < len(infos) {
setDriveItemThumbnails(items[i], infos[i], g.config.Commons.OpenCloudURL)
}
}
}

// 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
thumbnailBoxLarge = 96
)

func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string) {
if set := previewThumbnailSet(res, baseURL); set != nil {
item.SetThumbnails([]libregraph.ThumbnailSet{*set})
}
}

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()))

set := &libregraph.ThumbnailSet{
Small: previewThumbnail(base, thumbnailBoxSmall),
Medium: previewThumbnail(base, thumbnailBoxMedium),
Large: previewThumbnail(base, thumbnailBoxLarge),
}
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 int32) *libregraph.Thumbnail {
url := fmt.Sprintf("%s&x=%d&y=%d", base, box, box)
return &libregraph.Thumbnail{Url: &url}
}

// previewSourceDimensions: audio cover from oc.preview, images from the image facet.
func previewSourceDimensions(res *provider.ResourceInfo) (int32, int32) {
if w, h := thumbnail.PreviewDimensions(res); w > 0 && h > 0 {
return w, h
}
meta := res.GetArbitraryMetadata().GetMetadata()
w := conversions.StringToInt32(meta["libre.graph.image.width"], 0)
h := conversions.StringToInt32(meta["libre.graph.image.height"], 0)
return w, h
}
1 change: 1 addition & 0 deletions services/search/pkg/bleve/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
}

Expand Down
16 changes: 16 additions & 0 deletions services/search/pkg/content/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ 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 (e.g. audio cover art).
// Internal signal, not a Graph facet; its presence marks that a preview exists.
type Preview struct {
Width int32 `json:"width"`
Height int32 `json:"height"`
}

// ToMap flows Preview through the shared facet flattening, under oc.preview.
func (p Preview) ToMap() (map[string]interface{}, error) {
return map[string]interface{}{
"width": p.Width,
"height": p.Height,
}, nil
}

func CleanString(content, langCode string) string {
Expand Down
19 changes: 14 additions & 5 deletions services/search/pkg/content/tika.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ 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 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))
Expand All @@ -86,13 +90,18 @@ 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 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)
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 {
doc.Content = CleanString(doc.Content, langCode)
}
Expand Down
47 changes: 47 additions & 0 deletions services/search/pkg/content/tika_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package content

import (
"strconv"
"strings"

"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
)

// frontCoverDescription is the picture type Tika reports (as dc:description) for
// the front cover, matching the thumbnailer's cover selection.
const frontCoverDescription = "Cover (front)"

// getPreview returns the dimensions of an audio file's embedded cover art from
// Tika's recursive metadata, preferring the front cover (dc:description) and
// falling back to the first image, matching the thumbnailer's selection. It only
// runs for EmbeddedPreviewMimeTypes.
func getPreview(mimeType string, metas []map[string][]string) *Preview {
if _, ok := thumbnail.EmbeddedPreviewMimeTypes[mimeType]; !ok {
return nil
}
var first *Preview
for _, meta := range metas {
ct, err := getFirstValue(meta, "Content-Type")
if err != nil || !strings.HasPrefix(ct, "image/") {
continue
}
w, wErr := getFirstValue(meta, "tiff:ImageWidth")
h, hErr := getFirstValue(meta, "tiff:ImageLength")
if wErr != nil || hErr != nil {
continue
}
width, wErr := strconv.ParseInt(w, 10, 32)
height, hErr := strconv.ParseInt(h, 10, 32)
if wErr != nil || hErr != nil || width <= 0 || height <= 0 {
continue
}
preview := &Preview{Width: int32(width), Height: int32(height)}
if desc, _ := getFirstValue(meta, "dc:description"); desc == frontCoverDescription {
return preview
}
if first == nil {
first = preview
}
}
return first
}
49 changes: 49 additions & 0 deletions services/search/pkg/content/tika_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package content

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("getPreview", func() {
audio := map[string][]string{"Content-Type": {"audio/mpeg"}}
cover := map[string][]string{
"Content-Type": {"image/jpeg"},
"tiff:ImageWidth": {"500"},
"tiff:ImageLength": {"400"},
}
coverNoDims := map[string][]string{"Content-Type": {"image/jpeg"}}

It("returns the embedded cover dimensions for audio", func() {
p := getPreview("audio/mpeg", []map[string][]string{audio, cover})
Expect(p).ToNot(BeNil())
Expect(*p).To(Equal(Preview{Width: 500, Height: 400}))
})

It("returns nil when the audio has no cover", func() {
Expect(getPreview("audio/mpeg", []map[string][]string{audio})).To(BeNil())
})

It("returns nil when the cover lacks dimensions", func() {
Expect(getPreview("audio/mpeg", []map[string][]string{audio, coverNoDims})).To(BeNil())
})

It("prefers the front cover over an earlier back cover", func() {
back := map[string][]string{
"Content-Type": {"image/jpeg"}, "dc:description": {"Cover (back)"},
"tiff:ImageWidth": {"30"}, "tiff:ImageLength": {"30"},
}
front := map[string][]string{
"Content-Type": {"image/jpeg"}, "dc:description": {"Cover (front)"},
"tiff:ImageWidth": {"64"}, "tiff:ImageLength": {"40"},
}
p := getPreview("audio/mpeg", []map[string][]string{audio, back, front})
Expect(p).ToNot(BeNil())
Expect(*p).To(Equal(Preview{Width: 64, Height: 40}))
})

It("is gated to embedded-preview types", func() {
// an image is unconditional; its preview is not driven by oc.preview.
Expect(getPreview("image/png", []map[string][]string{cover})).To(BeNil())
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
}

Expand Down
1 change: 1 addition & 0 deletions services/search/pkg/search/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading