diff --git a/drivers/all.go b/drivers/all.go index 9dd30bb3d8f..64c2d86dbeb 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -11,6 +11,7 @@ import ( _ "github.com/alist-org/alist/v3/drivers/baidu_photo" _ "github.com/alist-org/alist/v3/drivers/ftp" _ "github.com/alist-org/alist/v3/drivers/google_drive" + _ "github.com/alist-org/alist/v3/drivers/google_photo" _ "github.com/alist-org/alist/v3/drivers/lanzou" _ "github.com/alist-org/alist/v3/drivers/local" _ "github.com/alist-org/alist/v3/drivers/mediatrack" diff --git a/drivers/google_photo/driver.go b/drivers/google_photo/driver.go new file mode 100644 index 00000000000..6de1ae9d013 --- /dev/null +++ b/drivers/google_photo/driver.go @@ -0,0 +1,170 @@ +package google_photo + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/errs" + "github.com/alist-org/alist/v3/internal/model" + "github.com/alist-org/alist/v3/pkg/utils" + "github.com/go-resty/resty/v2" +) + +type GooglePhoto struct { + model.Storage + Addition + AccessToken string +} + +func (d *GooglePhoto) Config() driver.Config { + return config +} + +func (d *GooglePhoto) GetAddition() driver.Additional { + return d.Addition +} + +func (d *GooglePhoto) Init(ctx context.Context, storage model.Storage) error { + d.Storage = storage + err := utils.Json.UnmarshalFromString(d.Storage.Addition, &d.Addition) + if err != nil { + return err + } + return d.refreshToken() +} + +func (d *GooglePhoto) Drop(ctx context.Context) error { + return nil +} + +func (d *GooglePhoto) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) { + files, err := d.getFiles() + if err != nil { + return nil, err + } + return utils.SliceConvert(files, func(src MediaItem) (model.Obj, error) { + return fileToObj(src), nil + }) +} + +//func (d *GooglePhoto) Get(ctx context.Context, path string) (model.Obj, error) { +// // this is optional +// return nil, errs.NotImplement +//} + +func (d *GooglePhoto) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) { + f, err := d.getFile(file.GetID()) + if err != nil { + return nil, err + } + + if strings.Contains(f.MimeType, "image/") { + return &model.Link{ + URL: f.BaseURL + "=d", + }, nil + } else if strings.Contains(f.MimeType, "video/") { + return &model.Link{ + URL: f.BaseURL + "=dv", + }, nil + } + return &model.Link{}, nil +} + +func (d *GooglePhoto) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error { + return errs.NotSupport +} + +func (d *GooglePhoto) Move(ctx context.Context, srcObj, dstDir model.Obj) error { + return errs.NotSupport +} + +func (d *GooglePhoto) Rename(ctx context.Context, srcObj model.Obj, newName string) error { + return errs.NotSupport +} + +func (d *GooglePhoto) Copy(ctx context.Context, srcObj, dstDir model.Obj) error { + return errs.NotSupport +} + +func (d *GooglePhoto) Remove(ctx context.Context, obj model.Obj) error { + return errs.NotSupport +} + +func (d *GooglePhoto) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error { + var e Error + // Create resumable upload url + postHeaders := map[string]string{ + "Authorization": "Bearer " + d.AccessToken, + "Content-type": "application/octet-stream", + "X-Goog-Upload-Command": "start", + "X-Goog-Upload-Content-Type": stream.GetMimetype(), + "X-Goog-Upload-Protocol": "resumable", + "X-Goog-Upload-Raw-Size": strconv.FormatInt(stream.GetSize(), 10), + } + url := "https://photoslibrary.googleapis.com/v1/uploads" + res, err := base.NoRedirectClient.R().SetHeaders(postHeaders). + SetError(&e). + Post(url) + + if err != nil { + return err + } + if e.Error.Code != 0 { + if e.Error.Code == 401 { + err = d.refreshToken() + if err != nil { + return err + } + return d.Put(ctx, dstDir, stream, up) + } + return fmt.Errorf("%s: %v", e.Error.Message, e.Error.Errors) + } + + //Upload to the Google Photo + postUrl := res.Header().Get("X-Goog-Upload-URL") + //chunkSize := res.Header().Get("X-Goog-Upload-Chunk-Granularity") + postHeaders = map[string]string{ + "X-Goog-Upload-Command": "upload, finalize", + "X-Goog-Upload-Offset": "0", + } + + resp, err := d.request(postUrl, http.MethodPost, func(req *resty.Request) { + req.SetBody(stream.GetReadCloser()) + }, nil, postHeaders) + + if err != nil { + return err + } + //Create MediaItem + createItemUrl := "https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate" + + postHeaders = map[string]string{ + "X-Goog-Upload-Command": "upload, finalize", + "X-Goog-Upload-Offset": "0", + } + + data := base.Json{ + "newMediaItems": []base.Json{ + { + "description": "item-description", + "simpleMediaItem": base.Json{ + "fileName": stream.GetName(), + "uploadToken": string(resp), + }, + }, + }, + } + + _, err = d.request(createItemUrl, http.MethodPost, func(req *resty.Request) { + req.SetBody(data) + }, nil, postHeaders) + + return err +} + +var _ driver.Driver = (*GooglePhoto)(nil) diff --git a/drivers/google_photo/meta.go b/drivers/google_photo/meta.go new file mode 100644 index 00000000000..9258dce9080 --- /dev/null +++ b/drivers/google_photo/meta.go @@ -0,0 +1,29 @@ +package google_photo + +import ( + "github.com/alist-org/alist/v3/internal/driver" + "github.com/alist-org/alist/v3/internal/op" +) + +type Addition struct { + driver.RootID + RefreshToken string `json:"refresh_token" required:"true"` + ClientID string `json:"client_id" required:"true" default:"202264815644.apps.googleusercontent.com"` + ClientSecret string `json:"client_secret" required:"true" default:"X4Z3ca8xfWDb1Voo-F9a7ZxJ"` +} + +var config = driver.Config{ + Name: "GooglePhoto", + OnlyProxy: true, + DefaultRoot: "root", + NoUpload: true, + LocalSort: true, +} + +func New() driver.Driver { + return &GooglePhoto{} +} + +func init() { + op.RegisterDriver(config, New) +} diff --git a/drivers/google_photo/types.go b/drivers/google_photo/types.go new file mode 100644 index 00000000000..84ddb886b04 --- /dev/null +++ b/drivers/google_photo/types.go @@ -0,0 +1,69 @@ +package google_photo + +import ( + "time" + + "github.com/alist-org/alist/v3/internal/model" +) + +type TokenError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type Files struct { + NextPageToken string `json:"nextPageToken"` + MediaItems []MediaItem `json:"mediaItems"` +} + +type MediaItem struct { + Id string `json:"id"` + BaseURL string `json:"baseUrl"` + MimeType string `json:"mimeType"` + FileName string `json:"filename"` + MediaMetadata MediaMetadata `json:"mediaMetadata"` +} + +type MediaMetadata struct { + CreationTime time.Time `json:"creationTime"` + Width string `json:"width"` + Height string `json:"height"` + Photo Photo `json:"photo,omitempty"` + Video Video `json:"video,omitempty"` +} + +type Photo struct { +} + +type Video struct { +} + +func fileToObj(f MediaItem) *model.ObjThumb { + //size, _ := strconv.ParseInt(f.Size, 10, 64) + return &model.ObjThumb{ + Object: model.Object{ + ID: f.Id, + Name: f.FileName, + Size: 0, + Modified: f.MediaMetadata.CreationTime, + IsFolder: false, + }, + Thumbnail: model.Thumbnail{ + Thumbnail: f.BaseURL + "=w100-h100-c", + }, + } +} + +type Error struct { + Error struct { + Errors []struct { + Domain string `json:"domain"` + Reason string `json:"reason"` + Message string `json:"message"` + LocationType string `json:"location_type"` + Location string `json:"location"` + } + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} diff --git a/drivers/google_photo/util.go b/drivers/google_photo/util.go new file mode 100644 index 00000000000..571ce9fcd78 --- /dev/null +++ b/drivers/google_photo/util.go @@ -0,0 +1,105 @@ +package google_photo + +import ( + "fmt" + "net/http" + + "github.com/alist-org/alist/v3/drivers/base" + "github.com/go-resty/resty/v2" +) + +// do others that not defined in Driver interface + +func (d *GooglePhoto) refreshToken() error { + url := "https://www.googleapis.com/oauth2/v4/token" + var resp base.TokenResp + var e TokenError + _, err := base.RestyClient.R().SetResult(&resp).SetError(&e). + SetFormData(map[string]string{ + "client_id": d.ClientID, + "client_secret": d.ClientSecret, + "refresh_token": d.RefreshToken, + "grant_type": "refresh_token", + }).Post(url) + if err != nil { + return err + } + if e.Error != "" { + return fmt.Errorf(e.Error) + } + d.AccessToken = resp.AccessToken + return nil +} + +func (d *GooglePhoto) request(url string, method string, callback base.ReqCallback, resp interface{}, headers map[string]string) ([]byte, error) { + req := base.RestyClient.R() + req.SetHeader("Authorization", "Bearer "+d.AccessToken) + if headers != nil { + req.SetHeaders(headers) + } + + if callback != nil { + callback(req) + } + if resp != nil { + req.SetResult(resp) + } + var e Error + req.SetError(&e) + res, err := req.Execute(method, url) + if err != nil { + return nil, err + } + if e.Error.Code != 0 { + if e.Error.Code == 401 { + err = d.refreshToken() + if err != nil { + return nil, err + } + return d.request(url, method, callback, resp, headers) + } + return nil, fmt.Errorf("%s: %v", e.Error.Message, e.Error.Errors) + } + return res.Body(), nil +} + +func (d *GooglePhoto) getFiles() ([]MediaItem, error) { + pageToken := "first" + res := make([]MediaItem, 0) + for pageToken != "" { + if pageToken == "first" { + pageToken = "" + } + var resp Files + query := map[string]string{ + "fields": "mediaItems(id,baseUrl,mimeType,mediaMetadata,filename),nextPageToken", + "pageSize": "100", + "pageToken": pageToken, + } + _, err := d.request("https://photoslibrary.googleapis.com/v1/mediaItems", http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(query) + }, &resp, nil) + if err != nil { + return nil, err + } + pageToken = resp.NextPageToken + res = append(res, resp.MediaItems...) + } + return res, nil +} + +func (d *GooglePhoto) getFile(id string) (MediaItem, error) { + var resp MediaItem + + query := map[string]string{ + "fields": "baseUrl,mimeType", + } + _, err := d.request(fmt.Sprintf("https://photoslibrary.googleapis.com/v1/mediaItems/%s", id), http.MethodGet, func(req *resty.Request) { + req.SetQueryParams(query) + }, &resp, nil) + if err != nil { + return resp, err + } + + return resp, nil +}