Places: Only show nearby photos in viewer if > 50 results on map

This commit is contained in:
Michael Mayer 2021-11-26 21:10:52 +01:00
parent 650817a9e0
commit 2d350c190f
16 changed files with 433 additions and 14 deletions

View file

@ -200,13 +200,52 @@ export default {
query: function () { query: function () {
return this.$route.params.q ? this.$route.params.q : ""; 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) { if (!this.photos || !this.photos.length) {
this.photos = this.result.features.map((f) => new Photo(f.properties)); this.photos = this.result.features.map((f) => new Photo(f.properties));
} }
if (this.photos.length > 0) { 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]; const selected = this.photos[index];
if (selected.Type === TypeVideo || selected.Type === TypeLive) { if (selected.Type === TypeVideo || selected.Type === TypeLive) {
@ -275,10 +314,11 @@ export default {
} }
this.initialized = true; this.initialized = true;
this.loading = false;
this.updateMarkers(); this.updateMarkers();
}).catch(() => this.loading = false); }).finally(() => {
this.loading = false;
});
}, },
renderMap() { renderMap() {
this.map = new mapboxgl.Map(this.options); this.map = new mapboxgl.Map(this.options);

View file

@ -9,14 +9,15 @@ import (
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt" "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 // GET /api/v1/geo
func SearchGeo(router *gin.RouterGroup) { 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) s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionSearch)
if s.Invalid() { if s.Invalid() {
@ -55,8 +56,16 @@ func SearchGeo(router *gin.RouterGroup) {
return return
} }
// Create GeoJSON from search results. var resp []byte
resp, err := photos.MarshalJSON()
// 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 { if err != nil {
c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())}) c.AbortWithStatusJSON(400, gin.H{"error": txt.UcFirst(err.Error())})
@ -66,5 +75,8 @@ func SearchGeo(router *gin.RouterGroup) {
AddTokenHeaders(c) AddTokenHeaders(c)
c.Data(http.StatusOK, "application/json", resp) c.Data(http.StatusOK, "application/json", resp)
}) }
router.GET("/geo", handler)
router.GET("/geo/:format", handler)
} }

View file

@ -8,7 +8,7 @@ import (
) )
func TestSearchGeo(t *testing.T) { func TestSearchGeo(t *testing.T) {
t.Run("Success", func(t *testing.T) { t.Run("GeoJSON", func(t *testing.T) {
app, router, _ := NewApiTest() app, router, _ := NewApiTest()
SearchGeo(router) SearchGeo(router)
@ -16,4 +16,14 @@ func TestSearchGeo(t *testing.T) {
result := PerformRequest(app, "GET", "/api/v1/geo") result := PerformRequest(app, "GET", "/api/v1/geo")
assert.Equal(t, http.StatusOK, result.Code) 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())
})
} }

View file

@ -5,6 +5,7 @@ import "time"
// SearchGeo represents search form fields for "/api/v1/geo". // SearchGeo represents search form fields for "/api/v1/geo".
type SearchGeo struct { type SearchGeo struct {
Query string `form:"q"` Query string `form:"q"`
Near string `form:"near"`
Type string `form:"type"` Type string `form:"type"`
Path string `form:"path"` Path string `form:"path"`
Folder string `form:"folder"` // Alias for Path Folder string `form:"folder"` // Alias for Path
@ -45,6 +46,8 @@ type SearchGeo struct {
Color string `form:"color"` Color string `form:"color"`
Camera int `form:"camera"` Camera int `form:"camera"`
Lens int `form:"lens"` Lens int `form:"lens"`
Count int `form:"count" serialize:"-"`
Offset int `form:"offset" serialize:"-"`
} }
// GetQuery returns the query parameter as string. // GetQuery returns the query parameter as string.

View file

@ -26,6 +26,17 @@ func Geo(f form.SearchGeo) (results GeoResults, err error) {
return GeoResults{}, err 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 := UnscopedDb()
// s.LogMode(true) // 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) s2Min, s2Max := s2.PrefixedRange(pluscode.S2(f.Olc), 7)
s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max) s = s.Where("photos.cell_id BETWEEN ? AND ?", s2Min, s2Max)
} else { } else {
// Filter by approx distance to coordinates: // Filter by approx distance to coordinate:
if f.Lat != 0 { if f.Lat != 0 {
latMin := f.Lat - Radius*float32(f.Dist) latMin := f.Lat - Radius*float32(f.Dist)
latMax := 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() { if !f.Before.IsZero() {
s = s.Where("photos.taken_at <= ?", f.Before.Format("2006-01-02")) s = s.Where("photos.taken_at <= ?", f.Before.Format("2006-01-02"))
} }
// Find photos taken after date?
if !f.After.IsZero() { if !f.After.IsZero() {
s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02")) s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02"))
} }
// 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") 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 { if result := s.Scan(&results); result.Error != nil {
return results, result.Error return results, result.Error
} }

View file

@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
geojson "github.com/paulmach/go.geojson" geojson "github.com/paulmach/go.geojson"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
) )
@ -37,8 +38,8 @@ func (photo GeoResult) Lng() float64 {
// GeoResults represents a list of geo search results. // GeoResults represents a list of geo search results.
type GeoResults []GeoResult type GeoResults []GeoResult
// MarshalJSON returns results as Geo. // GeoJSON returns results as specified on https://geojson.org/.
func (photos GeoResults) MarshalJSON() ([]byte, error) { func (photos GeoResults) GeoJSON() ([]byte, error) {
fc := geojson.NewFeatureCollection() fc := geojson.NewFeatureCollection()
bbox := make([]float64, 4) bbox := make([]float64, 4)

View file

@ -1,10 +1,13 @@
package search package search
import ( import (
"bytes"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
) )
func TestGeoResult_Lat(t *testing.T) { func TestGeoResult_Lat(t *testing.T) {
@ -38,3 +41,63 @@ func TestGeoResult_Lng(t *testing.T) {
} }
assert.Equal(t, 8.774999618530273, geo.Lng()) 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)
}

View file

@ -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, "", " ")
}

View file

@ -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)
}

View file

@ -11,6 +11,16 @@ import (
) )
func TestGeo(t *testing.T) { 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) { t.Run("UnknownFaces", func(t *testing.T) {
query := form.NewGeoSearch("face:none") query := form.NewGeoSearch("face:none")

View file

@ -33,6 +33,7 @@ package search
import ( import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
) )

View file

@ -10,6 +10,11 @@ func (n Name) Jpeg() string {
return string(n) + fs.JpegExt return string(n) + fs.JpegExt
} }
// String returns the thumbnail name as string.
func (n Name) String() string {
return string(n)
}
// Names of thumbnail sizes. // Names of thumbnail sizes.
const ( const (
Tile50 Name = "tile_50" Tile50 Name = "tile_50"

29
internal/viewer/result.go Normal file
View file

@ -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"`
}

48
internal/viewer/thumb.go Normal file
View file

@ -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)}
}

View file

@ -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)
})
}

32
internal/viewer/viewer.go Normal file
View file

@ -0,0 +1,32 @@
/*
Package viewer provides photo viewer data structures and utility functions.
Copyright (c) 2018 - 2021 Michael Mayer <hello@photoprism.org>
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 <https://www.gnu.org/licenses/>.
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