mirror of https://github.com/Xhofe/alist
Merge 994aa46bf5
into 4f8bc478d5
commit
e45e256cb5
|
@ -51,6 +51,7 @@ import (
|
|||
_ "github.com/alist-org/alist/v3/drivers/onedrive"
|
||||
_ "github.com/alist-org/alist/v3/drivers/onedrive_app"
|
||||
_ "github.com/alist-org/alist/v3/drivers/onedrive_sharelink"
|
||||
_ "github.com/alist-org/alist/v3/drivers/pcloud"
|
||||
_ "github.com/alist-org/alist/v3/drivers/pikpak"
|
||||
_ "github.com/alist-org/alist/v3/drivers/pikpak_share"
|
||||
_ "github.com/alist-org/alist/v3/drivers/quark_uc"
|
||||
|
|
|
@ -0,0 +1,189 @@
|
|||
package pcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/alist-org/alist/v3/internal/driver"
|
||||
"github.com/alist-org/alist/v3/internal/model"
|
||||
"github.com/alist-org/alist/v3/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
type PCloud struct {
|
||||
model.Storage
|
||||
Addition
|
||||
AccessToken string // Actual access token obtained from refresh token
|
||||
}
|
||||
|
||||
func (d *PCloud) Config() driver.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
func (d *PCloud) GetAddition() driver.Additional {
|
||||
return &d.Addition
|
||||
}
|
||||
|
||||
func (d *PCloud) Init(ctx context.Context) error {
|
||||
// Map hostname selection to actual API endpoints
|
||||
if d.Hostname == "us" {
|
||||
d.Hostname = "api.pcloud.com"
|
||||
} else if d.Hostname == "eu" {
|
||||
d.Hostname = "eapi.pcloud.com"
|
||||
}
|
||||
|
||||
// Set default root folder ID if not provided
|
||||
if d.RootFolderID == "" {
|
||||
d.RootFolderID = "d0"
|
||||
}
|
||||
|
||||
// Use the access token directly (like rclone)
|
||||
d.AccessToken = d.RefreshToken // RefreshToken field actually contains the access_token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PCloud) Drop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PCloud) List(ctx context.Context, dir model.Obj, args model.ListArgs) ([]model.Obj, error) {
|
||||
folderID := d.RootFolderID
|
||||
if dir.GetID() != "" {
|
||||
folderID = dir.GetID()
|
||||
}
|
||||
|
||||
files, err := d.getFiles(folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return utils.SliceConvert(files, func(src FileObject) (model.Obj, error) {
|
||||
return fileToObj(src), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (d *PCloud) Link(ctx context.Context, file model.Obj, args model.LinkArgs) (*model.Link, error) {
|
||||
downloadURL, err := d.getDownloadLink(file.GetID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.Link{
|
||||
URL: downloadURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Mkdir implements driver.Mkdir
|
||||
func (d *PCloud) MakeDir(ctx context.Context, parentDir model.Obj, dirName string) error {
|
||||
parentID := d.RootFolderID
|
||||
if parentDir.GetID() != "" {
|
||||
parentID = parentDir.GetID()
|
||||
}
|
||||
|
||||
return d.createFolder(parentID, dirName)
|
||||
}
|
||||
|
||||
// Move implements driver.Move
|
||||
func (d *PCloud) Move(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
// pCloud uses renamefile/renamefolder for both rename and move
|
||||
endpoint := "/renamefile"
|
||||
paramName := "fileid"
|
||||
|
||||
if srcObj.IsDir() {
|
||||
endpoint = "/renamefolder"
|
||||
paramName = "folderid"
|
||||
}
|
||||
|
||||
var resp ItemResult
|
||||
_, err := d.requestWithRetry(endpoint, "POST", func(req *resty.Request) {
|
||||
req.SetFormData(map[string]string{
|
||||
paramName: extractID(srcObj.GetID()),
|
||||
"tofolderid": extractID(dstDir.GetID()),
|
||||
"toname": srcObj.GetName(),
|
||||
})
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename implements driver.Rename
|
||||
func (d *PCloud) Rename(ctx context.Context, srcObj model.Obj, newName string) error {
|
||||
endpoint := "/renamefile"
|
||||
paramName := "fileid"
|
||||
|
||||
if srcObj.IsDir() {
|
||||
endpoint = "/renamefolder"
|
||||
paramName = "folderid"
|
||||
}
|
||||
|
||||
var resp ItemResult
|
||||
_, err := d.requestWithRetry(endpoint, "POST", func(req *resty.Request) {
|
||||
req.SetFormData(map[string]string{
|
||||
paramName: extractID(srcObj.GetID()),
|
||||
"toname": newName,
|
||||
})
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Copy implements driver.Copy
|
||||
func (d *PCloud) Copy(ctx context.Context, srcObj, dstDir model.Obj) error {
|
||||
endpoint := "/copyfile"
|
||||
paramName := "fileid"
|
||||
|
||||
if srcObj.IsDir() {
|
||||
endpoint = "/copyfolder"
|
||||
paramName = "folderid"
|
||||
}
|
||||
|
||||
var resp ItemResult
|
||||
_, err := d.requestWithRetry(endpoint, "POST", func(req *resty.Request) {
|
||||
req.SetFormData(map[string]string{
|
||||
paramName: extractID(srcObj.GetID()),
|
||||
"tofolderid": extractID(dstDir.GetID()),
|
||||
"toname": srcObj.GetName(),
|
||||
})
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove implements driver.Remove
|
||||
func (d *PCloud) Remove(ctx context.Context, obj model.Obj) error {
|
||||
return d.delete(obj.GetID(), obj.IsDir())
|
||||
}
|
||||
|
||||
// Put implements driver.Put
|
||||
func (d *PCloud) Put(ctx context.Context, dstDir model.Obj, stream model.FileStreamer, up driver.UpdateProgress) error {
|
||||
parentID := d.RootFolderID
|
||||
if dstDir.GetID() != "" {
|
||||
parentID = dstDir.GetID()
|
||||
}
|
||||
|
||||
return d.uploadFile(ctx, stream, parentID, stream.GetName(), stream.GetSize())
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package pcloud
|
||||
|
||||
import (
|
||||
"github.com/alist-org/alist/v3/internal/driver"
|
||||
"github.com/alist-org/alist/v3/internal/op"
|
||||
)
|
||||
|
||||
type Addition struct {
|
||||
// Using json tag "access_token" for UI display, but internally it's a refresh token
|
||||
RefreshToken string `json:"access_token" required:"true" help:"OAuth token from pCloud authorization"`
|
||||
Hostname string `json:"hostname" type:"select" options:"us,eu" default:"us" help:"Select pCloud server region"`
|
||||
RootFolderID string `json:"root_folder_id" help:"Get folder ID from URL like https://my.pcloud.com/#/filemanager?folder=12345678901 (leave empty for root folder)"`
|
||||
ClientID string `json:"client_id" help:"Custom OAuth client ID (optional)"`
|
||||
ClientSecret string `json:"client_secret" help:"Custom OAuth client secret (optional)"`
|
||||
}
|
||||
|
||||
// Implement IRootId interface
|
||||
func (a Addition) GetRootId() string {
|
||||
return a.RootFolderID
|
||||
}
|
||||
|
||||
var config = driver.Config{
|
||||
Name: "pCloud",
|
||||
}
|
||||
|
||||
func init() {
|
||||
op.RegisterDriver(func() driver.Driver {
|
||||
return &PCloud{}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
package pcloud
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alist-org/alist/v3/internal/model"
|
||||
)
|
||||
|
||||
// ErrorResult represents a pCloud API error response
|
||||
type ErrorResult struct {
|
||||
Result int `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// TokenResponse represents OAuth token response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// ItemResult represents a common pCloud API response
|
||||
type ItemResult struct {
|
||||
Result int `json:"result"`
|
||||
Metadata *FolderMeta `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// FolderMeta contains folder metadata including contents
|
||||
type FolderMeta struct {
|
||||
Contents []FileObject `json:"contents,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadLinkResult represents download link response
|
||||
type DownloadLinkResult struct {
|
||||
Result int `json:"result"`
|
||||
Hosts []string `json:"hosts"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// FileObject represents a file or folder object in pCloud
|
||||
type FileObject struct {
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"` // pCloud returns RFC1123 format string
|
||||
Modified string `json:"modified"` // pCloud returns RFC1123 format string
|
||||
IsFolder bool `json:"isfolder"`
|
||||
FolderID uint64 `json:"folderid,omitempty"`
|
||||
FileID uint64 `json:"fileid,omitempty"`
|
||||
Size uint64 `json:"size"`
|
||||
ParentID uint64 `json:"parentfolderid"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Hash uint64 `json:"hash,omitempty"`
|
||||
Category int `json:"category,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// Convert FileObject to model.Obj
|
||||
func fileToObj(f FileObject) model.Obj {
|
||||
// Parse RFC1123 format time from pCloud
|
||||
modTime, _ := time.Parse(time.RFC1123, f.Modified)
|
||||
|
||||
obj := model.Object{
|
||||
Name: f.Name,
|
||||
Size: int64(f.Size),
|
||||
Modified: modTime,
|
||||
IsFolder: f.IsFolder,
|
||||
}
|
||||
|
||||
if f.IsFolder {
|
||||
obj.ID = "d" + strconv.FormatUint(f.FolderID, 10)
|
||||
} else {
|
||||
obj.ID = "f" + strconv.FormatUint(f.FileID, 10)
|
||||
}
|
||||
|
||||
return &obj
|
||||
}
|
||||
|
||||
// Extract numeric ID from string ID (remove 'd' or 'f' prefix)
|
||||
func extractID(id string) string {
|
||||
if len(id) > 1 && (id[0] == 'd' || id[0] == 'f') {
|
||||
return id[1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Get folder ID from path, return "0" for root
|
||||
func getFolderID(path string) string {
|
||||
if path == "/" || path == "" {
|
||||
return "0"
|
||||
}
|
||||
return extractID(path)
|
||||
}
|
|
@ -0,0 +1,297 @@
|
|||
package pcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/alist-org/alist/v3/drivers/base"
|
||||
"github.com/alist-org/alist/v3/pkg/utils"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultClientID = "DnONSzyJXpm"
|
||||
defaultClientSecret = "VKEnd3ze4jsKFGg8TJiznwFG8"
|
||||
)
|
||||
|
||||
// Get API base URL
|
||||
func (d *PCloud) getAPIURL() string {
|
||||
return "https://" + d.Hostname
|
||||
}
|
||||
|
||||
// Get OAuth client credentials
|
||||
func (d *PCloud) getClientCredentials() (string, string) {
|
||||
clientID := d.ClientID
|
||||
clientSecret := d.ClientSecret
|
||||
|
||||
if clientID == "" {
|
||||
clientID = defaultClientID
|
||||
}
|
||||
if clientSecret == "" {
|
||||
clientSecret = defaultClientSecret
|
||||
}
|
||||
|
||||
return clientID, clientSecret
|
||||
}
|
||||
|
||||
// Refresh OAuth access token
|
||||
func (d *PCloud) refreshToken() error {
|
||||
clientID, clientSecret := d.getClientCredentials()
|
||||
|
||||
var resp TokenResponse
|
||||
_, err := base.RestyClient.R().
|
||||
SetFormData(map[string]string{
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": d.RefreshToken,
|
||||
}).
|
||||
SetResult(&resp).
|
||||
Post(d.getAPIURL() + "/oauth2_token")
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.AccessToken = resp.AccessToken
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldRetry determines if an error should be retried based on pCloud-specific logic
|
||||
func (d *PCloud) shouldRetry(statusCode int, apiError *ErrorResult) bool {
|
||||
// HTTP-level retry conditions
|
||||
if statusCode == 429 || statusCode >= 500 {
|
||||
return true
|
||||
}
|
||||
|
||||
// pCloud API-specific retry conditions (like rclone)
|
||||
if apiError != nil && apiError.Result != 0 {
|
||||
// 4xxx: rate limiting
|
||||
if apiError.Result/1000 == 4 {
|
||||
return true
|
||||
}
|
||||
// 5xxx: internal errors
|
||||
if apiError.Result/1000 == 5 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// requestWithRetry makes authenticated API request with retry logic
|
||||
func (d *PCloud) requestWithRetry(endpoint string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
maxRetries := 3
|
||||
baseDelay := 500 * time.Millisecond
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
body, err := d.request(endpoint, method, callback, resp)
|
||||
if err == nil {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// If this is the last attempt, return the error
|
||||
if attempt == maxRetries {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if we should retry based on error type
|
||||
if !d.shouldRetryError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
delay := baseDelay * time.Duration(1<<attempt)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("max retries exceeded")
|
||||
}
|
||||
|
||||
// shouldRetryError checks if an error should trigger a retry
|
||||
func (d *PCloud) shouldRetryError(err error) bool {
|
||||
// For now, we'll retry on any error
|
||||
// In production, you'd want more specific error handling
|
||||
return true
|
||||
}
|
||||
|
||||
// Make authenticated API request
|
||||
func (d *PCloud) request(endpoint string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
|
||||
req := base.RestyClient.R()
|
||||
|
||||
// Add access token as query parameter (pCloud doesn't use Bearer auth)
|
||||
req.SetQueryParam("access_token", d.AccessToken)
|
||||
|
||||
if callback != nil {
|
||||
callback(req)
|
||||
}
|
||||
|
||||
if resp != nil {
|
||||
req.SetResult(resp)
|
||||
}
|
||||
|
||||
var res *resty.Response
|
||||
var err error
|
||||
|
||||
switch method {
|
||||
case http.MethodGet:
|
||||
res, err = req.Get(d.getAPIURL() + endpoint)
|
||||
case http.MethodPost:
|
||||
res, err = req.Post(d.getAPIURL() + endpoint)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported method: %s", method)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check for API errors with pCloud-specific logic
|
||||
if res.StatusCode() != 200 {
|
||||
var errResp ErrorResult
|
||||
if err := utils.Json.Unmarshal(res.Body(), &errResp); err == nil {
|
||||
// Check if this error should trigger a retry
|
||||
if d.shouldRetry(res.StatusCode(), &errResp) {
|
||||
return nil, fmt.Errorf("pCloud API error (retryable): %s (result: %d)", errResp.Error, errResp.Result)
|
||||
}
|
||||
return nil, fmt.Errorf("pCloud API error: %s (result: %d)", errResp.Error, errResp.Result)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP error: %d", res.StatusCode())
|
||||
}
|
||||
|
||||
return res.Body(), nil
|
||||
}
|
||||
|
||||
// List files in a folder
|
||||
func (d *PCloud) getFiles(folderID string) ([]FileObject, error) {
|
||||
var resp ItemResult
|
||||
_, err := d.requestWithRetry("/listfolder", http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParam("folderid", extractID(folderID))
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return nil, fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
if resp.Metadata == nil {
|
||||
return []FileObject{}, nil
|
||||
}
|
||||
|
||||
return resp.Metadata.Contents, nil
|
||||
}
|
||||
|
||||
// Get download link for a file
|
||||
func (d *PCloud) getDownloadLink(fileID string) (string, error) {
|
||||
var resp DownloadLinkResult
|
||||
_, err := d.requestWithRetry("/getfilelink", http.MethodGet, func(req *resty.Request) {
|
||||
req.SetQueryParam("fileid", extractID(fileID))
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return "", fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
if len(resp.Hosts) == 0 {
|
||||
return "", fmt.Errorf("no download hosts available")
|
||||
}
|
||||
|
||||
return "https://" + resp.Hosts[0] + resp.Path, nil
|
||||
}
|
||||
|
||||
// Create a folder
|
||||
func (d *PCloud) createFolder(parentID, name string) error {
|
||||
var resp ItemResult
|
||||
_, err := d.requestWithRetry("/createfolder", http.MethodPost, func(req *resty.Request) {
|
||||
req.SetFormData(map[string]string{
|
||||
"folderid": extractID(parentID),
|
||||
"name": name,
|
||||
})
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete a file or folder
|
||||
func (d *PCloud) delete(objID string, isFolder bool) error {
|
||||
endpoint := "/deletefile"
|
||||
paramName := "fileid"
|
||||
|
||||
if isFolder {
|
||||
endpoint = "/deletefolderrecursive"
|
||||
paramName = "folderid"
|
||||
}
|
||||
|
||||
var resp ItemResult
|
||||
_, err := d.requestWithRetry(endpoint, http.MethodPost, func(req *resty.Request) {
|
||||
req.SetFormData(map[string]string{
|
||||
paramName: extractID(objID),
|
||||
})
|
||||
}, &resp)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return fmt.Errorf("pCloud error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload a file using direct /uploadfile endpoint like rclone
|
||||
func (d *PCloud) uploadFile(ctx context.Context, file io.Reader, parentID, name string, size int64) error {
|
||||
// pCloud requires Content-Length, so we need to know the size
|
||||
if size <= 0 {
|
||||
return fmt.Errorf("file size must be provided for pCloud upload")
|
||||
}
|
||||
|
||||
// Upload directly to /uploadfile endpoint like rclone
|
||||
var resp ItemResult
|
||||
req := base.RestyClient.R().
|
||||
SetQueryParam("access_token", d.AccessToken).
|
||||
SetHeader("Content-Length", strconv.FormatInt(size, 10)).
|
||||
SetFileReader("content", name, file).
|
||||
SetFormData(map[string]string{
|
||||
"filename": name,
|
||||
"folderid": extractID(parentID),
|
||||
"nopartial": "1",
|
||||
})
|
||||
|
||||
// Use PUT method like rclone
|
||||
res, err := req.Put(d.getAPIURL() + "/uploadfile")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
if err := utils.Json.Unmarshal(res.Body(), &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.Result != 0 {
|
||||
return fmt.Errorf("pCloud upload error: result code %d", resp.Result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Reference in New Issue