From 845cc5a77d89cdb95af39a9e66a6aaf98746dd1a Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Wed, 11 Dec 2019 19:11:44 +0100 Subject: [PATCH] Backend: API stub for editing photo metadata Signed-off-by: Michael Mayer --- internal/api/photo.go | 160 +++++++++++++++++++++++++++++++++ internal/api/photos.go | 96 -------------------- internal/entity/entity.go | 13 +++ internal/entity/entity_test.go | 9 +- internal/entity/photo.go | 72 ++++++++++++--- internal/repo/photos.go | 11 +++ internal/server/routes.go | 2 + 7 files changed, 254 insertions(+), 109 deletions(-) create mode 100644 internal/api/photo.go diff --git a/internal/api/photo.go b/internal/api/photo.go new file mode 100644 index 000000000..7b4794925 --- /dev/null +++ b/internal/api/photo.go @@ -0,0 +1,160 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/repo" + "github.com/photoprism/photoprism/internal/util" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/photo/:uuid +// +// Parameters: +// uuid: string PhotoUUID as returned by the API +func GetPhoto(router *gin.RouterGroup, conf *config.Config) { + router.GET("/photos/:uuid", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + r := repo.New(conf.OriginalsPath(), conf.Db()) + p, err := r.PreloadPhotoByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, p) + }) +} + +// PUT /api/v1/photos/:uuid +func UpdatePhoto(router *gin.RouterGroup, conf *config.Config) { + router.PUT("/photos/:uuid", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + r := repo.New(conf.OriginalsPath(), conf.Db()) + + m, err := r.FindPhotoByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())}) + return + } + + if err := c.BindJSON(&m); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())}) + return + } + + conf.Db().Save(&m) + + event.Success("photo updated") + + c.JSON(http.StatusOK, m) + }) +} + +// GET /api/v1/photos/:uuid/download +// +// Parameters: +// uuid: string PhotoUUID as returned by the API +func GetPhotoDownload(router *gin.RouterGroup, conf *config.Config) { + router.GET("/photos/:uuid/download", func(c *gin.Context) { + r := repo.New(conf.OriginalsPath(), conf.Db()) + file, err := r.FindFileByPhotoUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) + return + } + + fileName := fmt.Sprintf("%s/%s", conf.OriginalsPath(), file.FileName) + + if !util.Exists(fileName) { + log.Errorf("could not find original: %s", c.Param("uuid")) + c.Data(404, "image/svg+xml", photoIconSvg) + + // Set missing flag so that the file doesn't show up in search results anymore + file.FileMissing = true + conf.Db().Save(&file) + return + } + + downloadFileName := file.DownloadFileName() + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName)) + + c.File(fileName) + }) +} + +// POST /api/v1/photos/:uuid/like +// +// Parameters: +// uuid: string PhotoUUID as returned by the API +func LikePhoto(router *gin.RouterGroup, conf *config.Config) { + router.POST("/photos/:uuid/like", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + r := repo.New(conf.OriginalsPath(), conf.Db()) + m, err := r.FindPhotoByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())}) + return + } + + m.PhotoFavorite = true + conf.Db().Save(&m) + + event.Publish("count.favorites", event.Data{ + "count": 1, + }) + + c.JSON(http.StatusOK, gin.H{"photo": m}) + }) +} + +// DELETE /api/v1/photos/:uuid/like +// +// Parameters: +// uuid: string PhotoUUID as returned by the API +func DislikePhoto(router *gin.RouterGroup, conf *config.Config) { + router.DELETE("/photos/:uuid/like", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + r := repo.New(conf.OriginalsPath(), conf.Db()) + m, err := r.FindPhotoByUUID(c.Param("uuid")) + + if err != nil { + c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())}) + return + } + + m.PhotoFavorite = false + conf.Db().Save(&m) + + event.Publish("count.favorites", event.Data{ + "count": -1, + }) + + c.JSON(http.StatusOK, gin.H{"photo": m}) + }) +} diff --git a/internal/api/photos.go b/internal/api/photos.go index 00594ff0e..8cd2ea073 100644 --- a/internal/api/photos.go +++ b/internal/api/photos.go @@ -1,12 +1,10 @@ package api import ( - "fmt" "net/http" "strconv" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/repo" "github.com/photoprism/photoprism/internal/util" @@ -54,97 +52,3 @@ func GetPhotos(router *gin.RouterGroup, conf *config.Config) { c.JSON(http.StatusOK, result) }) } - -// GET /api/v1/photos/:uuid/download -// -// Parameters: -// uuid: string PhotoUUID as returned by the API -func GetPhotoDownload(router *gin.RouterGroup, conf *config.Config) { - router.GET("/photos/:uuid/download", func(c *gin.Context) { - r := repo.New(conf.OriginalsPath(), conf.Db()) - file, err := r.FindFileByPhotoUUID(c.Param("uuid")) - - if err != nil { - c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) - return - } - - fileName := fmt.Sprintf("%s/%s", conf.OriginalsPath(), file.FileName) - - if !util.Exists(fileName) { - log.Errorf("could not find original: %s", c.Param("uuid")) - c.Data(404, "image/svg+xml", photoIconSvg) - - // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) - return - } - - downloadFileName := file.DownloadFileName() - - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName)) - - c.File(fileName) - }) -} - -// POST /api/v1/photos/:uuid/like -// -// Parameters: -// uuid: string PhotoUUID as returned by the API -func LikePhoto(router *gin.RouterGroup, conf *config.Config) { - router.POST("/photos/:uuid/like", func(c *gin.Context) { - if Unauthorized(c, conf) { - c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) - return - } - - r := repo.New(conf.OriginalsPath(), conf.Db()) - m, err := r.FindPhotoByUUID(c.Param("uuid")) - - if err != nil { - c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())}) - return - } - - m.PhotoFavorite = true - conf.Db().Save(&m) - - event.Publish("count.favorites", event.Data{ - "count": 1, - }) - - c.JSON(http.StatusOK, gin.H{"photo": m}) - }) -} - -// DELETE /api/v1/photos/:uuid/like -// -// Parameters: -// uuid: string PhotoUUID as returned by the API -func DislikePhoto(router *gin.RouterGroup, conf *config.Config) { - router.DELETE("/photos/:uuid/like", func(c *gin.Context) { - if Unauthorized(c, conf) { - c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) - return - } - - r := repo.New(conf.OriginalsPath(), conf.Db()) - m, err := r.FindPhotoByUUID(c.Param("uuid")) - - if err != nil { - c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())}) - return - } - - m.PhotoFavorite = false - conf.Db().Save(&m) - - event.Publish("count.favorites", event.Data{ - "count": -1, - }) - - c.JSON(http.StatusOK, gin.H{"photo": m}) - }) -} diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 221558d4e..3183593ec 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -8,3 +8,16 @@ Additional information concerning data storage can be found in our Developer Gui https://github.com/photoprism/photoprism/wiki/Storage */ package entity + +import ( + "github.com/jinzhu/gorm" + "github.com/photoprism/photoprism/internal/event" +) + +var log = event.Log + +func logError(result *gorm.DB) { + if result.Error != nil { + log.Error(result.Error.Error()) + } +} diff --git a/internal/entity/entity_test.go b/internal/entity/entity_test.go index c43f8c9e3..d177ced24 100644 --- a/internal/entity/entity_test.go +++ b/internal/entity/entity_test.go @@ -1,14 +1,19 @@ package entity import ( + "bytes" "os" "testing" - log "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus" ) +var logBuffer bytes.Buffer + func TestMain(m *testing.M) { - log.SetLevel(log.DebugLevel) + log = logrus.StandardLogger() + log.Out = &logBuffer + log.SetLevel(logrus.DebugLevel) code := m.Run() os.Exit(code) } diff --git a/internal/entity/photo.go b/internal/entity/photo.go index fb8758916..293763c64 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -16,15 +16,15 @@ type Photo struct { PhotoToken string `gorm:"type:varchar(64);"` PhotoPath string `gorm:"type:varchar(128);index;"` PhotoName string - PhotoTitle string + PhotoTitle string `json:"PhotoTitle"` PhotoTitleChanged bool - PhotoDescription string `gorm:"type:text;"` - PhotoNotes string `gorm:"type:text;"` - PhotoArtist string - PhotoFavorite bool - PhotoPrivate bool - PhotoNSFW bool - PhotoStory bool + PhotoDescription string `gorm:"type:text;"` + PhotoNotes string `gorm:"type:text;"` + PhotoArtist string `json:"PhotoArtist"` + PhotoFavorite bool `json:"PhotoFavorite"` + PhotoPrivate bool `json:"PhotoPrivate"` + PhotoNSFW bool `json:"PhotoNSFW"` + PhotoStory bool `json:"PhotoStory"` PhotoLat float64 `gorm:"index;"` PhotoLong float64 `gorm:"index;"` PhotoAltitude int @@ -48,9 +48,10 @@ type Photo struct { TakenAtLocal time.Time TakenAtChanged bool TimeZone string - Labels []*PhotoLabel - Albums []*PhotoAlbum - Files []*File + Files []File + Labels []Label + Keywords []Keyword + Albums []Album } func (m *Photo) BeforeCreate(scope *gorm.Scope) error { @@ -94,3 +95,52 @@ func (m *Photo) IndexKeywords(keywords []string, db *gorm.DB) { db.Where("photo_id = ? AND keyword_id NOT IN (?)", m.ID, keywordIds).Delete(&PhotoKeyword{}) } + +func (m *Photo) PreloadFiles(db *gorm.DB) { + q := db.NewScope(nil).DB(). + Table("files"). + Select(`files.*`). + Where("files.photo_id = ?", m.ID). + Order("files.file_primary DESC") + + logError(q.Scan(&m.Files)) +} + +func (m *Photo) PreloadLabels(db *gorm.DB) { + q := db.NewScope(nil).DB(). + Table("labels"). + Select(`labels.*`). + Joins("JOIN photos_labels ON photos_labels.label_id = labels.id AND photos_labels.photo_id = ?", m.ID). + Where("labels.deleted_at IS NULL"). + Order("labels.label_name ASC") + + logError(q.Scan(&m.Labels)) +} + +func (m *Photo) PreloadKeywords(db *gorm.DB) { + q := db.NewScope(nil).DB(). + Table("keywords"). + Select(`keywords.*`). + Joins("JOIN photos_keywords ON photos_keywords.keyword_id = keywords.id AND photos_keywords.photo_id = ?", m.ID). + Order("keywords.keyword ASC") + + logError(q.Scan(&m.Keywords)) +} + +func (m *Photo) PreloadAlbums(db *gorm.DB) { + q := db.NewScope(nil).DB(). + Table("albums"). + Select(`albums.*`). + Joins("JOIN photos_albums ON photos_albums.album_uuid = albums.album_uuid AND photos_albums.photo_uuid = ?", m.PhotoUUID). + Where("albums.deleted_at IS NULL"). + Order("albums.album_name ASC") + + logError(q.Scan(&m.Albums)) +} + +func (m *Photo) PreloadMany(db *gorm.DB) { + m.PreloadFiles(db) + m.PreloadLabels(db) + m.PreloadKeywords(db) + m.PreloadAlbums(db) +} diff --git a/internal/repo/photos.go b/internal/repo/photos.go index 9b104e871..71ffafb9a 100644 --- a/internal/repo/photos.go +++ b/internal/repo/photos.go @@ -336,3 +336,14 @@ func (s *Repo) FindPhotoByUUID(photoUUID string) (photo entity.Photo, err error) return photo, nil } + +// PreloadPhotoByUUID returns a Photo based on the UUID with all dependencies preloaded. +func (s *Repo) PreloadPhotoByUUID(photoUUID string) (photo entity.Photo, err error) { + if err := s.db.Where("photo_uuid = ?", photoUUID).Preload("Camera").Preload("Lens").First(&photo).Error; err != nil { + return photo, err + } + + photo.PreloadMany(s.db) + + return photo, nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index d34990ee1..ffa364ef8 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -27,6 +27,8 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.CreateZip(v1, conf) api.DownloadZip(v1, conf) + api.GetPhoto(v1, conf) + api.UpdatePhoto(v1, conf) api.GetPhotos(v1, conf) api.GetPhotoDownload(v1, conf) api.LikePhoto(v1, conf)