Working download photo method

Signed-off-by: Kris Nóva <kris@nivenly.com>
This commit is contained in:
Kris Nóva 2021-02-09 18:25:46 -08:00
parent c1a45bf8e3
commit b7fd487a2e
18 changed files with 918 additions and 170 deletions

View file

@ -16,6 +16,7 @@ const (
)
type V1Client struct {
downloadToken string
token string
apihost *url.URL
client http.Client
@ -23,11 +24,12 @@ type V1Client struct {
// New will only accept a url.URL so that we know
// all errors have been handled up until this point
func New(connURL *url.URL, token string) *V1Client {
func New(connURL *url.URL, token, downloadToken string) *V1Client {
return &V1Client{
client: http.Client{},
apihost: connURL,
token: token,
downloadToken: downloadToken,
}
}
@ -196,6 +198,60 @@ func (v1 *V1Client) PUT(payload interface{}, endpointFormat string, a ...interfa
return response
}
// DELETE is the V1 POST function. By design it will check globally for all non 200
// responses and return an error if a non 200 is encountered.
// DELETE will accept a payload.
//
// Error Codes:
// -1 Unable to create request
// -2 Unable to write payload
// -3 Unable to JSON Marshal
func (v1 *V1Client) DELETE(payload interface{}, endpointFormat string, a ...interface{}) *V1Response {
url := v1.Endpoint(fmt.Sprintf(endpointFormat, a...))
//logger.Debug("POST [%s]", url)
response := &V1Response{}
jBytes, err := json.Marshal(&payload)
if err != nil {
response.StatusCode = -3
response.Error = fmt.Errorf("unable to marshal JSON: %v", err)
return response
}
buffer := &bytes.Buffer{}
_, err = buffer.Write(jBytes)
if err != nil {
response.StatusCode = -2
response.Error = fmt.Errorf("unable to write payload: %v", err)
return response
}
req, err := http.NewRequest("DELETE", url, buffer)
if err != nil {
response.StatusCode = -1
response.Error = fmt.Errorf("unable to create new request: %v", err)
return response
}
req.Header.Set("Content-Type", DefaultContentType)
req.Header.Set("X-Session-Id", v1.token)
resp, err := v1.client.Do(req)
if err != nil {
response.Error = fmt.Errorf("error while executing request: %v", err)
return response
}
response.StatusCode = resp.StatusCode
response.HTTPResponse = resp
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
response.Error = fmt.Errorf("unable to read body: %v", err)
return response
}
response.Body = body
if resp.StatusCode != 200 {
response.Error = fmt.Errorf("[%d]: %s", resp.StatusCode, body)
return response
}
return response
}
// Endpoint supports "/api/v1" and "api/v1" like strings
// to generate the string type of a given endpoint based on
// a client

View file

@ -1,7 +1,6 @@
package api
import (
"fmt"
"time"
)
@ -58,7 +57,7 @@ type Photo struct {
//Place *Place `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Place" yaml:"-"`
//Keywords []Keyword `json:"-" yaml:"-"`
//Albums []Album `json:"-" yaml:"-"`
//Files []File `yaml:"-"`
Files []File `yaml:"-"`
//Labels []PhotoLabel `yaml:"-"`
CreatedAt time.Time `yaml:"CreatedAt,omitempty"`
UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"`
@ -80,6 +79,9 @@ func (v1 *V1Client) GetPhoto(uuid string) (Photo, error) {
}
// PUT /api/v1/photos/:uid
//
// Parameters:
// uuid: string PhotoUUID as returned by the API
func (v1 *V1Client) UpdatePhoto(object Photo) (Photo, error) {
err := v1.PUT(&object, "/api/v1/photos/%s", object.UUID).JSON(&object)
return object, err
@ -89,36 +91,27 @@ func (v1 *V1Client) UpdatePhoto(object Photo) (Photo, error) {
//
// Parameters:
// uuid: string PhotoUUID as returned by the API
func (v1 *V1Client) GetPhotoDownload(uuid string) (*File, error) {
if uuid == "" {
return nil, fmt.Errorf("missing uuid for GetPhotoDownload [GET /api/v1/photos/:uuid/dl]")
}
file := &File{}
return file, nil
func (v1 *V1Client) GetPhotoDownload(uuid string) ([]byte, error) {
resp := v1.GET("/api/v1/photos/%s/dl?t=%s", uuid, v1.downloadToken)
return resp.Body, resp.Error
}
// GET /api/v1/photos/:uuid/yaml
//
// Parameters:
// uuid: string PhotoUUID as returned by the API
func (v1 *V1Client) GetPhotoYaml(uuid string) (*Photo, error) {
if uuid == "" {
return nil, fmt.Errorf("missing uuid for GetPhotoYAML [GET /api/v1/photos/:uuid/yaml]")
}
photo := &Photo{}
return photo, nil
func (v1 *V1Client) GetPhotoYaml(uuid string) ([]byte, error) {
resp := v1.GET("/api/v1/photos/%s/yaml", uuid)
return resp.Body, resp.Error
}
// POST /api/v1/photos/:uuid/approve
//
// Parameters:
// uuid: string PhotoUUID as returned by the API
func (v1 *V1Client) ApprovePhoto(uuid string) (*Photo, error) {
if uuid == "" {
return nil, fmt.Errorf("missing uuid for ApprovePhoto [POST /api/v1/photos/:uuid/approve]")
}
photo := &Photo{}
return photo, nil
func (v1 *V1Client) ApprovePhoto(uuid string) error {
resp := v1.POST(nil, "/api/v1/photos/%s/approve", uuid)
return resp.Error
}
// POST /api/v1/photos/:uid/like
@ -126,10 +119,8 @@ func (v1 *V1Client) ApprovePhoto(uuid string) (*Photo, error) {
// Parameters:
// uid: string PhotoUID as returned by the API
func (v1 *V1Client) LikePhoto(uuid string) error {
if uuid == "" {
return fmt.Errorf("missing uuid for LikePhoto [POST /api/v1/photos/:uid/like]")
}
return nil
resp := v1.POST(nil, "/api/v1/photos/%s/like", uuid)
return resp.Error
}
// DELETE /api/v1/photos/:uuid/like
@ -137,10 +128,8 @@ func (v1 *V1Client) LikePhoto(uuid string) error {
// Parameters:
// uuid: string PhotoUUID as returned by the API
func (v1 *V1Client) DislikePhoto(uuid string) error {
if uuid == "" {
return fmt.Errorf("missing uuid for DislikePhoto [DELETE /api/v1/photos/:uuid/like]")
}
return nil
resp := v1.DELETE(nil, "/api/v1/photos/%s/approve", uuid)
return resp.Error
}
// POST /api/v1/photos/:uid/files/:file_uid/primary
@ -149,48 +138,6 @@ func (v1 *V1Client) DislikePhoto(uuid string) error {
// uid: string PhotoUID as returned by the API
// file_uid: string File UID as returned by the API
func (v1 *V1Client) PhotoPrimary(uuid, fileuuid string) error {
if uuid == "" {
return fmt.Errorf("missing uuid for PhotoPrimary [POST /api/v1/photos/:uid/files/:file_uid/primary]")
resp := v1.POST(nil, "/api/v1/photos/%s/files/%s/primary", uuid, fileuuid)
return resp.Error
}
if fileuuid == "" {
return fmt.Errorf("missing fileuuid for PhotoPrimary [POST /api/v1/photos/:uid/files/:file_uid/primary]")
}
return nil
}
// -----
// Dump from Chrome
//
//Request URL: http://localhost:8080/api/v1/photos/pqnzigq156lndozm
//Request Method: PUT
//Status Code: 200 OK
//Remote Address: 127.0.0.1:8080
//Referrer Policy: strict-origin-when-cross-origin
// [RESPONSE HEADERS]
//Content-Type: application/json; charset=utf-8
//Date: Thu, 04 Feb 2021 04:27:16 GMT
//Transfer-Encoding: chunked
// [REQUEST HEADERS]
//Accept: application/json, text/plain, */*
//Accept-Encoding: gzip, deflate, br
//Accept-Language: en-US,en;q=0.9
//Connection: keep-alive
//Content-Length: 41
//Content-Type: application/json;charset=UTF-8
//Host: localhost:8080
//Origin: http://localhost:8080
//Referer: http://localhost:8080/albums/aqnzih81icziiyae/february-2021
//Sec-Fetch-Dest: empty
//Sec-Fetch-Mode: cors
//Sec-Fetch-Site: same-origin
//User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36
//X-Client-Hash: 2607a5a5
//X-Client-Version: 210121-07e559df-Linux-x86_64
//X-Session-ID: d92837cb1c41e37b9993d25e282efb3b337b6ae609a687d9
// [REQUEST PAYLOAD]
//{Title: "Test Nova", TitleSrc: "manual"}
//Title: "Test Nova"
//TitleSrc: "manual"

View file

@ -124,11 +124,25 @@ func (c *Client) LoginV1() error {
}
return fmt.Errorf("login error [%d] %s", resp.StatusCode, body)
}
// --- JSON Auth Response on to Options ---
cfg := &Config{
Config: &Options{},
}
bytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("unable to parse auth body: %v", err)
}
err = json.Unmarshal(bytes, &cfg)
if err != nil {
return fmt.Errorf("unable to json unmarshal auth body: %v", err)
}
token := resp.Header.Get(APIAuthHeaderKey)
if token == "" {
return fmt.Errorf("missing auth token from successful login")
}
c.v1client = v1.New(c.connectionURL, token)
c.v1client = v1.New(c.connectionURL, token, cfg.Config.DownloadToken)
return nil
}

1
examples/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
ignore_*

View file

@ -1,30 +1,60 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"path"
photoprism "github.com/kris-nova/client-go"
"github.com/kris-nova/logger"
)
func main() {
// ---
// Log Level 4 (Most)
// Log Level 3
// Log Level 2
// Log Level 1
// Log Level 0 (Least)
//
logger.Level = 4
//
// ---
uuid := "pqnzigq351j2fqgn" // This is a known ID
client := photoprism.New("http://localhost:8080")
err := client.Auth(photoprism.NewClientAuthLogin("admin", "missy"))
if err != nil {
halt(4, "Error logging into API: %v", err)
}
//logger.Always("Login Success!")
// ---
// GetPhoto()
//
photo, err := client.V1().GetPhoto(uuid)
if err != nil {
halt(3, "Error fetching photo: %v", err)
}
bytes, err := json.Marshal(photo)
// ---
// UpdatePhoto()
photo.PhotoTitle = "A really great photo!"
photo, err = client.V1().UpdatePhoto(photo)
if err != nil {
halt(5, "Error: %v", err)
halt(2, "Error updating photo: %v", err)
}
fmt.Println(string(bytes))
// ---
// GetPhotoDownload()
file, err := client.V1().GetPhotoDownload(photo.UUID)
if err != nil {
halt(2, "Error getting photo download: %v", err)
}
for _, f := range photo.Files {
fileName := fmt.Sprintf("ignore_%s", path.Base(f.FileName))
logger.Always(fileName)
ioutil.WriteFile(fileName, file, 0666)
}
}

View file

@ -124,7 +124,7 @@ func GetAccountFolders(router *gin.RouterGroup) {
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(fs.FileInfos)
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return

View file

@ -38,22 +38,32 @@ func BatchPhotosArchive(router *gin.RouterGroup) {
return
}
log.Infof("archive: adding %s", f.String())
log.Infof("photos: archiving %s", f.String())
// Soft delete by setting deleted_at to current date.
err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error
if service.Config().BackupYaml() {
photos, err := query.PhotoSelection(f)
if err != nil {
AbortSaveFailed(c)
AbortEntityNotFound(c)
return
}
// Remove archived photos from albums.
logError("archive", entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error)
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
for _, p := range photos {
if err := p.Archive(); err != nil {
log.Errorf("archive: %s", err)
} else {
SavePhotoAsYaml(p)
}
}
} else if err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error; err != nil {
log.Errorf("archive: %s", err)
AbortSaveFailed(c)
return
} else if err := entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error; err != nil {
log.Errorf("archive: %s", err)
}
logError("photos", entity.UpdatePhotoCounts())
UpdateClientConfig()
@ -63,6 +73,62 @@ func BatchPhotosArchive(router *gin.RouterGroup) {
})
}
// POST /api/v1/batch/photos/restore
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: restoring %s", f.String())
if service.Config().BackupYaml() {
photos, err := query.PhotoSelection(f)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if err := p.Restore(); err != nil {
log.Errorf("restore: %s", err)
} else {
SavePhotoAsYaml(p)
}
}
} else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos).
UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil {
log.Errorf("restore: %s", err)
AbortSaveFailed(c)
return
}
logError("photos", entity.UpdatePhotoCounts())
UpdateClientConfig()
event.EntitiesRestored("photos", f.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored))
})
}
// POST /api/v1/batch/photos/approve
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
@ -98,7 +164,7 @@ func BatchPhotosApprove(router *gin.RouterGroup) {
for _, p := range photos {
if err := p.Approve(); err != nil {
log.Errorf("photo: %s (approve)", err.Error())
log.Errorf("approve: %s", err)
} else {
approved = append(approved, p)
SavePhotoAsYaml(p)
@ -113,50 +179,6 @@ func BatchPhotosApprove(router *gin.RouterGroup) {
})
}
// POST /api/v1/batch/photos/restore
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
return
}
var f form.Selection
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("archive: restoring %s", f.String())
err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos).
UpdateColumn("deleted_at", gorm.Expr("NULL")).Error
if err != nil {
AbortSaveFailed(c)
return
}
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
}
UpdateClientConfig()
event.EntitiesRestored("photos", f.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored))
})
}
// POST /api/v1/batch/albums/delete
func BatchAlbumsDelete(router *gin.RouterGroup) {
router.POST("/batch/albums/delete", func(c *gin.Context) {
@ -214,22 +236,23 @@ func BatchPhotosPrivate(router *gin.RouterGroup) {
return
}
log.Infof("photos: mark %s as private", f.String())
log.Infof("photos: updating private flag for %s", f.String())
err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private",
gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error
if err != nil {
if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private",
gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil {
log.Errorf("private: %s", err)
AbortSaveFailed(c)
return
}
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
logError("photos", entity.UpdatePhotoCounts())
if photos, err := query.PhotoSelection(f); err == nil {
for _, p := range photos {
SavePhotoAsYaml(p)
}
if entities, err := query.PhotoSelection(f); err == nil {
event.EntitiesUpdated("photos", entities)
event.EntitiesUpdated("photos", photos)
}
UpdateClientConfig()
@ -313,7 +336,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
return
}
log.Infof("archive: permanently deleting %s", f.String())
log.Infof("photos: deleting %s", f.String())
photos, err := query.PhotoSelection(f)
@ -327,7 +350,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
// Delete photos.
for _, p := range photos {
if err := photoprism.Delete(p); err != nil {
log.Errorf("photo: %s (delete)", err.Error())
log.Errorf("delete: %s", err)
} else {
deleted = append(deleted, p)
}
@ -335,9 +358,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
// Update counts and views if needed.
if len(deleted) > 0 {
if err := entity.UpdatePhotoCounts(); err != nil {
log.Errorf("photos: %s", err)
}
logError("photos", entity.UpdatePhotoCounts())
UpdateClientConfig()

View file

@ -50,7 +50,7 @@ func AlbumCover(router *gin.RouterGroup) {
cacheKey := CacheKey(albumCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
@ -108,8 +108,8 @@ func AlbumCover(router *gin.RouterGroup) {
}
if err != nil {
log.Errorf("album: %s", err)
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
log.Errorf("%s: %s", albumCover, err)
c.Data(http.StatusOK, "image/svg+xml", albumIconSvg)
return
} else if thumbnail == "" {
log.Errorf("%s: %s has empty thumb name - bug?", albumCover, filepath.Base(fileName))
@ -160,7 +160,7 @@ func LabelCover(router *gin.RouterGroup) {
cacheKey := CacheKey(labelCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View file

@ -56,7 +56,7 @@ func GetFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string) {
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(FoldersResponse)
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return

View file

@ -0,0 +1,140 @@
package api
import (
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
const (
folderCover = "folder-cover"
)
// GET /api/v1/folders/t/:hash/:token/:type
//
// Parameters:
// uid: string folder uid
// token: string url security token, see config
// type: string thumb type, see thumb.Types
func GetFolderCover(router *gin.RouterGroup) {
router.GET("/folders/t/:uid/:token/:type", func(c *gin.Context) {
if InvalidPreviewToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", folderIconSvg)
return
}
start := time.Now()
conf := service.Config()
uid := c.Param("uid")
typeName := c.Param("type")
download := c.Query("download") != ""
thumbType, ok := thumb.Types[typeName]
if !ok {
log.Errorf("folder: invalid thumb type %s", txt.Quote(typeName))
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}
if thumbType.ExceedsSize() && !conf.ThumbUncached() {
typeName, thumbType = thumb.Find(conf.ThumbSize())
if typeName == "" {
log.Errorf("folder: invalid thumb size %d", conf.ThumbSize())
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}
}
cache := service.CoverCache()
cacheKey := CacheKey(folderCover, uid, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
if !fs.FileExists(cached.FileName) {
log.Errorf("%s: %s not found", folderCover, uid)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}
AddCoverCacheHeader(c)
if download {
c.FileAttachment(cached.FileName, cached.ShareName)
} else {
c.File(cached.FileName)
}
return
}
f, err := query.FolderCoverByUID(uid)
if err != nil {
log.Debugf("%s: no photos yet, using generic image for %s", folderCover, uid)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("%s: could not find original for %s", folderCover, fileName)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
log.Warnf("%s: %s is missing", folderCover, txt.Quote(f.FileName))
logError(folderCover, f.Update("FileMissing", true))
return
}
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
if thumbType.ExceedsSizeUncached() && !download {
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", folderCover, thumbType.Width, thumbType.Height)
AddCoverCacheHeader(c)
c.File(fileName)
return
}
var thumbnail string
if conf.ThumbUncached() || thumbType.OnDemand() {
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
} else {
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbPath(), thumbType.Width, thumbType.Height, thumbType.Options...)
}
if err != nil {
log.Errorf("%s: %s", folderCover, err)
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
} else if thumbnail == "" {
log.Errorf("%s: %s has empty thumb name - bug?", folderCover, filepath.Base(fileName))
c.Data(http.StatusOK, "image/svg+xml", folderIconSvg)
return
}
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
AddCoverCacheHeader(c)
if download {
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
} else {
c.File(thumbnail)
}
})
}

332
internal/api/photo.go Normal file
View file

@ -0,0 +1,332 @@
package api
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// SavePhotoAsYaml saves photo data as YAML file.
func SavePhotoAsYaml(p entity.Photo) {
c := service.Config()
// Write YAML sidecar file (optional).
if !c.BackupYaml() {
return
}
fileName := p.YamlFileName(c.OriginalsPath(), c.SidecarPath())
if err := p.SaveAsYaml(fileName); err != nil {
log.Errorf("photo: %s (update yaml)", err)
} else {
log.Debugf("photo: updated yaml file %s", txt.Quote(filepath.Base(fileName)))
}
}
// GET /api/v1/photos/:uid
//
// Parameters:
// uid: string PhotoUID as returned by the API
func GetPhoto(router *gin.RouterGroup) {
router.GET("/photos/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
AbortEntityNotFound(c)
return
}
c.IndentedJSON(http.StatusOK, p)
})
}
// PUT /api/v1/photos/:uid
func UpdatePhoto(router *gin.RouterGroup) {
router.PUT("/photos/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
m, err := query.PhotoByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
// TODO: Proof-of-concept for form handling - might need refactoring
// 1) Init form with model values
f, err := form.NewPhoto(m)
if err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
// 2) Update form with values from request
if err := c.BindJSON(&f); err != nil {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
return
} else if f.PhotoPrivate {
FlushCoverCache()
}
// 3) Save model with values from form
if err := entity.SavePhotoForm(m, f); err != nil {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
return
}
PublishPhotoEvent(EntityUpdated, uid, c)
event.SuccessMsg(i18n.MsgChangesSaved)
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
SavePhotoAsYaml(p)
UpdateClientConfig()
c.JSON(http.StatusOK, p)
})
}
// GET /api/v1/photos/:uid/dl
//
// Parameters:
// uid: string PhotoUID as returned by the API
func GetPhotoDownload(router *gin.RouterGroup) {
router.GET("/photos/:uid/dl", func(c *gin.Context) {
if InvalidDownloadToken(c) {
c.Data(http.StatusForbidden, "image/svg+xml", brokenIconSvg)
return
}
f, err := query.FileByPhotoUID(c.Param("uid"))
if err != nil {
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
return
}
fileName := photoprism.FileName(f.FileRoot, f.FileName)
if !fs.FileExists(fileName) {
log.Errorf("photo: file %s is missing", txt.Quote(f.FileName))
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
// Set missing flag so that the file doesn't show up in search results anymore.
logError("photo", f.Update("FileMissing", true))
return
}
c.FileAttachment(fileName, f.DownloadName(DownloadName(c), 0))
})
}
// GET /api/v1/photos/:uid/yaml
//
// Parameters:
// uid: string PhotoUID as returned by the API
func GetPhotoYaml(router *gin.RouterGroup) {
router.GET("/photos/:uid/yaml", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionExport)
if s.Invalid() {
AbortUnauthorized(c)
return
}
p, err := query.PhotoPreloadByUID(c.Param("uid"))
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
data, err := p.Yaml()
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
if c.Query("download") != "" {
AddDownloadHeader(c, c.Param("uid")+fs.YamlExt)
}
c.Data(http.StatusOK, "text/x-yaml; charset=utf-8", data)
})
}
// POST /api/v1/photos/:uid/approve
//
// Parameters:
// uid: string PhotoUID as returned by the API
func ApprovePhoto(router *gin.RouterGroup) {
router.POST("/photos/:uid/approve", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
m, err := query.PhotoByUID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if err := m.Approve(); err != nil {
log.Errorf("photo: %s", err.Error())
AbortSaveFailed(c)
return
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
// POST /api/v1/photos/:uid/like
//
// Parameters:
// uid: string PhotoUID as returned by the API
func LikePhoto(router *gin.RouterGroup) {
router.POST("/photos/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionLike)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
m, err := query.PhotoByUID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if err := m.SetFavorite(true); err != nil {
log.Errorf("photo: %s", err.Error())
AbortSaveFailed(c)
return
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
// DELETE /api/v1/photos/:uid/like
//
// Parameters:
// uid: string PhotoUID as returned by the API
func DislikePhoto(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionLike)
if s.Invalid() {
AbortUnauthorized(c)
return
}
id := c.Param("uid")
m, err := query.PhotoByUID(id)
if err != nil {
AbortEntityNotFound(c)
return
}
if err := m.SetFavorite(false); err != nil {
log.Errorf("photo: %s", err.Error())
AbortSaveFailed(c)
return
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
}
// POST /api/v1/photos/:uid/files/:file_uid/primary
//
// Parameters:
// uid: string PhotoUID as returned by the API
// file_uid: string File UID as returned by the API
func PhotoPrimary(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/primary", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
uid := c.Param("uid")
fileUID := c.Param("file_uid")
err := query.SetPhotoPrimary(uid, fileUID)
if err != nil {
AbortEntityNotFound(c)
return
}
PublishPhotoEvent(EntityUpdated, uid, c)
event.SuccessMsg(i18n.MsgChangesSaved)
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
AbortEntityNotFound(c)
return
}
c.JSON(http.StatusOK, p)
})
}

View file

@ -55,7 +55,7 @@ func GetThumb(router *gin.RouterGroup) {
cacheKey := CacheKey("thumbs", fileHash, typeName)
if cacheData, ok := cache.Get(cacheKey); ok {
log.Debugf("cache hit for %s [%s]", cacheKey, time.Since(start))
log.Debugf("api: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View file

@ -69,7 +69,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
stackPrimary, err := stackPhoto.PrimaryFile()
if err != nil {
log.Errorf("photo: can't find primary file for existing photo (unstack %s)", txt.Quote(baseName))
log.Errorf("photo: can't find primary file for %s (unstack)", txt.Quote(baseName))
AbortUnexpected(c)
return
}
@ -81,11 +81,11 @@ func PhotoUnstack(router *gin.RouterGroup) {
AbortEntityNotFound(c)
return
} else if related.Len() == 0 {
log.Errorf("photo: no files found (unstack %s)", txt.Quote(baseName))
log.Errorf("photo: no files found for %s (unstack)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
} else if related.Main == nil {
log.Errorf("photo: no main file found (unstack %s)", txt.Quote(baseName))
log.Errorf("photo: no main file found for %s (unstack)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
}

View file

@ -9,6 +9,16 @@
"tokens": null,
"expiration": 1613514526199122543
},
"0957017ab9576154468a8a0df5fd74798be6965b3f5eef90": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613523028656083744
},
"0bc03c7776136e860865c7cd2c30d6678e896805d0e0bba8": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613527737294727784
},
"0bf52bb31c11c5ca6c56646496b184eb39f33b004a46b203": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -34,6 +44,11 @@
"tokens": null,
"expiration": 1613502823940492608
},
"163e08d6fc94c7c7c07f1af8638fd5db4aa929b0462d5548": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613522078076464014
},
"21a2853a53625574889c1b0d653170efaf3ba51489c3da85": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -44,6 +59,11 @@
"tokens": null,
"expiration": 1613513903738209742
},
"25847336ab7fa37815b6ba9fba455766bb797f81cdb70dc8": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613527778569130528
},
"27ecafdd4819a88cc523aa95a0698d353ceb85ac6371cc4e": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -69,11 +89,26 @@
"tokens": null,
"expiration": 1613500755747387098
},
"34dd871e663973ef4ae9f00ee7a2a10e264e3d12f60d36db": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613528491291754429
},
"370c05ea9a50f1e6befe3eba0e59e1a965daaa31da6f028a": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613520870803124902
},
"3bd19dc60e5a515d2624f0d712db8471f433416935c2a045": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613512395822771302
},
"3fac95f9ca118f4e3dfe3fc87a174ef845d608af2a301f31": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526857034102326
},
"41a99f15500d1eca9818bc3e0cae7cca319cd6eb2b38bb8d": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -94,6 +129,11 @@
"tokens": null,
"expiration": 1613519956718633151
},
"447da9991224a43c09deb314d41a9f7ff718e1ce75d824cc": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613528419822486762
},
"469a249abd7cf70eae9e39abd41c7a311b40d8c1e31f0199": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -104,11 +144,21 @@
"tokens": null,
"expiration": 1613512362197250367
},
"55b19c28a5189350d53a979adb17a8335b683d5474a064d1": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526231902816117
},
"589279988dbd4ad774ef7a59392f6bc44f416ff8d34e653e": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613502738172098175
},
"5973a6f141757700945f48564869ef350bfd0e6c7319aa35": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613527873142005750
},
"5a159bc84d1cac8cd025db03de8e1dbf414d6bdefb967885": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -134,11 +184,21 @@
"tokens": null,
"expiration": 1613519264711770335
},
"7ad01775ce800f5a065cb187bbdcfe8ff72e81774db5105f": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526444602833378
},
"7d381eaaea551483d8d50aa39007d7fc9fdddd3b6a06359d": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613513949594487106
},
"8173f5cece02b1a41e9bc937bbbbc80960b838d93cef7f2d": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526315102874135
},
"82b0a1bdd1ac266855b338513eff6631c000f7187f1e099d": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -194,6 +254,16 @@
"tokens": null,
"expiration": 1613516776661303208
},
"92f1ec16db743e3a112d6c889834c93e21dd015cba8d8917": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526336803063121
},
"988de1401f8dfe14a3766ea883ac54fa14dfd3149cefff6d": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613520871647468457
},
"9d382dfa406c01501fc5cc88b025c22d09248834da7e2b8b": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -204,6 +274,11 @@
"tokens": null,
"expiration": 1613502624809588007
},
"9e8c7c0f010bbdf87c0274442c1b35cf98cd4aa521a8a94c": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613522128528856443
},
"a03f2d8d33fbb447c1c0573735d93632dc2b81e44923f4c1": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -219,6 +294,11 @@
"tokens": null,
"expiration": 1613502517447074204
},
"a83cc511e399c1dab37468d0c64302bf4b68f7e3df5a1e0b": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613527553683560802
},
"aa9951b3b6533deadac0376a66a51e63ff6b9c306c82f4c8": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -229,6 +309,16 @@
"tokens": null,
"expiration": 1613502660685792657
},
"c053cc843cd5a58b6e7e4dfb0b896180734df58c6cf6ca88": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613528519484379600
},
"c3440286c8cf0b619ec0a5883a836115de84752c519f625c": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613525841223849602
},
"c558cccdd25917056e8b7b72a2a3e5f40215d707a6fac1aa": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -274,6 +364,11 @@
"tokens": null,
"expiration": 1613511459136720858
},
"e90c5207fefb6712f0a261708de68ca5639dfc60a4dfe6f6": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526269423740413
},
"ece6e3ed1d36a43bd26843ee5efa547c662cc23423959316": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
@ -289,9 +384,24 @@
"tokens": null,
"expiration": 1613512474632135880
},
"f27ef3af1a5139eb23ab2d2cd5adb54dd63e147c74f12208": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613522175097030333
},
"f5dd3851137b73d1e039ffa521d0c02e60504aa1f972fe13": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613515512912618531
},
"f733dc1a7213485c2d8c78a0839d2d2157d04c108ffd1789": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613525903142014501
},
"ff3f257a8a8d59834194117ec388228a6c2c4c799ffee9c6": {
"user": "uqnzie01i1nypnt9",
"tokens": null,
"expiration": 1613526460305916347
}
}

View file

@ -1,9 +1,9 @@
TakenAt: 2021-02-04T03:17:07Z
UID: pqnzigq351j2fqgn
Type: image
Title: Tambourine Bitches!
Title: A really great photo!
TitleSrc: manual
Description: 'Sample App Description: 2021-02-09 15:59:16.724684964 -0800 PST m=+5.911590611'
Description: 'Sample App Description: 2021-02-09 16:14:30.809303693 -0800 PST m=+5.845330665'
DescriptionSrc: manual
OriginalName: IMG_3044
Year: -1
@ -14,5 +14,5 @@ Details:
Keywords: green, mean, tambourine
KeywordsSrc: manual
CreatedAt: 2021-02-04T03:17:14.613092062Z
UpdatedAt: 2021-02-09T23:59:16.737466872Z
EditedAt: 2021-02-09T23:59:17Z
UpdatedAt: 2021-02-10T02:21:59.491263544Z
EditedAt: 2021-02-10T02:21:59Z

View file

@ -57,3 +57,21 @@ func TestSadUpdatePhoto(t *testing.T) {
t.Errorf("expecting failure updaitng bad photo id: %s", photo.UUID)
t.FailNow()
}
func TestHappyGetPhotoDownload(t *testing.T) {
_, err := Client.V1().GetPhotoDownload(WellKnownPhotoID)
if err != nil {
t.Errorf("expected success getting well known photo: %v", err)
t.FailNow()
}
}
func TestSadGetPhotoDownload(t *testing.T) {
file, err := Client.V1().GetPhotoDownload("1234567890")
if err != nil {
t.Logf("success returning error for unknown photo: %v", err)
return
}
t.Errorf("expected error for unknown file: %s", file.FileName)
t.FailNow()
}

79
types.go Normal file
View file

@ -0,0 +1,79 @@
package photoprism
type Config struct {
Config *Options `json:"config"`
}
type Options struct {
Name string `json:"name"`
Version string `json:"version"`
Copyright string `json:"copyright"`
//Debug bool `yaml:"Debug" json:"Debug" flag:"debug"`
//Test bool `yaml:"-" json:"Test,omitempty" flag:"test"`
//Demo bool `yaml:"Demo" json:"-" flag:"demo"`
//Sponsor bool `yaml:"-" json:"-" flag:"sponsor"`
//Public bool `yaml:"Public" json:"-" flag:"public"`
//ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"`
//Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"`
//ConfigPath string `yaml:"ConfigPath" json:"-" flag:"config-path"`
//ConfigFile string `json:"-"`
//AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
//OriginalsPath string `yaml:"OriginalsPath" json:"-" flag:"originals-path"`
//OriginalsLimit int64 `yaml:"OriginalsLimit" json:"OriginalsLimit" flag:"originals-limit"`
//ImportPath string `yaml:"ImportPath" json:"-" flag:"import-path"`
//StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"`
//SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
//TempPath string `yaml:"TempPath" json:"-" flag:"temp-path"`
//BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"`
//AssetsPath string `yaml:"AssetsPath" json:"-" flag:"assets-path"`
//CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"`
//Workers int `yaml:"Workers" json:"Workers" flag:"workers"`
//WakeupInterval int `yaml:"WakeupInterval" json:"WakeupInterval" flag:"wakeup-interval"`
//AutoIndex int `yaml:"AutoIndex" json:"AutoIndex" flag:"auto-index"`
//AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"`
//DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"`
//DisableWebDAV bool `yaml:"DisableWebDAV" json:"DisableWebDAV" flag:"disable-webdav"`
//DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"`
//DisablePlaces bool `yaml:"DisablePlaces" json:"DisablePlaces" flag:"disable-places"`
//DisableExifTool bool `yaml:"DisableExifTool" json:"DisableExifTool" flag:"disable-exiftool"`
//DisableTensorFlow bool `yaml:"DisableTensorFlow" json:"DisableTensorFlow" flag:"disable-tensorflow"`
//DetectNSFW bool `yaml:"DetectNSFW" json:"DetectNSFW" flag:"detect-nsfw"`
//UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"`
//LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"`
//LogFilename string `yaml:"LogFilename" json:"-" flag:"log-filename"`
//PIDFilename string `yaml:"PIDFilename" json:"-" flag:"pid-filename"`
//SiteUrl string `yaml:"SiteUrl" json:"SiteUrl" flag:"site-url"`
//SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"`
//SiteTitle string `yaml:"SiteTitle" json:"SiteTitle" flag:"site-title"`
//SiteCaption string `yaml:"SiteCaption" json:"SiteCaption" flag:"site-caption"`
//SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"`
//SiteAuthor string `yaml:"SiteAuthor" json:"SiteAuthor" flag:"site-author"`
//DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"`
//DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"`
//DatabaseServer string `yaml:"DatabaseServer" json:"-" flag:"database-server"`
//DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"`
//DatabaseUser string `yaml:"DatabaseUser" json:"-" flag:"database-user"`
//DatabasePassword string `yaml:"DatabasePassword" json:"-" flag:"database-password"`
//DatabaseConns int `yaml:"DatabaseConns" json:"-" flag:"database-conns"`
//DatabaseConnsIdle int `yaml:"DatabaseConnsIdle" json:"-" flag:"database-conns-idle"`
//HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"`
//HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"`
//HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
//HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
//SipsBin string `yaml:"SipsBin" json:"-" flag:"sips-bin"`
//RawtherapeeBin string `yaml:"RawtherapeeBin" json:"-" flag:"rawtherapee-bin"`
//DarktableBin string `yaml:"DarktableBin" json:"-" flag:"darktable-bin"`
//DarktablePresets bool `yaml:"DarktablePresets" json:"DarktablePresets" flag:"darktable-presets"`
//HeifConvertBin string `yaml:"HeifConvertBin" json:"-" flag:"heifconvert-bin"`
//FFmpegBin string `yaml:"FFmpegBin" json:"-" flag:"ffmpeg-bin"`
//ExifToolBin string `yaml:"ExifToolBin" json:"-" flag:"exiftool-bin"`
//DetachServer bool `yaml:"DetachServer" json:"-" flag:"detach-server"`
DownloadToken string `yaml:"DownloadToken" json:"downloadToken" flag:"download-token"`
//PreviewToken string `yaml:"PreviewToken" json:"-" flag:"preview-token"`
//ThumbFilter string `yaml:"ThumbFilter" json:"ThumbFilter" flag:"thumb-filter"`
//ThumbUncached bool `yaml:"ThumbUncached" json:"ThumbUncached" flag:"thumb-uncached"`
//ThumbSize int `yaml:"ThumbSize" json:"ThumbSize" flag:"thumb-size"`
//ThumbSizeUncached int `yaml:"ThumbSizeUncached" json:"ThumbSizeUncached" flag:"thumb-size-uncached"`
//JpegSize int `yaml:"JpegSize" json:"JpegSize" flag:"jpeg-size"`
//JpegQuality int `yaml:"JpegQuality" json:"JpegQuality" flag:"jpeg-quality"`
}