Skip to content

Commit 0d94199

Browse files
committed
feat(graph): expand thumbnails on search hits
Honor $expand=thumbnails on POST /v1beta1/search/query: hits whose resource has a preview (unconditional mimetypes, or embedded cover art signalled by the indexed preview dimensions) carry a thumbnails set with small/medium/large URLs plus an exact-size source thumbnail. Source dimensions come from the index (oc.preview for embedded previews, the image facet for images) instead of a stat, so no extra gateway calls are needed. Also catches the search test stub up with the streaming IndexSpace signature.
1 parent 71ce4be commit 0d94199

3 files changed

Lines changed: 131 additions & 10 deletions

File tree

services/graph/pkg/service/v0/searchquery.go

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
2121
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
2222
"github.com/opencloud-eu/opencloud/services/search/pkg/search"
23+
"github.com/opencloud-eu/opencloud/services/thumbnails/pkg/thumbnail"
2324
)
2425

2526
// SearchQuery runs the search requests and returns results grouped by request
@@ -48,9 +49,11 @@ func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) {
4849
ctx := revaCtx.ContextSetToken(r.Context(), th)
4950
ctx = metadata.Set(ctx, revaCtx.TokenHeader, th)
5051

52+
expandThumbnails := shouldExpand(r, "thumbnails")
53+
5154
responses := make([]libregraph.SearchResponse, 0, len(req.Requests))
5255
for _, sr := range req.Requests {
53-
sresp, err := g.runSingleSearch(ctx, sr)
56+
sresp, err := g.runSingleSearch(ctx, sr, expandThumbnails)
5457
if err != nil {
5558
g.renderSearchError(w, r, err)
5659
return
@@ -62,7 +65,7 @@ func (g Graph) SearchQuery(w http.ResponseWriter, r *http.Request) {
6265
render.JSON(w, r, libregraph.SearchQuery200Response{Value: responses})
6366
}
6467

65-
func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest) (libregraph.SearchResponse, error) {
68+
func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest, expandThumbnails bool) (libregraph.SearchResponse, error) {
6669
from, size := clampPagination(sr.From, sr.Size)
6770

6871
// The gRPC layer has no from field: request from+size matches and slice
@@ -86,7 +89,7 @@ func (g Graph) runSingleSearch(ctx context.Context, sr libregraph.SearchRequest)
8689
start := min(int(from), len(rsp.Matches))
8790
end := min(start+int(size), len(rsp.Matches))
8891
for i := start; i < end; i++ {
89-
hits = append(hits, matchToSearchHit(rsp.Matches[i], int32(i+1)))
92+
hits = append(hits, g.matchToSearchHit(rsp.Matches[i], int32(i+1), expandThumbnails))
9093
}
9194
}
9295

@@ -305,7 +308,7 @@ func (g Graph) renderSearchError(w http.ResponseWriter, r *http.Request, err err
305308
}
306309
}
307310

308-
func matchToSearchHit(m *searchmsg.Match, rank int32) libregraph.SearchHit {
311+
func (g Graph) matchToSearchHit(m *searchmsg.Match, rank int32, expandThumbnails bool) libregraph.SearchHit {
309312
hit := libregraph.SearchHit{
310313
HitId: libregraph.PtrString(searchEntityHitID(m.GetEntity())),
311314
Rank: &rank,
@@ -314,10 +317,34 @@ func matchToSearchHit(m *searchmsg.Match, rank int32) libregraph.SearchHit {
314317
hit.Summary = libregraph.PtrString(h)
315318
}
316319
di := searchEntityToDriveItem(m.GetEntity())
320+
if expandThumbnails {
321+
if set := searchEntityThumbnailSet(m.GetEntity(), g.config.Commons.OpenCloudURL); set != nil {
322+
di.SetThumbnails([]libregraph.ThumbnailSet{*set})
323+
}
324+
}
317325
hit.Resource = di
318326
return hit
319327
}
320328

329+
// searchEntityThumbnailSet is previewThumbnailSet for search hits: preview
330+
// presence and source dimensions come from the index instead of a stat.
331+
func searchEntityThumbnailSet(e *searchmsg.Entity, baseURL string) *libregraph.ThumbnailSet {
332+
if !thumbnail.HasPreviewForMimeType(e.GetMimeType(), e.GetPreview() != nil) {
333+
return nil
334+
}
335+
w, h := searchEntitySourceDimensions(e)
336+
return buildThumbnailSet(previewBaseURL(baseURL, searchEntityHitID(e)), w, h)
337+
}
338+
339+
// searchEntitySourceDimensions mirrors previewSourceDimensions on index data:
340+
// audio cover art from the indexed preview, images from the image facet.
341+
func searchEntitySourceDimensions(e *searchmsg.Entity) (int32, int32) {
342+
if p := e.GetPreview(); p.GetWidth() > 0 && p.GetHeight() > 0 {
343+
return p.GetWidth(), p.GetHeight()
344+
}
345+
return e.GetImage().GetWidth(), e.GetImage().GetHeight()
346+
}
347+
321348
func searchEntityHitID(e *searchmsg.Entity) string {
322349
return storagespace.FormatResourceID(&storageprovider.ResourceId{
323350
StorageId: e.GetId().GetStorageId(),

services/graph/pkg/service/v0/searchquery_test.go

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import (
1414
"go-micro.dev/v4/client"
1515

1616
"github.com/opencloud-eu/opencloud/pkg/log"
17+
"github.com/opencloud-eu/opencloud/pkg/shared"
18+
searchmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/search/v0"
1719
searchsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/search/v0"
20+
"github.com/opencloud-eu/opencloud/services/graph/pkg/config"
1821
)
1922

2023
type stubSearchService struct {
@@ -25,20 +28,25 @@ func (s stubSearchService) Search(_ context.Context, req *searchsvc.SearchReques
2528
return s.search(req)
2629
}
2730

28-
func (s stubSearchService) IndexSpace(_ context.Context, _ *searchsvc.IndexSpaceRequest, _ ...client.CallOption) (*searchsvc.IndexSpaceResponse, error) {
31+
func (s stubSearchService) IndexSpace(_ context.Context, _ *searchsvc.IndexSpaceRequest, _ ...client.CallOption) (searchsvc.SearchProvider_IndexSpaceService, error) {
2932
return nil, nil
3033
}
3134

3235
func graphWithSearch(stub stubSearchService) Graph {
3336
logger := log.NewLogger()
37+
cfg := &config.Config{Commons: &shared.Commons{OpenCloudURL: "https://cloud.example"}}
3438
return Graph{
35-
BaseGraphService: BaseGraphService{logger: &logger},
39+
BaseGraphService: BaseGraphService{logger: &logger, config: cfg},
3640
searchService: stub,
3741
}
3842
}
3943

4044
func postSearchQuery(g Graph, body string) *httptest.ResponseRecorder {
41-
req := httptest.NewRequest(http.MethodPost, "/search/query", bytes.NewBufferString(body))
45+
return postSearchQueryTarget(g, "/search/query", body)
46+
}
47+
48+
func postSearchQueryTarget(g Graph, target, body string) *httptest.ResponseRecorder {
49+
req := httptest.NewRequest(http.MethodPost, target, bytes.NewBufferString(body))
4250
req.Header.Set("Content-Type", "application/json")
4351
rr := httptest.NewRecorder()
4452
g.SearchQuery(rr, req)
@@ -164,4 +172,83 @@ var _ = ginkgo.Describe("SearchQuery", func() {
164172
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
165173
Expect(called).To(BeTrue())
166174
})
175+
176+
ginkgo.It("expands thumbnails on hits when $expand=thumbnails is requested", func() {
177+
coverW, coverH := int32(500), int32(500)
178+
g := graphWithSearch(stubSearchService{
179+
search: func(*searchsvc.SearchRequest) (*searchsvc.SearchResponse, error) {
180+
return &searchsvc.SearchResponse{
181+
TotalMatches: 3,
182+
Matches: []*searchmsg.Match{
183+
{Entity: &searchmsg.Entity{
184+
Id: &searchmsg.ResourceID{StorageId: "s", SpaceId: "sp", OpaqueId: "audio-with-cover"},
185+
Name: "song.flac",
186+
MimeType: "audio/flac",
187+
Preview: &searchmsg.Preview{Width: &coverW, Height: &coverH},
188+
}},
189+
{Entity: &searchmsg.Entity{
190+
Id: &searchmsg.ResourceID{StorageId: "s", SpaceId: "sp", OpaqueId: "audio-plain"},
191+
Name: "plain.flac",
192+
MimeType: "audio/flac",
193+
}},
194+
{Entity: &searchmsg.Entity{
195+
Id: &searchmsg.ResourceID{StorageId: "s", SpaceId: "sp", OpaqueId: "image"},
196+
Name: "pic.png",
197+
MimeType: "image/png",
198+
Image: &searchmsg.Image{Width: int32Ptr(1024), Height: int32Ptr(768)},
199+
}},
200+
},
201+
}, nil
202+
},
203+
})
204+
205+
body := `{"requests": [{"entityTypes": ["driveItem"], "query": {"queryString": "*"}}]}`
206+
207+
rr := postSearchQueryTarget(g, "/search/query?$expand=thumbnails", body)
208+
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
209+
210+
var resp struct {
211+
Value []struct {
212+
HitsContainers []struct {
213+
Hits []struct {
214+
Resource struct {
215+
Name string `json:"name"`
216+
Thumbnails []struct {
217+
Small *struct{ Url string } `json:"small"`
218+
Source *struct {
219+
Url string
220+
Width, Height int32
221+
} `json:"source"`
222+
} `json:"thumbnails"`
223+
} `json:"resource"`
224+
} `json:"hits"`
225+
} `json:"hitsContainers"`
226+
} `json:"value"`
227+
}
228+
Expect(json.Unmarshal(rr.Body.Bytes(), &resp)).To(Succeed())
229+
hits := resp.Value[0].HitsContainers[0].Hits
230+
Expect(hits).To(HaveLen(3))
231+
232+
// audio with indexed cover art: thumbnails incl. exact-size source
233+
withCover := hits[0].Resource
234+
Expect(withCover.Thumbnails).To(HaveLen(1))
235+
Expect(withCover.Thumbnails[0].Small.Url).To(ContainSubstring("https://cloud.example/dav/spaces/"))
236+
Expect(withCover.Thumbnails[0].Source).NotTo(BeNil())
237+
Expect(withCover.Thumbnails[0].Source.Width).To(Equal(int32(500)))
238+
239+
// audio without indexed cover art: no preview, no thumbnails
240+
Expect(hits[1].Resource.Thumbnails).To(BeEmpty())
241+
242+
// image: unconditional preview, source dimensions from the image facet
243+
image := hits[2].Resource
244+
Expect(image.Thumbnails).To(HaveLen(1))
245+
Expect(image.Thumbnails[0].Source).NotTo(BeNil())
246+
Expect(image.Thumbnails[0].Source.Width).To(Equal(int32(1024)))
247+
Expect(image.Thumbnails[0].Source.Height).To(Equal(int32(768)))
248+
249+
// without $expand the hits stay bare
250+
rr = postSearchQueryTarget(g, "/search/query", body)
251+
Expect(rr.Code).To(Equal(http.StatusOK), rr.Body.String())
252+
Expect(rr.Body.String()).NotTo(ContainSubstring("thumbnails"))
253+
})
167254
})

services/graph/pkg/service/v0/thumbnails.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,23 @@ func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph
4848
if !thumbnail.HasPreview(res) {
4949
return nil
5050
}
51+
w, h := previewSourceDimensions(res)
52+
return buildThumbnailSet(previewBaseURL(baseURL, storagespace.FormatResourceID(res.GetId())), w, h)
53+
}
5154

52-
base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
53-
baseURL, storagespace.FormatResourceID(res.GetId()))
55+
func previewBaseURL(baseURL, resourceID string) string {
56+
return fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, resourceID)
57+
}
5458

59+
// buildThumbnailSet assembles the small/medium/large thumbnails plus, when the
60+
// source dimensions are known, the exact-size source thumbnail.
61+
func buildThumbnailSet(base string, w, h int32) *libregraph.ThumbnailSet {
5562
set := &libregraph.ThumbnailSet{
5663
Small: previewThumbnail(base, thumbnailBoxSmall),
5764
Medium: previewThumbnail(base, thumbnailBoxMedium),
5865
Large: previewThumbnail(base, thumbnailBoxLarge),
5966
}
60-
if w, h := previewSourceDimensions(res); w > 0 && h > 0 {
67+
if w > 0 && h > 0 {
6168
url := fmt.Sprintf("%s&x=%d&y=%d", base, w, h)
6269
set.Source = &libregraph.Thumbnail{Url: &url, Width: &w, Height: &h}
6370
}

0 commit comments

Comments
 (0)