diff --git a/README.md b/README.md index f931209..ca86597 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ -# photoprism-client-go -Go client for the Photoprism Application +# Photoprism Client Go + +Go client for the Photoprism Application. + +Bugs: Kris NĂ³va diff --git a/api/v1/client.go b/api/v1/client.go index e69de29..b875c9e 100644 --- a/api/v1/client.go +++ b/api/v1/client.go @@ -0,0 +1,5 @@ +package api + +type V1Client struct { + // +} diff --git a/api/v1/file.go b/api/v1/file.go index e69de29..4bf040a 100644 --- a/api/v1/file.go +++ b/api/v1/file.go @@ -0,0 +1,60 @@ +package api + +import "time" + +type Files []File + +// File represents an image or sidecar file that belongs to a photo. +type File struct { + ID uint `gorm:"primary_key" json:"-" yaml:"-"` + Photo *Photo `json:"-" yaml:"-"` + PhotoID uint `gorm:"index;" json:"-" yaml:"-"` + PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"` + InstanceID string `gorm:"type:VARBINARY(42);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"` + FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"` + FileName string `gorm:"type:VARBINARY(755);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"` + FileRoot string `gorm:"type:VARBINARY(16);default:'/';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"` + OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"` + FileHash string `gorm:"type:VARBINARY(128);index" json:"Hash" yaml:"Hash,omitempty"` + FileSize int64 `json:"Size" yaml:"Size,omitempty"` + FileCodec string `gorm:"type:VARBINARY(32)" json:"Codec" yaml:"Codec,omitempty"` + FileType string `gorm:"type:VARBINARY(32)" json:"Type" yaml:"Type,omitempty"` + FileMime string `gorm:"type:VARBINARY(64)" json:"Mime" yaml:"Mime,omitempty"` + FilePrimary bool `json:"Primary" yaml:"Primary,omitempty"` + FileSidecar bool `json:"Sidecar" yaml:"Sidecar,omitempty"` + FileMissing bool `json:"Missing" yaml:"Missing,omitempty"` + FilePortrait bool `json:"Portrait" yaml:"Portrait,omitempty"` + FileVideo bool `json:"Video" yaml:"Video,omitempty"` + FileDuration time.Duration `json:"Duration" yaml:"Duration,omitempty"` + FileWidth int `json:"Width" yaml:"Width,omitempty"` + FileHeight int `json:"Height" yaml:"Height,omitempty"` + FileOrientation int `json:"Orientation" yaml:"Orientation,omitempty"` + FileProjection string `gorm:"type:VARBINARY(16);" json:"Projection,omitempty" yaml:"Projection,omitempty"` + FileAspectRatio float32 `gorm:"type:FLOAT;" json:"AspectRatio" yaml:"AspectRatio,omitempty"` + FileMainColor string `gorm:"type:VARBINARY(16);index;" json:"MainColor" yaml:"MainColor,omitempty"` + FileColors string `gorm:"type:VARBINARY(9);" json:"Colors" yaml:"Colors,omitempty"` + FileLuminance string `gorm:"type:VARBINARY(9);" json:"Luminance" yaml:"Luminance,omitempty"` + FileDiff uint32 `json:"Diff" yaml:"Diff,omitempty"` + FileChroma uint8 `json:"Chroma" yaml:"Chroma,omitempty"` + FileError string `gorm:"type:VARBINARY(512)" json:"Error" yaml:"Error,omitempty"` + ModTime int64 `json:"ModTime" yaml:"-"` + CreatedAt time.Time `json:"CreatedAt" yaml:"-"` + CreatedIn int64 `json:"CreatedIn" yaml:"-"` + UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"` + UpdatedIn int64 `json:"UpdatedIn" yaml:"-"` + DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"` + //Share []FileShare `json:"-" yaml:"-"` + //Sync []FileSync `json:"-" yaml:"-"` +} + +type FileInfos struct { + FileWidth int + FileHeight int + FileOrientation int + FileAspectRatio float32 + FileMainColor string + FileColors string + FileLuminance string + FileDiff uint32 + FileChroma uint8 +} diff --git a/api/v1/meta.go b/api/v1/meta.go index e69de29..9b20bed 100644 --- a/api/v1/meta.go +++ b/api/v1/meta.go @@ -0,0 +1,21 @@ +package api + +import "net/http" + +type Meta struct { + requested bool + response *http.Response +} + +// TODO We need an http.Client and an object interface{} +func (m *Meta) Request() error { + m.requested = true + return nil +} + +func (m *Meta) Response() *http.Response { + if !m.requested { + return nil + } + return m.response +} diff --git a/api/v1/photo.go b/api/v1/photo.go index f290418..fe9f426 100644 --- a/api/v1/photo.go +++ b/api/v1/photo.go @@ -1,274 +1,153 @@ package api import ( - "net/http" - - "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/pkg/fs" - "github.com/photoprism/photoprism/pkg/txt" + "fmt" + "time" ) -// GET /api/v1/photos/:uid +// Photo represents a photo, all its properties, and link to all its images and sidecar files. +type Photo struct { + Meta + ID uint `gorm:"primary_key" yaml:"-"` + UUID string `gorm:"type:VARBINARY(42);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"` + TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"` + TakenAtLocal time.Time `gorm:"type:datetime;" yaml:"-"` + TakenSrc string `gorm:"type:VARBINARY(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"` + PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"` + PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"` + TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"` + PhotoTitle string `gorm:"type:VARCHAR(255);" json:"Title" yaml:"Title"` + TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"` + PhotoDescription string `gorm:"type:TEXT;" json:"Description" yaml:"Description,omitempty"` + DescriptionSrc string `gorm:"type:VARBINARY(8);" json:"DescriptionSrc" yaml:"DescriptionSrc,omitempty"` + PhotoPath string `gorm:"type:VARBINARY(500);index:idx_photos_path_name;" json:"Path" yaml:"-"` + PhotoName string `gorm:"type:VARBINARY(255);index:idx_photos_path_name;" json:"Name" yaml:"-"` + OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"` + PhotoStack int8 `json:"Stack" yaml:"Stack,omitempty"` + PhotoFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"` + PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"` + PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"` + PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"` + TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"-"` + PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"` + PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"` + CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"` + CellAccuracy int `json:"CellAccuracy" yaml:"CellAccuracy,omitempty"` + PhotoAltitude int `json:"Altitude" yaml:"Altitude,omitempty"` + PhotoLat float32 `gorm:"type:FLOAT;index;" json:"Lat" yaml:"Lat,omitempty"` + PhotoLng float32 `gorm:"type:FLOAT;index;" json:"Lng" yaml:"Lng,omitempty"` + PhotoCountry string `gorm:"type:VARBINARY(2);index:idx_photos_country_year_month;default:'zz'" json:"Country" yaml:"-"` + PhotoYear int `gorm:"index:idx_photos_country_year_month;" json:"Year" yaml:"Year"` + PhotoMonth int `gorm:"index:idx_photos_country_year_month;" json:"Month" yaml:"Month"` + PhotoDay int `json:"Day" yaml:"Day"` + PhotoIso int `json:"Iso" yaml:"ISO,omitempty"` + PhotoExposure string `gorm:"type:VARBINARY(64);" json:"Exposure" yaml:"Exposure,omitempty"` + PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"` + PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"` + PhotoQuality int `gorm:"type:SMALLINT" json:"Quality" yaml:"-"` + PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"` + PhotoColor uint8 `json:"Color" yaml:"-"` + CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"` + 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"` + //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:"-"` + //Labels []PhotoLabel `yaml:"-"` + CreatedAt time.Time `yaml:"CreatedAt,omitempty"` + UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"` + EditedAt *time.Time `yaml:"EditedAt,omitempty"` + CheckedAt *time.Time `sql:"index" yaml:"-"` + DeletedAt *time.Time `sql:"index" yaml:"DeletedAt,omitempty"` +} + +// GET /api/v1/photos/:uuid // // 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) - }) +// uuid: string PhotoUID as returned by the API +func (c *V1Client) GetPhoto(uuid string) (*Photo, error) { + if uuid == "" { + return nil, fmt.Errorf("missing uuid for GetPhoto [GET /api/v1/photos/:uuid]") + } + photo := &Photo{ + UUID: uuid, + } + return photo, nil } // 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) - - c.JSON(http.StatusOK, p) - }) +func (c *V1Client) UpdatePhoto(update *Photo) (*Photo, error) { + if update.UUID == "" { + return nil, fmt.Errorf("missing uuid for UpdatePhoto [PUT /api/v1/photos/:uid]") + } + ref := *update + updated := &ref + // TODO Execute Request() + return updated, nil } -// GET /api/v1/photos/:uid/dl +// GET /api/v1/photos/:uuid/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)) - }) +// uuid: string PhotoUUID as returned by the API +func (c *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 } -// GET /api/v1/photos/:uid/yaml +// GET /api/v1/photos/:uuid/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) - }) +// uuid: string PhotoUUID as returned by the API +func (c *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 } -// POST /api/v1/photos/:uid/approve +// POST /api/v1/photos/:uuid/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}) - }) +// uuid: string PhotoUUID as returned by the API +func (c *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 } // 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}) - }) +func (c *V1Client) LikePhoto(uuid string) error { + if uuid == "" { + return fmt.Errorf("missing uuid for LikePhoto [POST /api/v1/photos/:uid/like]") + } + return nil } -// DELETE /api/v1/photos/:uid/like +// DELETE /api/v1/photos/:uuid/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}) - }) +// uuid: string PhotoUUID as returned by the API +func (c *V1Client) DislikePhoto(uuid string) error { + if uuid == "" { + return fmt.Errorf("missing uuid for DislikePhoto [DELETE /api/v1/photos/:uuid/like]") + } + return nil } // POST /api/v1/photos/:uid/files/:file_uid/primary @@ -276,35 +155,12 @@ func DislikePhoto(router *gin.RouterGroup) { // 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) - }) +func (c *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 } diff --git a/client.go b/client.go index f99744f..7e98c4f 100644 --- a/client.go +++ b/client.go @@ -1,10 +1,65 @@ -package photoprism_client_go +package photoprism -type PhotoprismClient struct { - // +import "github.com/kris-nova/client-go/api/v1" + +type Client struct { + v1client *api.V1Client } -func New() *PhotoprismClient { - p := &PhotoprismClient{} +type ClientAuthenticator interface { + getKey() string + getSecret() string +} + +// -- [ ClientAuthLogin ] -- + +// TODO We probably want to base64 encode this +type ClientAuthLogin struct { + user string + pass string +} + +func NewClientAuthLogin(user, pass string) ClientAuthenticator { + return &ClientAuthLogin{ + user: user, + pass: pass, + } +} + +func (c *ClientAuthLogin) getKey() string { + return c.user +} +func (c *ClientAuthLogin) getSecret() string { + return c.pass +} + +// -- [ ClientAuthToken ] -- + +// TODO We probably want to base64 encode this +type ClientAuthToken struct { + key string + secret string +} + +func NewClientAuthToken(key, secret string) ClientAuthenticator { + return &ClientAuthToken{ + key: key, + secret: secret, + } +} + +func (c *ClientAuthToken) getKey() string { + return c.key +} +func (c *ClientAuthToken) getSecret() string { + return c.secret +} + +func New(auth ClientAuthenticator) *Client { + p := &Client{} return p } + +func (c *Client) V1() *api.V1Client { + return c.v1client +} diff --git a/examples/download_photo.go b/examples/download_photo.go index 8792a3a..ffcb0b4 100644 --- a/examples/download_photo.go +++ b/examples/download_photo.go @@ -25,7 +25,8 @@ func main() { if pass == "" { halt(2, "Missing PHOTOPRISM_PASS") } - photoclient := photoprism.New() + login := photoprism.NewClientAuthLogin("username", "password") + photoclient := photoprism.New(login) photo, err := photoclient.V1().GetPhoto("123") if err != nil { halt(3, "Error fetching photo: %v", err)