pull/9339/merge
textrix 2025-09-23 17:28:58 +09:00 committed by GitHub
commit e45e256cb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 608 additions and 0 deletions

View File

@ -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"

189
drivers/pcloud/driver.go Normal file
View File

@ -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())
}

30
drivers/pcloud/meta.go Normal file
View File

@ -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{}
})
}

91
drivers/pcloud/types.go Normal file
View File

@ -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)
}

297
drivers/pcloud/util.go Normal file
View File

@ -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
}