From 2d350c190f7a8714021fbe58840dfa3c18da6270 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 26 Nov 2021 21:10:52 +0100 Subject: [PATCH] Places: Only show nearby photos in viewer if > 50 results on map --- frontend/src/pages/places.vue | 48 ++++++++++++-- internal/api/search_geojson.go | 22 +++++-- internal/api/search_geojson_test.go | 12 +++- internal/form/search_geojson.go | 3 + internal/search/geojson.go | 31 ++++++++- internal/search/geojson_result.go | 5 +- internal/search/geojson_result_test.go | 63 ++++++++++++++++++ internal/search/geojson_result_viewer.go | 43 +++++++++++++ internal/search/geojson_result_viewer_test.go | 64 +++++++++++++++++++ internal/search/geojson_test.go | 10 +++ internal/search/search.go | 1 + internal/thumb/names.go | 5 ++ internal/viewer/result.go | 29 +++++++++ internal/viewer/thumb.go | 48 ++++++++++++++ internal/viewer/thumb_test.go | 31 +++++++++ internal/viewer/viewer.go | 32 ++++++++++ 16 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 internal/search/geojson_result_viewer.go create mode 100644 internal/search/geojson_result_viewer_test.go create mode 100644 internal/viewer/result.go create mode 100644 internal/viewer/thumb.go create mode 100644 internal/viewer/thumb_test.go create mode 100644 internal/viewer/viewer.go diff --git a/frontend/src/pages/places.vue b/frontend/src/pages/places.vue index cebac8934..6ceef4720 100644 --- a/frontend/src/pages/places.vue +++ b/frontend/src/pages/places.vue @@ -200,13 +200,52 @@ export default { query: function () { return this.$route.params.q ? this.$route.params.q : ""; }, - openPhoto(id) { + openNearbyPhotos(uid) { + // Abort if uid is empty or results aren't loaded. + if (!uid || this.loading) { + return; + } + + // Get request parameters. + const options = { + params: { + near: uid, + count: 1000, + }, + }; + + this.loading = true; + + // Perform get request to find nearby photos. + return Api.get("geo/view", options).then((r) => { + if (r && r.data && r.data.length > 0) { + // Show photos. + this.$viewer.show(r.data, 0); + } else { + // Don't open viewer if nothing was found. + this.$notify.warn(this.$gettext("No pictures found")); + } + }).finally(() => { + this.loading = false; + }); + }, + openPhoto(uid) { + // Abort if uid is empty or results aren't loaded. + if (!uid || this.loading || !this.result || !this.result.features) { + return; + } + + // Perform a backend request to find nearby photos? + if (this.result.features.length > 50) { + return this.openNearbyPhotos(uid); + } + if (!this.photos || !this.photos.length) { this.photos = this.result.features.map((f) => new Photo(f.properties)); } if (this.photos.length > 0) { - const index = this.photos.findIndex((p) => p.UID === id); + const index = this.photos.findIndex((p) => p.UID === uid); const selected = this.photos[index]; if (selected.Type === TypeVideo || selected.Type === TypeLive) { @@ -275,10 +314,11 @@ export default { } this.initialized = true; - this.loading = false; this.updateMarkers(); - }).catch(() => this.loading = false); + }).finally(() => { + this.loading = false; + }); }, renderMap() { this.map = new mapboxgl.Map(this.options); diff --git a/internal/api/search_geojson.go b/internal/api/search_geojson.go index f0fc93971..b9a6e58ca 100644 --- a/internal/api/search_geojson.go +++ b/internal/api/search_geojson.go @@ -9,14 +9,15 @@ import ( "github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/search" + "github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/pkg/txt" ) -// SearchGeo finds photos and returns results as Geo, so they can be displayed on a map. +// SearchGeo finds photos and returns results as JSON, so they can be displayed on a map or in a viewer. // // GET /api/v1/geo func SearchGeo(router *gin.RouterGroup) { - router.GET("/geo", func(c *gin.Context) { + handler := func(c *gin.Context) { s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionSearch) if s.Invalid() { @@ -55,8 +56,16 @@ func SearchGeo(router *gin.RouterGroup) { return } - // Create GeoJSON from search results. - resp, err := photos.MarshalJSON() + var resp []byte + + // Render JSON response. + switch c.Param("format") { + case "view": + conf := service.Config() + resp, err = photos.ViewerJSON(conf.ContentUri(), conf.ApiUri(), conf.PreviewToken(), conf.DownloadToken()) + default: + resp, err = photos.GeoJSON() + } if err != nil { c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) @@ -66,5 +75,8 @@ func SearchGeo(router *gin.RouterGroup) { AddTokenHeaders(c) c.Data(http.StatusOK, "application/json", resp) - }) + } + + router.GET("/geo", handler) + router.GET("/geo/:format", handler) } diff --git a/internal/api/search_geojson_test.go b/internal/api/search_geojson_test.go index f62eaf444..10324d5ea 100644 --- a/internal/api/search_geojson_test.go +++ b/internal/api/search_geojson_test.go @@ -8,7 +8,7 @@ import ( ) func TestSearchGeo(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("GeoJSON", func(t *testing.T) { app, router, _ := NewApiTest() SearchGeo(router) @@ -16,4 +16,14 @@ func TestSearchGeo(t *testing.T) { result := PerformRequest(app, "GET", "/api/v1/geo") assert.Equal(t, http.StatusOK, result.Code) }) + t.Run("ViewerJSON", func(t *testing.T) { + app, router, _ := NewApiTest() + + SearchGeo(router) + + r := PerformRequest(app, "GET", "/api/v1/geo/view") + + assert.Equal(t, http.StatusOK, r.Code) + t.Logf("response: %s", r.Body.String()) + }) } diff --git a/internal/form/search_geojson.go b/internal/form/search_geojson.go index 18a1fa79d..2a28167e3 100644 --- a/internal/form/search_geojson.go +++ b/internal/form/search_geojson.go @@ -5,6 +5,7 @@ import "time" // SearchGeo represents search form fields for "/api/v1/geo". type SearchGeo struct { Query string `form:"q"` + Near string `form:"near"` Type string `form:"type"` Path string `form:"path"` Folder string `form:"folder"` // Alias for Path @@ -45,6 +46,8 @@ type SearchGeo struct { Color string `form:"color"` Camera int `form:"camera"` Lens int `form:"lens"` + Count int `form:"count" serialize:"-"` + Offset int `form:"offset" serialize:"-"` } // GetQuery returns the query parameter as string. diff --git a/internal/search/geojson.go b/internal/search/geojson.go index 015a9be07..2c921a081 100644 --- a/internal/search/geojson.go +++ b/internal/search/geojson.go @@ -26,6 +26,17 @@ func Geo(f form.SearchGeo) (results GeoResults, err error) { return GeoResults{}, err } + // Search for nearby photos? + if f.Near != "" { + photo := Photo{} + + if err := Db().First(&photo, "photo_uid = ?", f.Near).Error; err != nil { + return GeoResults{}, err + } + + f.S2 = photo.CellID + } + s := UnscopedDb() // s.LogMode(true) @@ -308,7 +319,7 @@ func Geo(f form.SearchGeo) (results GeoResults, err error) { s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), 7) s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max) } else { - // Filter by approx distance to coordinates: + // Filter by approx distance to coordinate: if f.Lat != 0 { latMin := f.Lat - Radius*float32(f.Dist) latMax := f.Lat + Radius*float32(f.Dist) @@ -321,16 +332,32 @@ func Geo(f form.SearchGeo) (results GeoResults, err error) { } } + // Find photos taken before date? if !f.Before.IsZero() { s = s.Where("photos.taken_at <= ?", f.Before.Format("2006-01-02")) } + // Find photos taken after date? if !f.After.IsZero() { s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02")) } - s = s.Order("taken_at, photos.photo_uid") + // Sort order. + if f.Near != "" { + // Sort by distance to UID. + s = s.Order(gorm.Expr("(photos.photo_uid = ?) DESC, ABS((? - photos.photo_lat)*(? - photos.photo_lng))", f.Near, f.Lat, f.Lng)) + s = s.Limit(1000) + } else { + // Default. + s = s.Order("taken_at, photos.photo_uid") + } + // Limit result count? + if f.Count > 0 { + s = s.Limit(f.Count).Offset(f.Offset) + } + + // Fetch results. if result := s.Scan(&results); result.Error != nil { return results, result.Error } diff --git a/internal/search/geojson_result.go b/internal/search/geojson_result.go index 1c53ac968..4303bbc9c 100644 --- a/internal/search/geojson_result.go +++ b/internal/search/geojson_result.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" geojson "github.com/paulmach/go.geojson" + "github.com/photoprism/photoprism/internal/entity" ) @@ -37,8 +38,8 @@ func (photo GeoResult) Lng() float64 { // GeoResults represents a list of geo search results. type GeoResults []GeoResult -// MarshalJSON returns results as Geo. -func (photos GeoResults) MarshalJSON() ([]byte, error) { +// GeoJSON returns results as specified on https://geojson.org/. +func (photos GeoResults) GeoJSON() ([]byte, error) { fc := geojson.NewFeatureCollection() bbox := make([]float64, 4) diff --git a/internal/search/geojson_result_test.go b/internal/search/geojson_result_test.go index 6e5b7bffa..c36aaf393 100644 --- a/internal/search/geojson_result_test.go +++ b/internal/search/geojson_result_test.go @@ -1,10 +1,13 @@ package search import ( + "bytes" "testing" "time" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/entity" ) func TestGeoResult_Lat(t *testing.T) { @@ -38,3 +41,63 @@ func TestGeoResult_Lng(t *testing.T) { } assert.Equal(t, 8.774999618530273, geo.Lng()) } + +func TestGeoResults_GeoJSON(t *testing.T) { + taken := time.Date(2000, 1, 1, 1, 1, 1, 1, time.UTC).UTC().Round(time.Second) + items := GeoResults{ + GeoResult{ + ID: "1", + PhotoLat: 7.775, + PhotoLng: 8.775, + PhotoUID: "p1", + PhotoTitle: "Title 1", + PhotoDescription: "Description 1", + PhotoFavorite: false, + PhotoType: entity.TypeVideo, + FileHash: "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2", + FileWidth: 1920, + FileHeight: 1080, + TakenAt: taken, + }, + GeoResult{ + ID: "2", + PhotoLat: 1.775, + PhotoLng: -5.775, + PhotoUID: "p2", + PhotoTitle: "Title 2", + PhotoDescription: "Description 2", + PhotoFavorite: true, + PhotoType: entity.TypeImage, + FileHash: "da639e836dfa9179e66c619499b0a5e592f72fc1", + FileWidth: 3024, + FileHeight: 3024, + TakenAt: taken, + }, + GeoResult{ + ID: "3", + PhotoLat: -1.775, + PhotoLng: 100.775, + PhotoUID: "p3", + PhotoTitle: "Title 3", + PhotoDescription: "Description 3", + PhotoFavorite: false, + PhotoType: entity.TypeRaw, + FileHash: "412fe4c157a82b636efebc5bc4bc4a15c321aad1", + FileWidth: 5000, + FileHeight: 10000, + TakenAt: taken, + }, + } + + b, err := items.GeoJSON() + + if err != nil { + t.Fatal(err) + } + + expected := []byte("{\"type\":\"FeatureCollection\",\"bbox\":[-5.775000095367432,-1.774999976158142,100.7750015258789,7.775000095367432]") + + assert.Truef(t, bytes.Contains(b, expected), "GeoJSON not as expected") + + t.Logf("result: %s", b) +} diff --git a/internal/search/geojson_result_viewer.go b/internal/search/geojson_result_viewer.go new file mode 100644 index 000000000..d7d9169be --- /dev/null +++ b/internal/search/geojson_result_viewer.go @@ -0,0 +1,43 @@ +package search + +import ( + "encoding/json" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/thumb" + "github.com/photoprism/photoprism/internal/viewer" +) + +// NewViewerResult creates a new photo viewer result. +func NewViewerResult(p GeoResult, contentUri, apiUri, previewToken, downloadToken string) viewer.Result { + return viewer.Result{ + UID: p.PhotoUID, + Title: p.PhotoTitle, + Taken: p.TakenAt, + Description: p.PhotoDescription, + Favorite: p.PhotoFavorite, + Playable: p.PhotoType == entity.TypeVideo || p.PhotoType == entity.TypeLive, + DownloadUrl: viewer.DownloadUrl(p.FileHash, apiUri, downloadToken), + OriginalW: p.FileWidth, + OriginalH: p.FileHeight, + Fit720: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken), + Fit1280: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken), + Fit1920: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken), + Fit2048: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken), + Fit2560: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken), + Fit3840: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken), + Fit4096: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken), + Fit7680: viewer.NewThumb(p.FileWidth, p.FileHeight, p.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken), + } +} + +// ViewerJSON returns the results as photo viewer JSON. +func (photos GeoResults) ViewerJSON(contentUri, apiUri, previewToken, downloadToken string) ([]byte, error) { + results := make(viewer.Results, 0, len(photos)) + + for _, p := range photos { + results = append(results, NewViewerResult(p, contentUri, apiUri, previewToken, downloadToken)) + } + + return json.MarshalIndent(results, "", " ") +} diff --git a/internal/search/geojson_result_viewer_test.go b/internal/search/geojson_result_viewer_test.go new file mode 100644 index 000000000..d99c0b51f --- /dev/null +++ b/internal/search/geojson_result_viewer_test.go @@ -0,0 +1,64 @@ +package search + +import ( + "testing" + "time" + + "github.com/photoprism/photoprism/internal/entity" +) + +func TestGeoResults_ViewerJSON(t *testing.T) { + taken := time.Date(2000, 1, 1, 1, 1, 1, 1, time.UTC).UTC().Round(time.Second) + items := GeoResults{ + GeoResult{ + ID: "1", + PhotoLat: 7.775, + PhotoLng: 8.775, + PhotoUID: "p1", + PhotoTitle: "Title 1", + PhotoDescription: "Description 1", + PhotoFavorite: false, + PhotoType: entity.TypeVideo, + FileHash: "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2", + FileWidth: 1920, + FileHeight: 1080, + TakenAt: taken, + }, + GeoResult{ + ID: "2", + PhotoLat: 1.775, + PhotoLng: -5.775, + PhotoUID: "p2", + PhotoTitle: "Title 2", + PhotoDescription: "Description 2", + PhotoFavorite: true, + PhotoType: entity.TypeImage, + FileHash: "da639e836dfa9179e66c619499b0a5e592f72fc1", + FileWidth: 3024, + FileHeight: 3024, + TakenAt: taken, + }, + GeoResult{ + ID: "3", + PhotoLat: -1.775, + PhotoLng: 100.775, + PhotoUID: "p3", + PhotoTitle: "Title 3", + PhotoDescription: "Description 3", + PhotoFavorite: false, + PhotoType: entity.TypeRaw, + FileHash: "412fe4c157a82b636efebc5bc4bc4a15c321aad1", + FileWidth: 5000, + FileHeight: 10000, + TakenAt: taken, + }, + } + + b, err := items.ViewerJSON("/content", "/api/v1", "preview-token", "download-token") + + if err != nil { + t.Fatal(err) + } + + t.Logf("result: %s", b) +} diff --git a/internal/search/geojson_test.go b/internal/search/geojson_test.go index e683fe769..03e0b7d17 100644 --- a/internal/search/geojson_test.go +++ b/internal/search/geojson_test.go @@ -11,6 +11,16 @@ import ( ) func TestGeo(t *testing.T) { + t.Run("Near", func(t *testing.T) { + query := form.NewGeoSearch("near:pt9jtdre2lvl0y43") + + if result, err := Geo(query); err != nil { + t.Fatal(err) + } else { + t.Logf("RESULT: %#v", result) + assert.LessOrEqual(t, 4, len(result)) + } + }) t.Run("UnknownFaces", func(t *testing.T) { query := form.NewGeoSearch("face:none") diff --git a/internal/search/search.go b/internal/search/search.go index 3e12d2bcf..50da7b4e2 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -33,6 +33,7 @@ package search import ( "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" ) diff --git a/internal/thumb/names.go b/internal/thumb/names.go index 473bf7d72..542986210 100644 --- a/internal/thumb/names.go +++ b/internal/thumb/names.go @@ -10,6 +10,11 @@ func (n Name) Jpeg() string { return string(n) + fs.JpegExt } +// String returns the thumbnail name as string. +func (n Name) String() string { + return string(n) +} + // Names of thumbnail sizes. const ( Tile50 Name = "tile_50" diff --git a/internal/viewer/result.go b/internal/viewer/result.go new file mode 100644 index 000000000..4765aff16 --- /dev/null +++ b/internal/viewer/result.go @@ -0,0 +1,29 @@ +package viewer + +import ( + "time" +) + +// Results represents a list of viewer search results. +type Results []Result + +// Result represents a photo viewer result. +type Result struct { + UID string `json:"uid"` + Title string `json:"title"` + Taken time.Time `json:"taken"` + Description string `json:"description"` + Favorite bool `json:"favorite"` + Playable bool `json:"playable"` + DownloadUrl string `json:"download_url""` + OriginalW int `json:"original_w"` + OriginalH int `json:"original_h"` + Fit720 Thumb `json:"fit_720"` + Fit1280 Thumb `json:"fit_1280"` + Fit1920 Thumb `json:"fit_1920"` + Fit2048 Thumb `json:"fit_2048"` + Fit2560 Thumb `json:"fit_2560"` + Fit3840 Thumb `json:"fit_3840"` + Fit4096 Thumb `json:"fit_4096"` + Fit7680 Thumb `json:"fit_7680"` +} diff --git a/internal/viewer/thumb.go b/internal/viewer/thumb.go new file mode 100644 index 000000000..0ad588865 --- /dev/null +++ b/internal/viewer/thumb.go @@ -0,0 +1,48 @@ +package viewer + +import ( + "fmt" + "math" + + "github.com/photoprism/photoprism/internal/thumb" +) + +// DownloadUrl returns a download url based on hash, api uri, and download token. +func DownloadUrl(h, apiUri, downloadToken string) string { + return fmt.Sprintf("%s/dl/%s?t=%s", apiUri, h, downloadToken) +} + +// ThumbUrl returns a thumbnail url based on hash, thumb name, cdn uri, and preview token. +func ThumbUrl(h, name, contentUri, previewToken string) string { + return fmt.Sprintf("%s/t/%s/%s/%s", contentUri, h, previewToken, name) +} + +// Thumb represents a photo viewer thumbnail. +type Thumb struct { + W int `json:"w"` + H int `json:"h"` + Src string `json:"src"` +} + +// NewThumb creates a new photo viewer thumb. +func NewThumb(w, h int, hash string, s thumb.Size, contentUri, previewToken string) Thumb { + if s.Width >= w && s.Height >= h { + // Smaller + return Thumb{W: w, H: h, Src: ThumbUrl(hash, s.Name.String(), contentUri, previewToken)} + } + + srcAspectRatio := float64(w) / float64(h) + maxAspectRatio := float64(s.Width) / float64(s.Height) + + var newW, newH int + + if srcAspectRatio > maxAspectRatio { + newW = s.Width + newH = int(math.Round(float64(newW) / srcAspectRatio)) + } else { + newH = s.Height + newW = int(math.Round(float64(newH) * srcAspectRatio)) + } + + return Thumb{W: newW, H: newH, Src: ThumbUrl(hash, s.Name.String(), contentUri, previewToken)} +} diff --git a/internal/viewer/thumb_test.go b/internal/viewer/thumb_test.go new file mode 100644 index 000000000..4f086e4d2 --- /dev/null +++ b/internal/viewer/thumb_test.go @@ -0,0 +1,31 @@ +package viewer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/thumb" +) + +func TestNewThumb(t *testing.T) { + fileHash := "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2" + contentUri := "/content" + previewToken := "preview-token" + + t.Run("Fit1280", func(t *testing.T) { + result := NewThumb(1920, 1080, fileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken) + + assert.Equal(t, 1280, result.W) + assert.Equal(t, 720, result.H) + assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_1280", result.Src) + }) + + t.Run("Fit3840", func(t *testing.T) { + result := NewThumb(1920, 1080, fileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken) + + assert.Equal(t, 1920, result.W) + assert.Equal(t, 1080, result.H) + assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_3840", result.Src) + }) +} diff --git a/internal/viewer/viewer.go b/internal/viewer/viewer.go new file mode 100644 index 000000000..9de2665a4 --- /dev/null +++ b/internal/viewer/viewer.go @@ -0,0 +1,32 @@ +/* + +Package viewer provides photo viewer data structures and utility functions. + +Copyright (c) 2018 - 2021 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + + PhotoPrism® is a registered trademark of Michael Mayer. You may use it as required + to describe our software, run your own server, for educational purposes, but not for + offering commercial goods, products, or services without prior written permission. + In other words, please ask. + +Feel free to send an e-mail to hello@photoprism.org if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: +https://docs.photoprism.org/developer-guide/ + +*/ +package viewer