Files
photoprism/internal/api/albums.go
2025-09-18 13:33:18 +02:00

661 lines
16 KiB
Go

package api
import (
"net/http"
"os"
"sync"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/i18n"
)
var albumMutex = sync.Mutex{}
// SaveAlbumYaml saves the album metadata to a YAML backup file.
func SaveAlbumYaml(album entity.Album) {
conf := get.Config()
// Check if saving YAML backup files is enabled.
if !conf.BackupAlbums() {
return
}
// Write album metadata to YAML backup file.
_ = album.SaveBackupYaml(conf.BackupAlbumsPath())
}
// GetAlbum returns album details as JSON.
//
// @Summary returns album details as JSON
// @Id GetAlbum
// @Tags Albums
// @Produce json
// @Success 200 {object} entity.Album
// @Failure 401,403,404,429 {object} i18n.Response
// @Param uid path string true "Album UID"
// @Router /api/v1/albums/{uid} [get]
func GetAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionView)
if s.Abort(c) {
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors can only access shared content.
if (s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
}
// Other restricted users can only access their own or shared content.
if s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) && album.CreatedBy != s.UserUID && !s.HasShare(uid) {
AbortForbidden(c)
return
}
c.JSON(http.StatusOK, album)
})
}
// CreateAlbum creates a new album.
//
// @Summary creates a new album
// @Id CreateAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} entity.Album
// @Failure 400,401,403,429,500 {object} i18n.Response
// @Param album body form.Album true "properties of the album to be created (currently supports Title and Favorite)"
// @Router /api/v1/albums [post]
func CreateAlbum(router *gin.RouterGroup) {
router.POST("/albums", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionCreate)
if s.Abort(c) {
return
}
var frm form.Album
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c, err)
return
}
albumMutex.Lock()
defer albumMutex.Unlock()
conf := get.Config()
album := entity.NewUserAlbum(frm.AlbumTitle, entity.AlbumManual, conf.Settings().Albums.Order.Album, s.UserUID)
album.AlbumFavorite = frm.AlbumFavorite
// Existing album?
if found := album.Find(); found == nil {
// Not found, create new album.
if err := album.Create(); err != nil {
// Report unexpected error.
log.Errorf("album: %s (create)", err)
AbortUnexpectedError(c)
return
}
} else {
// Exists, restore if necessary.
album = found
if !album.Deleted() {
c.JSON(http.StatusOK, album)
return
} else if err := album.Restore(); err != nil {
// Report unexpected error.
log.Errorf("album: %s (restore)", err)
AbortUnexpectedError(c)
return
}
}
UpdateClientConfig()
// Update album YAML backup.
SaveAlbumYaml(*album)
// Return as JSON.
c.JSON(http.StatusOK, album)
})
}
// UpdateAlbum updates album metadata like title and description.
//
// @Summary updates album metadata like title and description
// @Id UpdateAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} entity.Album
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param uid path string true "Album UID"
// @Param album body form.Album true "properties to be updated"
// @Router /api/v1/albums/{uid} [put]
func UpdateAlbum(router *gin.RouterGroup) {
router.PUT("/albums/:uid", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Abort(c) {
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
}
frm, err := form.NewAlbum(album)
if err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
// Assign and validate request form values.
if err = c.BindJSON(frm); err != nil {
AbortBadRequest(c, err)
return
}
albumMutex.Lock()
defer albumMutex.Unlock()
if err = album.SaveForm(frm); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
// Flush album cover cache.
RemoveFromAlbumCoverCache(uid)
// Update client.
UpdateClientConfig()
// Update album YAML backup.
SaveAlbumYaml(album)
c.JSON(http.StatusOK, album)
})
}
// DeleteAlbum deletes an existing album.
//
// @Summary deletes an existing album
// @Id DeleteAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Failure 401,403,404,429,500 {object} i18n.Response
// @Param uid path string true "Album UID"
// @Router /api/v1/albums/{uid} [delete]
func DeleteAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionDelete)
if s.Abort(c) {
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
}
albumMutex.Lock()
defer albumMutex.Unlock()
// Regular, manually created album?
if album.IsDefault() {
// Soft delete manually created albums.
err = album.Delete()
// Also update album YAML backup.
if err != nil {
log.Errorf("album: %s (delete)", err)
AbortDeleteFailed(c)
return
} else {
SaveAlbumYaml(album)
}
} else {
// Permanently delete automatically created albums.
err = album.DeletePermanently()
// Also remove YAML backup file, if it exists.
if err != nil {
log.Errorf("album: %s (delete permanently)", err)
AbortDeleteFailed(c)
return
} else if fileName, relName, nameErr := album.YamlFileName(get.Config().BackupAlbumsPath()); nameErr != nil {
log.Warnf("album: %s (delete %s)", err, clean.Log(relName))
} else if !fs.FileExists(fileName) {
// Do nothing.
} else if removeErr := os.Remove(fileName); removeErr != nil {
log.Errorf("album: %s (delete %s)", err, clean.Log(relName))
}
}
UpdateClientConfig()
c.JSON(http.StatusOK, album)
})
}
// LikeAlbum sets the favorite flag for an album.
//
// @Summary sets the favorite flag for an album
// @Id LikeAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Failure 401,403,404,429,500 {object} i18n.Response
// @Param uid path string true "Album UID"
// @Router /api/v1/albums/{uid}/like [post]
func LikeAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Abort(c) {
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
}
if err = album.Update("AlbumFavorite", true); err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
UpdateClientConfig()
PublishAlbumEvent(StatusUpdated, uid, c)
// Update album YAML backup.
SaveAlbumYaml(album)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgChangesSaved))
})
}
// DislikeAlbum removes the favorite flag from an album.
//
// @Summary removes the favorite flag from an album
// @Id DislikeAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Failure 401,403,404,429,500 {object} i18n.Response
// @Param uid path string true "Album UID"
// @Router /api/v1/albums/{uid}/like [delete]
func DislikeAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Abort(c) {
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
}
if err = album.Update("AlbumFavorite", false); err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
UpdateClientConfig()
PublishAlbumEvent(StatusUpdated, uid, c)
// Update album YAML backup.
SaveAlbumYaml(album)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgChangesSaved))
})
}
// CloneAlbums creates a new album containing pictures from other albums.
//
// @Summary creates a new album containing pictures from other albums
// @Id CloneAlbums
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} gin.H
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param albums body form.Selection true "Album Selection"
// @Param uid path string true "UID of the album to which the pictures are to be added"
// @Router /api/v1/albums/{uid}/clone [post]
func CloneAlbums(router *gin.RouterGroup) {
router.POST("/albums/:uid/clone", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionCreate)
if s.Abort(c) {
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
}
var frm form.Selection
// Assign and validate request form values.
if err = c.BindJSON(&frm); err != nil {
AbortBadRequest(c, err)
return
}
var added []entity.PhotoAlbum
for _, albumUid := range frm.Albums {
cloneAlbum, queryErr := query.AlbumByUID(albumUid)
if queryErr != nil {
log.Errorf("album: %s", queryErr)
continue
}
photos, queryErr := search.AlbumPhotos(cloneAlbum, 100000, false)
if queryErr != nil {
log.Errorf("album: %s", queryErr)
continue
}
added = append(added, album.AddPhotos(photos)...)
}
if len(added) > 0 {
event.SuccessMsg(i18n.MsgSelectionAddedTo, clean.Log(album.Title()))
PublishAlbumEvent(StatusUpdated, album.AlbumUID, c)
// Update album YAML backup.
SaveAlbumYaml(album)
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgAlbumCloned), "album": album, "added": added})
})
}
// AddPhotosToAlbum adds photos to an album.
//
// @Summary adds photos to an album
// @Id AddPhotosToAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} gin.H
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Param uid path string true "Album UID"
// @Router /api/v1/albums/{uid}/photos [post]
func AddPhotosToAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/photos", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Abort(c) {
return
}
var frm form.Selection
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c, err)
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
} else if !album.HasID() {
AbortAlbumNotFound(c)
return
} else if frm.Empty() {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
// Fetch selection from index.
photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortBadRequest(c, err)
return
}
conf := get.Config()
added := album.AddPhotos(photos)
if len(added) > 0 {
if len(added) == 1 {
event.SuccessMsg(i18n.MsgEntryAddedTo, clean.Log(album.Title()))
} else {
event.SuccessMsg(i18n.MsgEntriesAddedTo, len(added), clean.Log(album.Title()))
}
RemoveFromAlbumCoverCache(album.AlbumUID)
PublishAlbumEvent(StatusUpdated, album.AlbumUID, c)
// Update album YAML backup.
SaveAlbumYaml(album)
// Auto-approve photos that have been added to an album,
// see https://github.com/photoprism/photoprism/issues/4229
if conf.Settings().Features.Review {
var approved entity.Photos
for _, p := range photos {
// Skip photos that are not in review.
if p.Approved() {
continue
}
// Approve photo and update YAML backup file.
if err = p.Approve(); err != nil {
log.Errorf("approve: %s", err)
} else {
approved = append(approved, p)
SaveSidecarYaml(p)
}
}
// Update client UI and counts if photos has been approved.
if len(approved) > 0 {
UpdateClientConfig()
event.EntitiesUpdated("photos", approved)
}
}
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgChangesSaved), "album": album, "photos": photos.UIDs(), "added": added})
})
}
// RemovePhotosFromAlbum removes photos from an album.
//
// @Summary removes photos from an album
// @Id RemovePhotosFromAlbum
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} gin.H
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Param uid path string true "Album UID"
// @Router /api/v1/albums/{uid}/photos [delete]
func RemovePhotosFromAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/photos", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Abort(c) {
return
}
var frm form.Selection
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c, err)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors and other restricted users can only access shared content.
if (s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) || s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
return
}
// Find album by UID.
album, err := query.AlbumByUID(uid)
if err != nil {
AbortAlbumNotFound(c)
return
} else if !album.HasID() {
AbortAlbumNotFound(c)
return
}
removed := album.RemovePhotos(frm.Photos)
if len(removed) > 0 {
if len(removed) == 1 {
event.SuccessMsg(i18n.MsgEntryRemovedFrom, clean.Log(album.Title()))
} else {
event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), clean.Log(album.Title()))
}
RemoveFromAlbumCoverCache(album.AlbumUID)
PublishAlbumEvent(StatusUpdated, album.AlbumUID, c)
// Update album YAML backup.
SaveAlbumYaml(album)
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "message": i18n.Msg(i18n.MsgChangesSaved), "album": album, "photos": frm.Photos, "removed": removed})
})
}