diff --git a/drivers/all.go b/drivers/all.go index b9275931..cf0fb8e5 100644 --- a/drivers/all.go +++ b/drivers/all.go @@ -25,6 +25,7 @@ import ( _ "github.com/Xhofe/alist/drivers/webdav" _ "github.com/Xhofe/alist/drivers/xunlei" _ "github.com/Xhofe/alist/drivers/yandex" + _ "github.com/Xhofe/alist/drivers/baiduphoto" log "github.com/sirupsen/logrus" "strings" ) diff --git a/drivers/baiduphoto/baidu.go b/drivers/baiduphoto/baidu.go new file mode 100644 index 00000000..287ec433 --- /dev/null +++ b/drivers/baiduphoto/baidu.go @@ -0,0 +1,259 @@ +package baiduphoto + +import ( + "fmt" + "net/http" + + "github.com/Xhofe/alist/drivers/base" + "github.com/Xhofe/alist/model" + "github.com/Xhofe/alist/utils" + "github.com/go-resty/resty/v2" + log "github.com/sirupsen/logrus" +) + +func (driver Baidu) RefreshToken(account *model.Account) error { + err := driver.refreshToken(account) + if err != nil && err == base.ErrEmptyToken { + err = driver.refreshToken(account) + } + if err != nil { + account.Status = err.Error() + } + _ = model.SaveAccount(account) + return err +} + +func (driver Baidu) refreshToken(account *model.Account) error { + u := "https://openapi.baidu.com/oauth/2.0/token" + var resp base.TokenResp + var e TokenErrResp + _, err := base.RestyClient.R(). + SetResult(&resp). + SetError(&e). + SetQueryParams(map[string]string{ + "grant_type": "refresh_token", + "refresh_token": account.RefreshToken, + "client_id": account.ClientId, + "client_secret": account.ClientSecret, + }).Get(u) + if err != nil { + return err + } + if e.ErrorMsg != "" { + return &e + } + if resp.RefreshToken == "" { + return base.ErrEmptyToken + } + account.Status = "work" + account.AccessToken, account.RefreshToken = resp.AccessToken, resp.RefreshToken + return nil +} + +func (driver Baidu) Request(method string, url string, callback func(*resty.Request), account *model.Account) (*resty.Response, error) { + req := base.RestyClient.R() + req.SetQueryParam("access_token", account.AccessToken) + if callback != nil { + callback(req) + } + + res, err := req.Execute(method, url) + if err != nil { + return nil, err + } + log.Debug(res.String()) + + var erron Erron + if err = utils.Json.Unmarshal(res.Body(), &erron); err != nil { + return nil, err + } + + switch erron.Errno { + case 0: + return res, nil + case -6: + if err = driver.RefreshToken(account); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("errno: %d, refer to https://photo.baidu.com/union/doc", erron.Errno) + } + return driver.Request(method, url, callback, account) +} + +// 获取所有根文件 +func (driver Baidu) GetAllFile(account *model.Account) (files []File, err error) { + var cursor string + + for { + var resp FileListResp + _, err = driver.Request(http.MethodGet, FILE_API_URL+"/list", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "need_thumbnail": "1", + "need_filter_hidden": "0", + "cursor": cursor, + }) + r.SetResult(&resp) + }, account) + if err != nil { + return + } + + cursor = resp.Cursor + files = append(files, resp.List...) + + if !resp.HasNextPage() { + return + } + } +} + +// 获取所有相册 +func (driver Baidu) GetAllAlbum(account *model.Account) (albums []Album, err error) { + var cursor string + for { + var resp AlbumListResp + _, err = driver.Request(http.MethodGet, ALBUM_API_URL+"/list", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "need_amount": "1", + "limit": "100", + "cursor": cursor, + }) + r.SetResult(&resp) + }, account) + if err != nil { + return + } + if albums == nil { + albums = make([]Album, 0, resp.TotalCount) + } + + cursor = resp.Cursor + albums = append(albums, resp.List...) + + if !resp.HasNextPage() { + return + } + } +} + +// 获取相册中所有文件 +func (driver Baidu) GetAllAlbumFile(albumID string, account *model.Account) (files []AlbumFile, err error) { + var cursor string + for { + var resp AlbumFileListResp + _, err = driver.Request(http.MethodGet, ALBUM_API_URL+"/listfile", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "album_id": splitID(albumID)[0], + "need_amount": "1", + "limit": "1000", + "cursor": cursor, + }) + r.SetResult(&resp) + }, account) + if err != nil { + return + } + if files == nil { + files = make([]AlbumFile, 0, resp.TotalCount) + } + + cursor = resp.Cursor + files = append(files, resp.List...) + + if !resp.HasNextPage() { + return + } + } +} + +// 创建相册 +func (driver Baidu) CreateAlbum(name string, account *model.Account) error { + if !checkName(name) { + return ErrNotSupportName + } + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/create", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "title": name, + "tid": getTid(), + "source": "0", + }) + }, account) + return err +} + +// 相册改名 +func (driver Baidu) SetAlbumName(albumID string, name string, account *model.Account) error { + if !checkName(name) { + return ErrNotSupportName + } + + e := splitID(albumID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/settitle", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "title": name, + "album_id": e[0], + "tid": e[1], + }) + }, account) + return err +} + +// 删除相册 +func (driver Baidu) DeleteAlbum(albumID string, account *model.Account) error { + e := splitID(albumID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/delete", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "album_id": e[0], + "tid": e[1], + "delete_origin_image": "0", // 是否删除原图 0 不删除 + }) + }, account) + return err +} + +// 删除相册文件 +func (driver Baidu) DeleteAlbumFile(albumID string, account *model.Account, fileIDs ...string) error { + e := splitID(albumID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/delfile", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "album_id": e[0], + "tid": e[1], + "list": fsidsFormat(fileIDs...), + "del_origin": "0", // 是否删除原图 0 不删除 1 删除 + }) + }, account) + return err +} + +// 增加相册文件 +func (driver Baidu) AddAlbumFile(albumID string, account *model.Account, fileIDs ...string) error { + e := splitID(albumID) + _, err := driver.Request(http.MethodGet, ALBUM_API_URL+"/addfile", func(r *resty.Request) { + r.SetQueryParams(map[string]string{ + "album_id": e[0], + "tid": e[1], + "list": fsidsFormatNotUk(fileIDs...), + }) + }, account) + return err +} + +// 保存相册文件为根文件 +func (driver Baidu) CopyAlbumFile(albumID string, account *model.Account, fileID string) (*CopyFile, error) { + var resp CopyFileResp + e := splitID(fileID) + _, err := driver.Request(http.MethodPost, ALBUM_API_URL+"/copyfile", func(r *resty.Request) { + r.SetFormData(map[string]string{ + "album_id": splitID(albumID)[0], + "tid": e[2], + "uk": e[1], + "list": fsidsFormatNotUk(fileID), + }) + r.SetResult(&resp) + }, account) + if err != nil { + return nil, err + } + return &resp.List[0], err +} diff --git a/drivers/baiduphoto/driver.go b/drivers/baiduphoto/driver.go new file mode 100644 index 00000000..a502eefe --- /dev/null +++ b/drivers/baiduphoto/driver.go @@ -0,0 +1,452 @@ +package baiduphoto + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "math" + "net/http" + "os" + "path/filepath" + + "github.com/Xhofe/alist/conf" + "github.com/Xhofe/alist/drivers/base" + "github.com/Xhofe/alist/model" + "github.com/Xhofe/alist/utils" + "github.com/go-resty/resty/v2" +) + +type Baidu struct{} + +func init() { + base.RegisterDriver(new(Baidu)) +} + +func (driver Baidu) Config() base.DriverConfig { + return base.DriverConfig{ + Name: "Baidu.Photo", + LocalSort: true, + } +} + +func (driver Baidu) Items() []base.Item { + return []base.Item{ + { + Name: "refresh_token", + Label: "refresh token", + Type: base.TypeString, + Required: true, + }, + { + Name: "root_folder", + Label: "album_id", + Type: base.TypeString, + }, + { + Name: "client_id", + Label: "client id", + Default: "iYCeC9g08h5vuP9UqvPHKKSVrKFXGa1v", + Type: base.TypeString, + Required: true, + }, + { + Name: "client_secret", + Label: "client secret", + Default: "jXiFMOPVPCWlO2M5CwWQzffpNPaGTRBG", + Type: base.TypeString, + Required: true, + }, + } +} + +func (driver Baidu) Save(account *model.Account, old *model.Account) error { + if account == nil { + return nil + } + return driver.RefreshToken(account) +} + +func (driver Baidu) File(path string, account *model.Account) (*model.File, error) { + path = utils.ParsePath(path) + if path == "/" { + return &model.File{ + Id: account.RootFolder, + Name: account.Name, + Size: 0, + Type: conf.FOLDER, + Driver: driver.Config().Name, + UpdatedAt: account.UpdatedAt, + }, nil + } + + dir, name := filepath.Split(path) + files, err := driver.Files(dir, account) + if err != nil { + return nil, err + } + for _, file := range files { + if file.Name == name { + return &file, nil + } + } + return nil, base.ErrPathNotFound +} + +func (driver Baidu) Files(path string, account *model.Account) ([]model.File, error) { + path = utils.ParsePath(path) + var files []model.File + cache, err := base.GetCache(path, account) + if err == nil { + files, _ = cache.([]model.File) + return files, nil + } + + file, err := driver.File(path, account) + if err != nil { + return nil, err + } + + if IsAlbum(file) { + albumFiles, err := driver.GetAllAlbumFile(file.Id, account) + if err != nil { + return nil, err + } + files = make([]model.File, 0, len(albumFiles)) + for _, file := range albumFiles { + var thumbnail string + if len(file.Thumburl) > 0 { + thumbnail = file.Thumburl[0] + } + files = append(files, model.File{ + Id: joinID(file.Fsid, file.Uk, file.Tid), + Name: file.Name(), + Size: file.Size, + Type: utils.GetFileType(filepath.Ext(file.Path)), + Driver: driver.Config().Name, + UpdatedAt: getTime(file.Mtime), + Thumbnail: thumbnail, + }) + } + } else if IsRoot(file) { + albums, err := driver.GetAllAlbum(account) + if err != nil { + return nil, err + } + + files = make([]model.File, 0, len(albums)) + for _, album := range albums { + files = append(files, model.File{ + Id: joinID(album.AlbumID, album.Tid), + Name: album.Title, + Size: 0, + Type: conf.FOLDER, + Driver: driver.Config().Name, + UpdatedAt: getTime(album.Mtime), + }) + } + } else { + return nil, base.ErrNotSupport + } + + if len(files) > 0 { + _ = base.SetCache(path, files, account) + } + return files, nil +} + +func (driver Baidu) Link(args base.Args, account *model.Account) (*base.Link, error) { + file, err := driver.File(args.Path, account) + if err != nil { + return nil, err + } + if !IsAlbumFile(file) { + return nil, base.ErrNotSupport + } + + album, err := driver.File(filepath.Dir(utils.ParsePath(args.Path)), account) + if err != nil { + return nil, err + } + + e := splitID(file.Id) + res, err := base.NoRedirectClient.R(). + SetQueryParams(map[string]string{ + "access_token": account.AccessToken, + "album_id": splitID(album.Id)[0], + "tid": e[2], + "fsid": e[0], + "uk": e[1], + }). + Head(ALBUM_API_URL + "/download") + if err != nil { + return nil, err + } + return &base.Link{ + Headers: []base.Header{ + {Name: "User-Agent", Value: base.UserAgent}, + }, + Url: res.Header().Get("location"), + }, nil +} + +func (driver Baidu) Path(path string, account *model.Account) (*model.File, []model.File, error) { + path = utils.ParsePath(path) + file, err := driver.File(path, account) + if err != nil { + return nil, nil, err + } + if !file.IsDir() { + return file, nil, nil + } + files, err := driver.Files(path, account) + if err != nil { + return nil, nil, err + } + return nil, files, nil +} + +func (driver Baidu) Preview(path string, account *model.Account) (interface{}, error) { + return nil, base.ErrNotSupport +} + +func (driver Baidu) Rename(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + + if IsAlbum(srcFile) { + return driver.SetAlbumName(srcFile.Id, filepath.Base(dst), account) + } + return base.ErrNotSupport +} + +func (driver Baidu) MakeDir(path string, account *model.Account) error { + dir, name := filepath.Split(path) + parentFile, err := driver.File(dir, account) + if err != nil { + return err + } + + if !IsRoot(parentFile) { + return base.ErrNotSupport + } + return driver.CreateAlbum(name, account) +} + +func (driver Baidu) Move(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + + if IsAlbumFile(srcFile) { + // 移动相册文件 + dstAlbum, err := driver.File(filepath.Dir(dst), account) + if err != nil { + return err + } + if !IsAlbum(dstAlbum) { + return base.ErrNotSupport + } + + srcAlbum, err := driver.File(filepath.Dir(src), account) + if err != nil { + return err + } + + newFile, err := driver.CopyAlbumFile(srcAlbum.Id, account, srcFile.Id) + if err != nil { + return err + } + err = driver.DeleteAlbumFile(srcAlbum.Id, account, srcFile.Id) + if err != nil { + return err + } + err = driver.AddAlbumFile(dstAlbum.Id, account, joinID(newFile.Fsid)) + if err != nil { + return err + } + return nil + } + return base.ErrNotSupport +} + +func (driver Baidu) Copy(src string, dst string, account *model.Account) error { + srcFile, err := driver.File(src, account) + if err != nil { + return err + } + + if IsAlbumFile(srcFile) { + // 复制相册文件 + dstAlbum, err := driver.File(filepath.Dir(dst), account) + if err != nil { + return err + } + if !IsAlbum(dstAlbum) { + return base.ErrNotSupport + } + + srcAlbum, err := driver.File(filepath.Dir(src), account) + if err != nil { + return err + } + + newFile, err := driver.CopyAlbumFile(srcAlbum.Id, account, srcFile.Id) + if err != nil { + return err + } + err = driver.AddAlbumFile(dstAlbum.Id, account, joinID(newFile.Fsid)) + if err != nil { + return err + } + return nil + } + return base.ErrNotSupport +} + +func (driver Baidu) Delete(path string, account *model.Account) error { + file, err := driver.File(path, account) + if err != nil { + return err + } + + // 删除相册 + if IsAlbum(file) { + return driver.DeleteAlbum(file.Id, account) + } + + // 生成相册文件 + if IsAlbumFile(file) { + // 删除相册文件 + album, err := driver.File(filepath.Dir(path), account) + if err != nil { + return err + } + return driver.DeleteAlbumFile(album.Id, account, file.Id) + } + return base.ErrNotSupport +} + +func (driver Baidu) Upload(file *model.FileStream, account *model.Account) error { + if file == nil { + return base.ErrEmptyFile + } + + parentFile, err := driver.File(file.ParentPath, account) + if err != nil { + return err + } + + if !IsAlbum(parentFile) { + return base.ErrNotSupport + } + + tempFile, err := ioutil.TempFile(conf.Conf.TempDir, "file-*") + if err != nil { + return err + } + defer func() { + tempFile.Close() + os.Remove(tempFile.Name()) + }() + + // 计算需要的数据 + const DEFAULT = 1 << 22 + const SliceSize = 1 << 18 + count := int(math.Ceil(float64(file.Size) / float64(DEFAULT))) + + sliceMD5List := make([]string, 0, count) + fileMd5 := md5.New() + sliceMd5 := md5.New() + for i := 1; i <= count; i++ { + if n, err := io.CopyN(io.MultiWriter(fileMd5, sliceMd5, tempFile), file, DEFAULT); err != io.EOF && n == 0 { + return err + } + sliceMD5List = append(sliceMD5List, hex.EncodeToString(sliceMd5.Sum(nil))) + sliceMd5.Reset() + } + + if _, err = tempFile.Seek(0, io.SeekStart); err != nil { + return err + } + + content_md5 := hex.EncodeToString(fileMd5.Sum(nil)) + slice_md5 := content_md5 + if file.GetSize() > SliceSize { + sliceData := make([]byte, SliceSize) + if _, err = io.ReadFull(tempFile, sliceData); err != nil { + return err + } + sliceMd5.Write(sliceData) + slice_md5 = hex.EncodeToString(sliceMd5.Sum(nil)) + if _, err = tempFile.Seek(0, io.SeekStart); err != nil { + return err + } + } + + // 开始执行上传 + params := map[string]string{ + "autoinit": "1", + "isdir": "0", + "rtype": "1", + "ctype": "11", + "path": utils.ParsePath(file.Name), + "size": fmt.Sprint(file.Size), + "slice-md5": slice_md5, + "content-md5": content_md5, + "block_list": MustString(utils.Json.MarshalToString(sliceMD5List)), + } + + // 预上传 + var precreateResp PrecreateResp + _, err = driver.Request(http.MethodPost, FILE_API_URL+"/precreate", func(r *resty.Request) { + r.SetFormData(params) + r.SetResult(&precreateResp) + }, account) + if err != nil { + return err + } + + switch precreateResp.ReturnType { + case 1: // 上传文件 + uploadParams := map[string]string{ + "method": "upload", + "path": params["path"], + "uploadid": precreateResp.UploadID, + } + + for i := 0; i < count; i++ { + uploadParams["partseq"] = fmt.Sprint(i) + _, err = driver.Request(http.MethodPost, "https://c3.pcs.baidu.com/rest/2.0/pcs/superfile2", func(r *resty.Request) { + r.SetQueryParams(uploadParams) + r.SetFileReader("file", file.Name, io.LimitReader(tempFile, DEFAULT)) + }, account) + if err != nil { + return err + } + } + fallthrough + case 2: // 创建文件 + params["uploadid"] = precreateResp.UploadID + _, err = driver.Request(http.MethodPost, FILE_API_URL+"/create", func(r *resty.Request) { + r.SetFormData(params) + r.SetResult(&precreateResp) + }, account) + if err != nil { + return err + } + fallthrough + case 3: // 增加到相册 + err = driver.AddAlbumFile(parentFile.Id, account, joinID(precreateResp.Data.FsID)) + if err != nil { + return err + } + } + return nil +} + +var _ base.Driver = (*Baidu)(nil) diff --git a/drivers/baiduphoto/types.go b/drivers/baiduphoto/types.go new file mode 100644 index 00000000..9bc954b0 --- /dev/null +++ b/drivers/baiduphoto/types.go @@ -0,0 +1,125 @@ +package baiduphoto + +import ( + "fmt" + "path/filepath" +) + +type TokenErrResp struct { + ErrorDescription string `json:"error_description"` + ErrorMsg string `json:"error"` +} + +func (e *TokenErrResp) Error() string { + return fmt.Sprint(e.ErrorMsg, " : ", e.ErrorDescription) +} + +type Erron struct { + Errno int `json:"errno"` + RequestID int `json:"request_id"` +} + +type Page struct { + HasMore int `json:"has_more"` + Cursor string `json:"cursor"` +} + +func (p Page) HasNextPage() bool { + return p.HasMore == 1 +} + +type ( + FileListResp struct { + Page + List []File `json:"list"` + } + + File struct { + Fsid int64 `json:"fsid"` // 文件ID + Path string `json:"path"` // 文件路径 + Size int64 `json:"size"` + Ctime int64 `json:"ctime"` // 创建时间 s + Mtime int64 `json:"mtime"` // 修改时间 s + Thumburl []string `json:"thumburl"` + } +) + +func (f File) Name() string { + return filepath.Base(f.Path) +} + +/*相册部分*/ +type ( + AlbumListResp struct { + Page + List []Album `json:"list"` + Reset int64 `json:"reset"` + TotalCount int64 `json:"total_count"` + } + + Album struct { + AlbumID string `json:"album_id"` + Tid int64 `json:"tid"` + Title string `json:"title"` + JoinTime int64 `json:"join_time"` + CreateTime int64 `json:"create_time"` + Mtime int64 `json:"mtime"` + } + + AlbumFileListResp struct { + Page + List []AlbumFile `json:"list"` + Reset int64 `json:"reset"` + TotalCount int64 `json:"total_count"` + } + + AlbumFile struct { + File + Tid int64 `json:"tid"` + Uk int64 `json:"uk"` + } +) + +type ( + CopyFileResp struct { + List []CopyFile `json:"list"` + } + CopyFile struct { + FromFsid int64 `json:"from_fsid"` // 源ID + Fsid int64 `json:"fsid"` // 目标ID + Path string `json:"path"` + ShootTime int `json:"shoot_time"` + } +) + +/*上传部分*/ +type ( + UploadFile struct { + FsID int64 `json:"fs_id"` + Size int `json:"size"` + Md5 string `json:"md5"` + ServerFilename string `json:"server_filename"` + Path string `json:"path"` + Ctime int `json:"ctime"` + Mtime int `json:"mtime"` + Isdir int `json:"isdir"` + Category int `json:"category"` + ServerMd5 string `json:"server_md5"` + ShootTime int `json:"shoot_time"` + } + + CreateFileResp struct { + Data UploadFile `json:"data"` + } + + PrecreateResp struct { + ReturnType int `json:"return_type"` //存在返回2 不存在返回1 已经保存3 + //存在返回 + CreateFileResp + + //不存在返回 + Path string `json:"path"` + UploadID string `json:"uploadid"` + Blocklist []int64 `json:"block_list"` + } +) diff --git a/drivers/baiduphoto/util.go b/drivers/baiduphoto/util.go new file mode 100644 index 00000000..5ab40a52 --- /dev/null +++ b/drivers/baiduphoto/util.go @@ -0,0 +1,83 @@ +package baiduphoto + +import ( + "errors" + "fmt" + "math" + "math/rand" + "regexp" + "strings" + "time" + + "github.com/Xhofe/alist/model" +) + +const ( + API_URL = "https://photo.baidu.com/youai" + ALBUM_API_URL = API_URL + "/album/v1" + FILE_API_URL = API_URL + "/file/v1" +) + +var ( + ErrNotSupportName = errors.New("only chinese and english, numbers and underscores are supported, and the length is no more than 20") +) + +//Tid生成 +func getTid() string { + return fmt.Sprintf("3%d%.0f", time.Now().Unix(), math.Floor(9000000*rand.Float64()+1000000)) +} + +// 检查名称 +func checkName(name string) bool { + return len(name) <= 20 && regexp.MustCompile("[\u4e00-\u9fa5A-Za-z0-9_]").MatchString(name) +} + +func getTime(t int64) *time.Time { + tm := time.Unix(t, 0) + return &tm +} + +func fsidsFormat(ids ...string) string { + var buf []string + for _, id := range ids { + e := strings.Split(id, "|") + buf = append(buf, fmt.Sprintf("{\"fsid\":%s,\"uk\":%s}", e[0], e[1])) + } + return fmt.Sprintf("[%s]", strings.Join(buf, ",")) +} + +func fsidsFormatNotUk(ids ...string) string { + var buf []string + for _, id := range ids { + buf = append(buf, fmt.Sprintf("{\"fsid\":%s}", strings.Split(id, "|")[0])) + } + return fmt.Sprintf("[%s]", strings.Join(buf, ",")) +} + +func splitID(id string) []string { + return strings.SplitN(id, "|", 3)[:3] +} + +func joinID(ids ...interface{}) string { + idsStr := make([]string, 0, len(ids)) + for _, id := range ids { + idsStr = append(idsStr, fmt.Sprint(id)) + } + return strings.Join(idsStr, "|") +} + +func IsAlbum(file *model.File) bool { + return file.Id != "" && file.IsDir() +} + +func IsAlbumFile(file *model.File) bool { + return file.Id != "" && !file.IsDir() +} + +func IsRoot(file *model.File) bool { + return file.Id == "" && file.IsDir() +} + +func MustString(str string, err error) string { + return str +}