diff --git a/api/v1/client.go b/api/v1/client.go index 0cf5c9d..a2dd454 100644 --- a/api/v1/client.go +++ b/api/v1/client.go @@ -16,18 +16,20 @@ const ( ) type V1Client struct { - token string - apihost *url.URL - client http.Client + downloadToken string + token string + apihost *url.URL + client http.Client } // 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, + 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 diff --git a/api/v1/photo.go b/api/v1/photo.go index 3216b77..ab4eb6f 100644 --- a/api/v1/photo.go +++ b/api/v1/photo.go @@ -1,7 +1,6 @@ package api import ( - "fmt" "time" ) @@ -51,14 +50,14 @@ type Photo struct { CameraSerial string `gorm:"type:VARBINARY(255);" json:"CameraSerial" yaml:"CameraSerial,omitempty"` CameraSrc string `gorm:"type:VARBINARY(8);" json:"CameraSrc" yaml:"-"` LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"` - //Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"` + //Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"` //Camera *Camera `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Camera" yaml:"-"` //Lens *Lens `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Lens" yaml:"-"` //Cell *Cell `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Cell" yaml:"-"` //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]") - } - if fileuuid == "" { - return fmt.Errorf("missing fileuuid for PhotoPrimary [POST /api/v1/photos/:uid/files/:file_uid/primary]") - } - return nil + resp := v1.POST(nil, "/api/v1/photos/%s/files/%s/primary", uuid, fileuuid) + return resp.Error } - -// ----- -// 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" diff --git a/client.go b/client.go index 08dcf83..6700672 100644 --- a/client.go +++ b/client.go @@ -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 } diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..633a832 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +ignore_* \ No newline at end of file diff --git a/examples/photo.go b/examples/photo.go index 7914bdc..48c08d8 100644 --- a/examples/photo.go +++ b/examples/photo.go @@ -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) + } + } diff --git a/internal/api/account.go b/internal/api/account.go index eb43257..ee70de8 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -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 diff --git a/internal/api/batch.go b/internal/api/batch.go index 207c34b..2ed3559 100644 --- a/internal/api/batch.go +++ b/internal/api/batch.go @@ -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 { + if err != nil { + AbortEntityNotFound(c) + return + } + + 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) } - // 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) - } + 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 entities, err := query.PhotoSelection(f); err == nil { - event.EntitiesUpdated("photos", entities) + if photos, err := query.PhotoSelection(f); err == nil { + for _, p := range photos { + SavePhotoAsYaml(p) + } + + 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() diff --git a/internal/api/covers.go b/internal/api/covers.go index a084afb..4592952 100644 --- a/internal/api/covers.go +++ b/internal/api/covers.go @@ -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) diff --git a/internal/api/folder.go b/internal/api/folder.go index c78d329..6e41775 100644 --- a/internal/api/folder.go +++ b/internal/api/folder.go @@ -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 diff --git a/internal/api/folder_cover.go b/internal/api/folder_cover.go new file mode 100644 index 0000000..bc1ce45 --- /dev/null +++ b/internal/api/folder_cover.go @@ -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) + } + }) +} diff --git a/internal/api/photo.go b/internal/api/photo.go new file mode 100644 index 0000000..7c514f1 --- /dev/null +++ b/internal/api/photo.go @@ -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) + }) +} diff --git a/internal/api/photo_thumb.go b/internal/api/photo_thumb.go index 6b7b187..b34c33d 100644 --- a/internal/api/photo_thumb.go +++ b/internal/api/photo_thumb.go @@ -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) diff --git a/internal/api/photo_unstack.go b/internal/api/photo_unstack.go index cdc7fc9..df304cf 100644 --- a/internal/api/photo_unstack.go +++ b/internal/api/photo_unstack.go @@ -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 } diff --git a/sample-app/photoprism/storage/cache/sessions.json b/sample-app/photoprism/storage/cache/sessions.json index 745e2ea..b4b24c4 100644 --- a/sample-app/photoprism/storage/cache/sessions.json +++ b/sample-app/photoprism/storage/cache/sessions.json @@ -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 } } \ No newline at end of file diff --git a/sample-app/photoprism/storage/index.db b/sample-app/photoprism/storage/index.db index 94613ee..ad31375 100644 Binary files a/sample-app/photoprism/storage/index.db and b/sample-app/photoprism/storage/index.db differ diff --git a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml index 2801aa5..df53f69 100755 --- a/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml +++ b/sample-app/photoprism/storage/sidecar/2021/02/20210204_031706_5B740007.yml @@ -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 diff --git a/test/photo_test.go b/test/photo_test.go index a8337aa..f1a65c5 100644 --- a/test/photo_test.go +++ b/test/photo_test.go @@ -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() +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..1083c1b --- /dev/null +++ b/types.go @@ -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"` +}