Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Google Photo support #1853

Merged
merged 3 commits into from
Oct 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions drivers/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
170 changes: 170 additions & 0 deletions drivers/google_photo/driver.go
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里没有使用file_id 是一次性展示所有吗

Copy link
Contributor Author

@LittleJake LittleJake Oct 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

根据谷歌提供的api来说,除了列出所有,或者通过手动进行搜索条件筛选列出、列出影集这几种。

我这边也review了一下rclone的做法,他们那边好像也是遍历分日期、影集、全部这三种方式展示全部文件(包括重复的情况)。

Rclone - Google Photos

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)
29 changes: 29 additions & 0 deletions drivers/google_photo/meta.go
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如果Google photo没有排序的方法的话,可以在这里加上LocalSort:true以使用本地排序。

NoUpload: true,
LocalSort: true,
}

func New() driver.Driver {
return &GooglePhoto{}
}

func init() {
op.RegisterDriver(config, New)
}
69 changes: 69 additions & 0 deletions drivers/google_photo/types.go
Original file line number Diff line number Diff line change
@@ -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"`
}
105 changes: 105 additions & 0 deletions drivers/google_photo/util.go
Original file line number Diff line number Diff line change
@@ -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
}