├── config_example.yml ├── images └── build.gif ├── .gitignore ├── main.go ├── docs ├── config.md ├── command_zhCN.md ├── command_docker.md └── command.md ├── internal ├── utils │ ├── str.go │ ├── embed.go │ ├── embed_test.go │ ├── sync.go │ └── path.go └── pikpak │ ├── quota.go │ ├── refresh_token.go │ ├── url.go │ ├── sha.go │ ├── download.go │ ├── captcha_token.go │ ├── pikpak.go │ ├── folder.go │ ├── file.go │ └── upload.go ├── Dockerfile ├── cmd ├── new │ ├── new.go │ ├── folder │ │ └── folder.go │ ├── sha │ │ └── sha.go │ └── url │ │ └── url.go ├── quota │ └── quota.go ├── root.go ├── share │ └── share.go ├── list │ └── list.go ├── embed │ └── embed.go ├── upload │ └── upload.go └── download │ └── download.go ├── .github └── workflows │ ├── goreleaser.yml │ └── dockerhub.yml ├── LICENSE ├── go.mod ├── README_zhCN.md ├── .goreleaser.yaml ├── README.md ├── conf └── config.go └── go.sum /config_example.yml: -------------------------------------------------------------------------------- 1 | proxy: 2 | username: xxx 3 | password: xxx 4 | -------------------------------------------------------------------------------- /images/build.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/52funny/pikpakcli/HEAD/images/build.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .vscode 3 | .pikpaksync.txt 4 | config.yml 5 | pikpakcli 6 | dist 7 | dist/ 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/52funny/pikpakcli/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | ## Configuration 2 | 3 | ### Using Proxy 4 | 5 | Configuring the `proxy` field in `config.yml` 6 | 7 | ```yml 8 | proxy: http://host:port 9 | ``` 10 | 11 | > ⚠️⚠️⚠️ proxy must contain `://`. 12 | -------------------------------------------------------------------------------- /internal/utils/str.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "unsafe" 5 | ) 6 | 7 | func StringToByteSlice(str string) []byte { 8 | return unsafe.Slice(unsafe.StringData(str), len(str)) 9 | } 10 | 11 | func ByteSliceToString(bs []byte) string { 12 | return *(*string)(unsafe.Pointer(&bs)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/utils/embed.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | ) 7 | 8 | func GetEmbedBinName(name string) string { 9 | if len(name) == 0 { 10 | return "_embed" 11 | } 12 | base := filepath.Base(name) 13 | ext := filepath.Ext(base) 14 | fmt.Println(base, ext) 15 | if len(ext) > 0 { 16 | base = base[:len(base)-len(ext)] 17 | } 18 | return base + "_embed" + ext 19 | } 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21-alpine AS builder 2 | 3 | RUN apk add --no-cache git 4 | WORKDIR /src 5 | 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | COPY . . 10 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 11 | go build -ldflags "-s -w" -o /usr/local/bin/pikpakcli ./main.go 12 | 13 | FROM alpine:3.18 14 | RUN apk add --no-cache ca-certificates 15 | COPY --from=builder /usr/local/bin/pikpakcli /usr/local/bin/pikpakcli 16 | WORKDIR /root 17 | 18 | ENTRYPOINT ["/usr/local/bin/pikpakcli"] 19 | 20 | -------------------------------------------------------------------------------- /internal/utils/embed_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetEmbedBinName(t *testing.T) { 10 | exe := "pikpakcli.exe" 11 | exe2 := ".exe" 12 | assert.Equal(t, "pikpakcli_embed.exe", GetEmbedBinName(exe)) 13 | assert.Equal(t, "_embed.exe", GetEmbedBinName(exe2)) 14 | 15 | bin := "pikpakcli" 16 | bin2 := "" 17 | assert.Equal(t, "pikpakcli_embed", GetEmbedBinName(bin)) 18 | assert.Equal(t, "_embed", GetEmbedBinName(bin2)) 19 | 20 | } 21 | -------------------------------------------------------------------------------- /cmd/new/new.go: -------------------------------------------------------------------------------- 1 | package new 2 | 3 | import ( 4 | "github.com/52funny/pikpakcli/cmd/new/folder" 5 | "github.com/52funny/pikpakcli/cmd/new/sha" 6 | "github.com/52funny/pikpakcli/cmd/new/url" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var NewCommand = &cobra.Command{ 11 | Use: "new", 12 | Aliases: []string{"n"}, 13 | Short: `New can do something like create folder or other things`, 14 | Run: func(cmd *cobra.Command, args []string) { 15 | cmd.Help() 16 | }, 17 | } 18 | 19 | func init() { 20 | NewCommand.AddCommand(folder.NewFolderCommand) 21 | NewCommand.AddCommand(sha.NewShaCommand) 22 | NewCommand.AddCommand(url.NewUrlCommand) 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v6 23 | with: 24 | distribution: goreleaser 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 28 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout source 11 | uses: actions/checkout@v2 12 | 13 | - name: Docker meta 14 | id: docker_meta 15 | uses: docker/metadata-action@v5 16 | with: 17 | images: 52funny/pikpakcli 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v3 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login DockerHub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKER_USERNAME }} 29 | password: ${{ secrets.DOCKER_PASSWORD }} 30 | 31 | - name: Push Docker Hub 32 | uses: docker/build-push-action@v6 33 | with: 34 | push: true 35 | context: . 36 | platforms: linux/amd64,linux/arm64 37 | file: ./Dockerfile 38 | tags: ${{ steps.docker_meta.outputs.tags }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 52funny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/pikpak/quota.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "net/http" 5 | 6 | jsoniter "github.com/json-iterator/go" 7 | ) 8 | 9 | type quotaMessage struct { 10 | Kind string `json:"kind"` 11 | Quota Quota `json:"quota"` 12 | ExpiresAt string `json:"expires_at"` 13 | Quotas Quotas `json:"quotas"` 14 | } 15 | type Quota struct { 16 | Kind string `json:"kind"` 17 | Limit string `json:"limit"` 18 | Usage string `json:"usage"` 19 | UsageInTrash string `json:"usage_in_trash"` 20 | PlayTimesLimit string `json:"play_times_limit"` 21 | PlayTimesUsage string `json:"play_times_usage"` 22 | } 23 | 24 | type Quotas struct { 25 | } 26 | 27 | // get cloud quota 28 | func (p *PikPak) GetQuota() (Quota, error) { 29 | req, err := http.NewRequest("GET", "https://api-drive.mypikpak.com/drive/v1/about", nil) 30 | if err != nil { 31 | return Quota{}, err 32 | } 33 | bs, err := p.sendRequest(req) 34 | if err != nil { 35 | return Quota{}, err 36 | } 37 | var quotaMessage quotaMessage 38 | err = jsoniter.Unmarshal(bs, "aMessage) 39 | if err != nil { 40 | return Quota{}, err 41 | } 42 | return quotaMessage.Quota, nil 43 | } 44 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/52funny/pikpakcli 2 | 3 | go 1.21.3 4 | 5 | require ( 6 | github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c 7 | github.com/fatih/color v1.15.0 8 | github.com/json-iterator/go v1.1.12 9 | github.com/sirupsen/logrus v1.9.0 10 | github.com/spf13/cobra v1.6.1 11 | github.com/stretchr/testify v1.8.4 12 | github.com/tidwall/gjson v1.14.4 13 | github.com/vbauerster/mpb/v8 v8.7.2 14 | gopkg.in/yaml.v2 v2.4.0 15 | ) 16 | 17 | require ( 18 | github.com/VividCortex/ewma v1.2.0 // indirect 19 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 22 | github.com/mattn/go-colorable v0.1.13 // indirect 23 | github.com/mattn/go-isatty v0.0.17 // indirect 24 | github.com/mattn/go-runewidth v0.0.15 // indirect 25 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect 26 | github.com/modern-go/reflect2 v1.0.2 // indirect 27 | github.com/pmezard/go-difflib v1.0.0 // indirect 28 | github.com/rivo/uniseg v0.4.4 // indirect 29 | github.com/spf13/pflag v1.0.5 // indirect 30 | github.com/tidwall/match v1.1.1 // indirect 31 | github.com/tidwall/pretty v1.2.0 // indirect 32 | golang.org/x/sys v0.16.0 // indirect 33 | gopkg.in/yaml.v3 v3.0.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /README_zhCN.md: -------------------------------------------------------------------------------- 1 | # PikPak CLI 2 | 3 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/52funny/pikpakcli) 4 | ![GitHub](https://img.shields.io/github/license/52funny/pikpakcli) 5 | 6 | PikPakCli 是 PikPak 的命令行工具。 7 | 8 | ![Build from source code.](./images/build.gif) 9 | 10 | ## 安装方法 11 | 12 | ### 从源码编译 13 | 14 | 要从源代码构建该工具,请确保您的系统已安装 [Go](https://go.dev/doc/install) 环境。 15 | 16 | 克隆项目 17 | 18 | ```bash 19 | git clone https://github.com/52funny/pikpakcli 20 | ``` 21 | 22 | 生成可执行文件 23 | 24 | ```bash 25 | go build 26 | ``` 27 | 28 | 运行 29 | 30 | ```bash 31 | ./pikpakcli 32 | ``` 33 | 34 | ### 从 Release 下载 35 | 36 | 从 Release 下载你所需要的版本,然后运行。 37 | 38 | ## 配置文件 39 | 40 | 首先将项目中的 `config_example.yml` 配置一下,输入自己的账号密码 41 | 42 | 如果账号是手机号,手机号要以区号开头。如 `+861xxxxxxxxxx` 43 | 44 | 然后将其重命名为 `config.yml` 45 | 46 | 配置文件将会优先从当前目录进行读取 `config.yml`,如果当前目录下不存在 `config.yml` 将会从用户的配置数据的默认根目录进行读取,各个平台的默认根目录如下: 47 | 48 | - Linux: `$HOME/.config/pikpakcli` 49 | - Darwin: `$HOME/Library/Application Support/pikpakcli` 50 | - Windows: `%AppData%/pikpakcli` 51 | 52 | ## 开始 53 | 54 | 之后你就可以运行 `ls` 指令来查看存储在 **PikPak** 上的文件了 55 | 56 | ```bash 57 | ./pikpakcli ls 58 | ``` 59 | 60 | ## 用法 61 | 62 | 参阅 [Command](docs/command_zhCN.md) 查看更多的指令 63 | 64 | ## 贡献者 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | 26 | archives: 27 | - format: tar.gz 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | format: zip 40 | files: 41 | - config_example.yml 42 | 43 | changelog: 44 | sort: asc 45 | filters: 46 | exclude: 47 | - "^docs:" 48 | - "^test:" 49 | -------------------------------------------------------------------------------- /internal/pikpak/refresh_token.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | 8 | jsoniter "github.com/json-iterator/go" 9 | "github.com/sirupsen/logrus" 10 | "github.com/tidwall/gjson" 11 | ) 12 | 13 | func (p *PikPak) RefreshToken() error { 14 | url := "https://user.mypikpak.com/v1/auth/token" 15 | m := map[string]string{ 16 | "client_id": clientID, 17 | "client_secret": clientSecret, 18 | "grant_type": "refresh_token", 19 | "refresh_token": p.refreshToken, 20 | } 21 | bs, err := jsoniter.Marshal(&m) 22 | if err != nil { 23 | return err 24 | } 25 | req, err := http.NewRequest("POST", url, bytes.NewBuffer(bs)) 26 | if err != nil { 27 | return err 28 | } 29 | bs, err = p.sendRequest(req) 30 | if err != nil { 31 | return err 32 | } 33 | error_code := gjson.GetBytes(bs, "error_code").Int() 34 | if error_code != 0 { 35 | // refresh token failed 36 | if error_code == 4126 { 37 | // 重新登录 38 | return p.Login() 39 | } 40 | return fmt.Errorf("refresh token error message: %d", gjson.GetBytes(bs, "error").Int()) 41 | } 42 | // logrus.Debug("refresh: ", string(bs)) 43 | p.JwtToken = gjson.GetBytes(bs, "access_token").String() 44 | p.refreshToken = gjson.GetBytes(bs, "refresh_token").String() 45 | p.RefreshSecond = gjson.GetBytes(bs, "expires_in").Int() 46 | logrus.Debugf("RefreshToken access_token: %s refresh_token: %s\n", p.JwtToken, p.refreshToken) 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /cmd/new/folder/folder.go: -------------------------------------------------------------------------------- 1 | package folder 2 | 3 | import ( 4 | "github.com/52funny/pikpakcli/conf" 5 | "github.com/52funny/pikpakcli/internal/pikpak" 6 | "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var NewFolderCommand = &cobra.Command{ 11 | Use: "folder", 12 | Short: `Create a folder to pikpak server`, 13 | Run: func(cmd *cobra.Command, args []string) { 14 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 15 | err := p.Login() 16 | if err != nil { 17 | logrus.Errorln("Login Failed:", err) 18 | } 19 | if len(args) > 0 { 20 | handleNewFolder(&p, args) 21 | } else { 22 | logrus.Errorln("Please input the folder name") 23 | } 24 | }, 25 | } 26 | 27 | var path string 28 | var parentId string 29 | 30 | func init() { 31 | NewFolderCommand.Flags().StringVarP(&path, "path", "p", "/", "The path of the folder") 32 | NewFolderCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "The parent id") 33 | } 34 | 35 | // new folder 36 | func handleNewFolder(p *pikpak.PikPak, folders []string) { 37 | var err error 38 | if parentId == "" { 39 | parentId, err = p.GetPathFolderId(path) 40 | if err != nil { 41 | logrus.Errorf("Get parent id failed: %s\n", err) 42 | return 43 | } 44 | } 45 | 46 | for _, folder := range folders { 47 | _, err := p.CreateFolder(parentId, folder) 48 | if err != nil { 49 | logrus.Errorf("Create folder %s failed: %s\n", folder, err) 50 | } else { 51 | logrus.Infof("Create folder %s success\n", folder) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/quota/quota.go: -------------------------------------------------------------------------------- 1 | package quota 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/52funny/pikpakcli/conf" 8 | "github.com/52funny/pikpakcli/internal/pikpak" 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var human bool 14 | 15 | var QuotaCmd = &cobra.Command{ 16 | Use: "quota", 17 | Short: `Get the quota for the pikpak cloud`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 20 | err := p.Login() 21 | if err != nil { 22 | logrus.Errorln("Login Failed:", err) 23 | } 24 | q, err := p.GetQuota() 25 | if err != nil { 26 | logrus.Errorln("get cloud quota error:", err) 27 | return 28 | } 29 | fmt.Printf("%-20s%-20s\n", "total", "used") 30 | switch human { 31 | case true: 32 | fmt.Printf("%-20s%-20s\n", displayStorage(q.Limit), displayStorage(q.Usage)) 33 | case false: 34 | fmt.Printf("%-20s%-20s\n", q.Limit, q.Usage) 35 | } 36 | }, 37 | } 38 | 39 | func init() { 40 | QuotaCmd.Flags().BoolVarP(&human, "human", "H", false, "display human readable format") 41 | } 42 | func displayStorage(s string) string { 43 | size, _ := strconv.ParseFloat(s, 64) 44 | cnt := 0 45 | for size > 1024 { 46 | cnt += 1 47 | if cnt > 5 { 48 | break 49 | } 50 | size /= 1024 51 | } 52 | // res := strconv.Itoa(int(size)) 53 | res := strconv.FormatFloat(size, 'g', 2, 64) 54 | switch cnt { 55 | case 0: 56 | res += "B" 57 | case 1: 58 | res += "KB" 59 | case 2: 60 | res += "MB" 61 | case 3: 62 | res += "GB" 63 | case 4: 64 | res += "TB" 65 | case 5: 66 | res += "PB" 67 | } 68 | return res 69 | } 70 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/52funny/pikpakcli/cmd/download" 7 | "github.com/52funny/pikpakcli/cmd/embed" 8 | "github.com/52funny/pikpakcli/cmd/list" 9 | "github.com/52funny/pikpakcli/cmd/new" 10 | "github.com/52funny/pikpakcli/cmd/quota" 11 | "github.com/52funny/pikpakcli/cmd/share" 12 | "github.com/52funny/pikpakcli/cmd/upload" 13 | 14 | "github.com/52funny/pikpakcli/conf" 15 | "github.com/sirupsen/logrus" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | var rootCmd = &cobra.Command{ 20 | Use: "pikpakcli", 21 | Short: "Pikpakcli is a command line interface for Pikpak", 22 | Run: func(cmd *cobra.Command, args []string) { 23 | cmd.Help() 24 | }, 25 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 26 | err := conf.InitConfig(configPath) 27 | if err != nil { 28 | logrus.Errorln(err) 29 | os.Exit(1) 30 | } 31 | if debug { 32 | logrus.SetLevel(logrus.DebugLevel) 33 | } 34 | }, 35 | } 36 | 37 | // Config path 38 | var configPath string 39 | 40 | // Debug mode 41 | var debug bool 42 | 43 | // Initialize the command line interface 44 | func init() { 45 | // debug 46 | rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "debug mode") 47 | // config 48 | rootCmd.PersistentFlags().StringVar(&configPath, "config", "config.yml", "config file path") 49 | rootCmd.AddCommand(upload.UploadCmd) 50 | rootCmd.AddCommand(download.DownloadCmd) 51 | rootCmd.AddCommand(share.ShareCommand) 52 | rootCmd.AddCommand(new.NewCommand) 53 | rootCmd.AddCommand(embed.EmbedCmd) 54 | rootCmd.AddCommand(quota.QuotaCmd) 55 | rootCmd.AddCommand(list.ListCmd) 56 | } 57 | 58 | // Execute the command line interface 59 | func Execute() { 60 | if err := rootCmd.Execute(); err != nil { 61 | logrus.Errorln(err) 62 | os.Exit(1) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/pikpak/url.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | 8 | jsoniter "github.com/json-iterator/go" 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func (p *PikPak) CreateUrlFile(parentId, url string) error { 13 | m := map[string]interface{}{ 14 | "kind": "drive#file", 15 | "upload_type": "UPLOAD_TYPE_URL", 16 | "url": map[string]string{ 17 | "url": url, 18 | }, 19 | } 20 | if parentId != "" { 21 | m["parent_id"] = parentId 22 | } 23 | bs, err := jsoniter.Marshal(&m) 24 | if err != nil { 25 | return err 26 | } 27 | START: 28 | req, err := http.NewRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs)) 29 | if err != nil { 30 | return err 31 | } 32 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 33 | req.Header.Set("Product_flavor_name", "cha") 34 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 35 | req.Header.Set("X-Client-Version-Code", "10083") 36 | req.Header.Set("X-Peer-Id", p.DeviceId) 37 | req.Header.Set("X-User-Region", "1") 38 | req.Header.Set("X-Alt-Capability", "3") 39 | req.Header.Set("Country", "CN") 40 | bs, err = p.sendRequest(req) 41 | if err != nil { 42 | return err 43 | } 44 | error_code := jsoniter.Get(bs, "error_code").ToInt() 45 | if error_code != 0 { 46 | if error_code == 9 { 47 | err := p.AuthCaptchaToken("POST:/drive/v1/files") 48 | if err != nil { 49 | return err 50 | } 51 | goto START 52 | } 53 | return fmt.Errorf("upload file error: %s", jsoniter.Get(bs, "error").ToString()) 54 | } 55 | 56 | task := jsoniter.Get(bs, "task") 57 | logrus.Debug(task.ToString()) 58 | // phase := task.Get("phase").ToString() 59 | // if phase == "PHASE_TYPE_COMPLETE" { 60 | // return nil 61 | // } else { 62 | // return fmt.Errorf("create file error: %s", phase) 63 | // } 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /internal/pikpak/sha.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | 8 | jsoniter "github.com/json-iterator/go" 9 | ) 10 | 11 | func (p *PikPak) CreateShaFile(parentId, fileName, size, sha string) error { 12 | m := map[string]interface{}{ 13 | "body": map[string]string{ 14 | "duration": "", 15 | "width": "", 16 | "height": "", 17 | }, 18 | "kind": "drive#file", 19 | "name": fileName, 20 | "size": size, 21 | "hash": sha, 22 | "upload_type": "UPLOAD_TYPE_RESUMABLE", 23 | "objProvider": map[string]string{ 24 | "provider": "UPLOAD_TYPE_UNKNOWN", 25 | }, 26 | } 27 | if parentId != "" { 28 | m["parent_id"] = parentId 29 | } 30 | bs, err := jsoniter.Marshal(&m) 31 | if err != nil { 32 | return err 33 | } 34 | START: 35 | req, err := http.NewRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs)) 36 | if err != nil { 37 | return err 38 | } 39 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 40 | req.Header.Set("Product_flavor_name", "cha") 41 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 42 | req.Header.Set("X-Client-Version-Code", "10083") 43 | req.Header.Set("X-Peer-Id", p.DeviceId) 44 | req.Header.Set("X-User-Region", "1") 45 | req.Header.Set("X-Alt-Capability", "3") 46 | req.Header.Set("Country", "CN") 47 | bs, err = p.sendRequest(req) 48 | if err != nil { 49 | return err 50 | } 51 | error_code := jsoniter.Get(bs, "error_code").ToInt() 52 | if error_code != 0 { 53 | if error_code == 9 { 54 | err := p.AuthCaptchaToken("POST:/drive/v1/files") 55 | if err != nil { 56 | return err 57 | } 58 | goto START 59 | } 60 | return fmt.Errorf("upload file error: %s", jsoniter.Get(bs, "error").ToString()) 61 | } 62 | file := jsoniter.Get(bs, "file") 63 | phase := file.Get("phase").ToString() 64 | if phase == "PHASE_TYPE_COMPLETE" { 65 | return nil 66 | } else { 67 | return fmt.Errorf("create file error: %s", phase) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/pikpak/download.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/vbauerster/mpb/v8" 13 | ) 14 | 15 | // Download file 16 | func (f *File) Download(path string, bar *mpb.Bar) error { 17 | outFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 18 | if err != nil { 19 | return err 20 | } 21 | defer outFile.Close() 22 | info, err := outFile.Stat() 23 | if err != nil { 24 | return err 25 | } 26 | size := info.Size() 27 | resume := size != 0 28 | req, err := http.NewRequest("GET", f.Links.ApplicationOctetStream.URL, nil) 29 | if err != nil { 30 | return err 31 | } 32 | req.Header.Set("User-Agent", userAgent) 33 | if resume { 34 | if bar != nil { 35 | bar.SetCurrent(size) 36 | } 37 | req.Header.Set("Range", fmt.Sprintf("bytes=%d-", size)) 38 | } 39 | resp, err := http.DefaultClient.Do(req) 40 | if err != nil { 41 | return err 42 | } 43 | // defer resp.Body.Close() 44 | if resume && resp.StatusCode != http.StatusPartialContent { 45 | logrus.Warnf("Resume file %s failed: Server doesn't support, restarting from the beginning", f.Name) 46 | // try re-opening the file with contents truncated 47 | outFile.Close() 48 | outFile, err = os.Create(path) 49 | if err != nil { 50 | return err 51 | } 52 | } 53 | contentLength, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) 54 | if err != nil { 55 | return errors.New("transmute content length to int64 failed") 56 | } 57 | var proxyReader io.ReadCloser 58 | if bar != nil { 59 | proxyReader = bar.ProxyReader(resp.Body) 60 | } else { 61 | proxyReader = resp.Body 62 | } 63 | defer proxyReader.Close() 64 | 65 | // using 128K buffer to accelerate the download 66 | buf := make([]byte, 1024*128) 67 | 68 | written, err := io.CopyBuffer(outFile, proxyReader, buf) 69 | if err != nil { 70 | return err 71 | } 72 | if contentLength != written { 73 | return errors.New("content length not equal to written") 74 | } 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /docs/command_zhCN.md: -------------------------------------------------------------------------------- 1 | # 命令使用方法 2 | 3 | ## 上传 4 | 5 | - 将本地目录下的所有文件上传至 Movies 文件夹内 6 | 7 | ```bash 8 | pikpakcli upload -p Movies . 9 | ``` 10 | 11 | - 将本地目录下除了后缀名为`mp3`, `jpg`的文件上传至 Movies 文件夹内 12 | 13 | ```bash 14 | pikpakcli upload -e .mp3,.jpg -p Movies . 15 | ``` 16 | 17 | - 指定上传的协程数目(默认为 16) 18 | 19 | ```bash 20 | pikpakcli -c 20 -p Movies . 21 | ``` 22 | 23 | - 使用 `-P` 标志设置 Pikpak 云上文件夹的 `id` 24 | 25 | ```bash 26 | pikpakcli upload -P AgmoDVmJPYbHn8ito1 . 27 | ``` 28 | 29 | ## 下载 30 | 31 | - 下载特定目录(如:`Movies` )下的所有文件 32 | 33 | ```bash 34 | pikpakcli download -p Movies 35 | ``` 36 | 37 | - 下载单个文件 38 | 39 | ```bash 40 | pikpakcli download -p Movies Peppa_Pig.mp4 41 | # or 42 | pikpakcli download Movies/Peppa_Pig.mp4 43 | ``` 44 | 45 | - 限制同时下载的文件个数 (默认: 3) 46 | 47 | ```bash 48 | pikpakcli download -c 5 -p Movies 49 | ``` 50 | 51 | - 指定下载文件的输出目录 52 | 53 | ```bash 54 | pikpakcli download -p Movies -o Film 55 | ``` 56 | 57 | - 使用 `-g` 标志显示下载过程中的状态信息 58 | ```bash 59 | pikpakcli download -p Movies -o Film -g 60 | ``` 61 | 62 | ## 分享 63 | 64 | - 分享 Movies 下的所有文件的链接 65 | 66 | ```bash 67 | pikpakcli share -p Movies 68 | ``` 69 | 70 | - 分享指定文件的链接 71 | 72 | ```bash 73 | pikpakcli share Movies/Peppa_Pig.mp4 74 | ``` 75 | 76 | - 分享链接输出到指定文件 77 | 78 | ```bash 79 | pikpakcli share --out sha.txt -p Movies 80 | ``` 81 | 82 | ## 新建 83 | 84 | ### 新建文件夹 85 | 86 | - 在 Movies 下新建文件夹 NewFolder 87 | 88 | ```bash 89 | pikpakcli new folder -p Movies NewFolder 90 | ``` 91 | 92 | ### 新建 Sha 文件 93 | 94 | - 在 Movies 下新建 Sha 文件 95 | 96 | ```bash 97 | pikpakcli new sha -p /Movies 'PikPak://美国队长.mkv|22809693754|75BFE33237A0C06C725587F87981C567E4E478C3' 98 | ``` 99 | 100 | ### 新建磁力 101 | 102 | - 新建磁力文件 103 | 104 | ```bash 105 | pikpakcli new url 'magnet:?xt=urn:btih:e9c98e3ed488611abc169a81d8a21487fd1d0732' 106 | ``` 107 | 108 | ## 配额 109 | 110 | - 获取 PikPak 云盘的空间 111 | 112 | ```bash 113 | pikpakcli quota -H 114 | ``` 115 | 116 | ## 获取目录信息 117 | 118 | - 获取根目录下面的所有文件信息 119 | 120 | ```bash 121 | pikpakcli ls -lH -p / 122 | ``` 123 | -------------------------------------------------------------------------------- /internal/utils/sync.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "slices" 8 | "strings" 9 | "unsafe" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | var ErrSyncTxtNotEnable = errors.New("sync txt is not enable") 15 | 16 | type SyncTxt struct { 17 | Enable bool 18 | FileName string 19 | alreadySynced []string 20 | f *os.File 21 | } 22 | 23 | func NewSyncTxt(fileName string, enable bool) (sync *SyncTxt, err error) { 24 | var f *os.File = nil 25 | var alreadySynced []string 26 | if enable { 27 | f, err = os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0666) 28 | if err != nil { 29 | return nil, err 30 | } 31 | bs, err := io.ReadAll(f) 32 | if err != nil { 33 | logrus.Error("read file error: ", err) 34 | os.Exit(1) 35 | } 36 | // avoid end with "\n" 37 | alreadySynced = strings.Split( 38 | strings.TrimRight(unsafe.String(unsafe.SliceData(bs), len(bs)), "\n"), 39 | "\n", 40 | ) 41 | } 42 | return &SyncTxt{ 43 | Enable: enable, 44 | FileName: fileName, 45 | f: f, 46 | alreadySynced: alreadySynced, 47 | }, nil 48 | } 49 | 50 | // impl Writer 51 | func (s *SyncTxt) Write(b []byte) (n int, err error) { 52 | if !s.Enable { 53 | return 0, ErrSyncTxtNotEnable 54 | } 55 | if b[len(b)-1] != '\n' { 56 | b = append(b, '\n') 57 | } 58 | // add to alreadySynced 59 | s.alreadySynced = append(s.alreadySynced, strings.TrimRight(string(b), "\n")) 60 | return s.f.Write(b) 61 | } 62 | 63 | // impl Closer 64 | func (s *SyncTxt) Close() error { 65 | if !s.Enable { 66 | return ErrSyncTxtNotEnable 67 | } 68 | return s.f.Close() 69 | } 70 | 71 | // impl StringWriter 72 | func (s *SyncTxt) WriteString(str string) (n int, err error) { 73 | if !s.Enable { 74 | return 0, ErrSyncTxtNotEnable 75 | } 76 | if str[len(str)-1] != '\n' { 77 | str += "\n" 78 | } 79 | // add to alreadySynced 80 | s.alreadySynced = append(s.alreadySynced, strings.TrimRight(str, "\n")) 81 | return s.f.WriteString(str) 82 | } 83 | 84 | func (s *SyncTxt) UnSync(files []string) []string { 85 | if !s.Enable { 86 | return files 87 | } 88 | res := make([]string, 0) 89 | for _, f := range files { 90 | if slices.Contains(s.alreadySynced, f) { 91 | continue 92 | } 93 | res = append(res, f) 94 | } 95 | return res 96 | } 97 | -------------------------------------------------------------------------------- /cmd/new/sha/sha.go: -------------------------------------------------------------------------------- 1 | package sha 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/52funny/pikpakcli/conf" 10 | "github.com/52funny/pikpakcli/internal/pikpak" 11 | "github.com/sirupsen/logrus" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var NewShaCommand = &cobra.Command{ 16 | Use: "sha", 17 | Short: `Create a file according to sha`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 20 | err := p.Login() 21 | if err != nil { 22 | logrus.Errorln("Login Failed:", err) 23 | } 24 | // input mode 25 | if strings.TrimSpace(input) != "" { 26 | f, err := os.OpenFile(input, os.O_RDONLY, 0666) 27 | if err != nil { 28 | logrus.Errorf("Open file %s failed: %v\n", input, err) 29 | return 30 | } 31 | reader := bufio.NewReader(f) 32 | shas := make([]string, 0) 33 | for { 34 | lineBytes, _, err := reader.ReadLine() 35 | if err == io.EOF { 36 | break 37 | } 38 | shas = append(shas, string(lineBytes)) 39 | } 40 | handleNewSha(&p, shas) 41 | return 42 | } 43 | 44 | // args mode 45 | if len(args) > 0 { 46 | handleNewSha(&p, args) 47 | } else { 48 | logrus.Errorln("Please input the folder name") 49 | } 50 | }, 51 | } 52 | 53 | var path string 54 | 55 | var parentId string 56 | 57 | var input string 58 | 59 | func init() { 60 | NewShaCommand.Flags().StringVarP(&path, "path", "p", "/", "The path of the folder") 61 | NewShaCommand.Flags().StringVarP(&input, "input", "i", "", "The input of the sha file") 62 | NewShaCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "The parent id") 63 | } 64 | 65 | // new folder 66 | func handleNewSha(p *pikpak.PikPak, shas []string) { 67 | var err error 68 | if parentId == "" { 69 | parentId, err = p.GetPathFolderId(path) 70 | if err != nil { 71 | logrus.Errorf("Get parent id failed: %s\n", err) 72 | return 73 | } 74 | } 75 | 76 | for _, sha := range shas { 77 | sha = sha[strings.Index(sha, "://")+3:] 78 | shaElements := strings.Split(sha, "|") 79 | if len(shaElements) != 3 { 80 | logrus.Errorln("The sha format is wrong: ", sha) 81 | continue 82 | } 83 | name, size, sha := shaElements[0], shaElements[1], shaElements[2] 84 | err := p.CreateShaFile(parentId, name, size, sha) 85 | if err != nil { 86 | logrus.Errorln("Create sha file failed: ", err) 87 | continue 88 | } 89 | logrus.Infoln("Create sha file success: ", name) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /docs/command_docker.md: -------------------------------------------------------------------------------- 1 | # Docker Command Usage 2 | 3 | For docker users, the most different part is linking the configuration file (i.e., `config.yml`) and folder you want to operate (e.g., `download` or `upload`) into the container. 4 | 5 | ## Upload 6 | 7 | - Uploads all files in the local directory (e.g., `/path/to/upload`) to the `Movies` folder. 8 | 9 | ```bash 10 | # original cli: pikpakcli upload -p Movies . 11 | # Docker cli 12 | docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/upload/:/upload pikpakcli:latest upload -p Movies /upload 13 | ``` 14 | 15 | - Upload files in local directory except for `mp3`, `jpg` to Movies folder. 16 | 17 | ```bash 18 | # original cli: pikpakcli upload -e .mp3,.jpg -p Movies . 19 | # Docker cli 20 | docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/upload/:/upload pikpakcli:latest upload -e .mp3,.jpg -p Movies /upload 21 | ``` 22 | 23 | ## Download 24 | 25 | - Download all files in a specific directory (e.g. `Movies`). 26 | 27 | ```bash 28 | # original cli: pikpakcli download -p Movies 29 | # Docker cli 30 | # the option -o is used to specify the folder in container to save downloaded files 31 | docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/download/:/download pikpakcli:latest download -p Movies -o /download 32 | ``` 33 | 34 | - Downloading a single file (e.g., `Movies/Peppa_Pig.mp4`). 35 | 36 | ```bash 37 | # original cli: pikpakcli download -p Movies Peppa_Pig.mp4 38 | # Docker cli 39 | docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/download/:/download pikpakcli:latest download -p Movies Peppa_Pig.mp4 -o /download 40 | ``` 41 | 42 | 43 | > Other download commands are omitted here, please refer to the original cli commands in [Command Usage](docs/command.md). 44 | 45 | 46 | ## Wrapper Script 47 | 48 | We provide a wrapper script `docker_cli.sh` to simplify the docker command usage. You can run the script directly after setting up the `config.yml` file in the current directory. The script will create two folders `pikpak_downloads` and `pikpak_uploads` in the current directory for download and upload operations respectively. 49 | 50 | ```bash 51 | # Make the script executable 52 | chmod +x docker_cli.sh 53 | # Run the script for upload 54 | ./docker_cli.sh upload -p Movies ./pikpak_uploads 55 | # Run the script for download 56 | ./docker_cli.sh download -p Movies -o ./pikpak_downloads 57 | ``` -------------------------------------------------------------------------------- /internal/utils/path.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io/fs" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "slices" 9 | "strings" 10 | ) 11 | 12 | func Contains(alreadySyncFiles []string, f string) bool { 13 | return slices.Contains(alreadySyncFiles, f) 14 | } 15 | 16 | func SplitSeparator(path string) []string { 17 | if path == "" { 18 | return []string{} 19 | } 20 | return strings.Split(path, string(filepath.Separator)) 21 | } 22 | 23 | func Slash(path string) string { 24 | // clean path 25 | path = filepath.Clean(path) 26 | if path == "" { 27 | return "" 28 | } 29 | if path[0] == filepath.Separator { 30 | return path[1:] 31 | } 32 | return path 33 | } 34 | 35 | // 获取目录文件夹下的所有文件路径名 36 | func GetUploadFilePath(basePath string, defaultRegexp []*regexp.Regexp) ([]string, error) { 37 | rawPath := make([]string, 0) 38 | err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, err error) error { 39 | if err != nil { 40 | return err 41 | } 42 | // match regexp 43 | // if matched, then skip 44 | // else append 45 | matchRegexp := func(name string) bool { 46 | for _, r := range defaultRegexp { 47 | if r.MatchString(name) { 48 | return true 49 | } 50 | } 51 | return false 52 | } 53 | 54 | if matchRegexp(d.Name()) { 55 | if d.IsDir() { 56 | return filepath.SkipDir 57 | } 58 | return nil 59 | } 60 | 61 | // skip dir 62 | if d.IsDir() { 63 | return nil 64 | } 65 | // get relative path 66 | refPath, err := filepath.Rel(basePath, path) 67 | if err != nil { 68 | return err 69 | } 70 | // append to rawPath 71 | rawPath = append(rawPath, refPath) 72 | return nil 73 | }) 74 | return rawPath, err 75 | } 76 | 77 | // 检查路径是否存在 78 | func Exists(path string) (bool, error) { 79 | _, err := os.Stat(path) 80 | if err == nil { 81 | return true, nil 82 | } 83 | if os.IsNotExist(err) { 84 | return false, nil 85 | } 86 | return false, err 87 | } 88 | 89 | // 不存在目录就创建 90 | func CreateDirIfNotExist(path string) error { 91 | exist, err := Exists(path) 92 | if err != nil { 93 | return err 94 | } 95 | if !exist { 96 | err := os.MkdirAll(path, os.ModePerm) 97 | if err != nil { 98 | return err 99 | } 100 | } 101 | return nil 102 | } 103 | 104 | // 创建空文件 105 | func TouchFile(path string) error { 106 | exist, err := Exists(path) 107 | if err != nil { 108 | return err 109 | } 110 | if !exist { 111 | f, err := os.Create(path) 112 | if err != nil { 113 | return err 114 | } 115 | f.Close() 116 | } 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /internal/pikpak/captcha_token.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | jsoniter "github.com/json-iterator/go" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | const package_name = `com.pikcloud.pikpak` 15 | const client_version = `1.21.0` 16 | const md5_obj = `[{"alg":"md5","salt":""},{"alg":"md5","salt":"E32cSkYXC2bciKJGxRsE8ZgwmH\/YwkvpD6\/O9guSOa2irCwciH4xPHaH"},{"alg":"md5","salt":"QtqgfMgHP2TFl"},{"alg":"md5","salt":"zOKgHT56L7nIzFzDpUGhpWFrgP53m3G6ML"},{"alg":"md5","salt":"S"},{"alg":"md5","salt":"THxpsktzfFXizUv7DK1y\/N7NZ1WhayViluBEvAJJ8bA1Wr6"},{"alg":"md5","salt":"y9PXH3xGUhG\/zQI8CaapRw2LhldCaFM9CRlKpZXJvj+pifu"},{"alg":"md5","salt":"+RaaG7T8FRTI4cP019N5y9ofLyHE9ySFUr"},{"alg":"md5","salt":"6Pf1l8UTeuzYldGtb\/d"}]` 17 | 18 | type md5Obj struct { 19 | Alg string `json:"alg"` 20 | Salt string `json:"salt"` 21 | } 22 | 23 | var md5Arr []md5Obj 24 | 25 | func init() { 26 | err := jsoniter.Unmarshal([]byte(md5_obj), &md5Arr) 27 | if err != nil { 28 | logrus.Warn(err) 29 | } 30 | } 31 | 32 | func (p *PikPak) AuthCaptchaToken(action string) error { 33 | m := make(map[string]interface{}) 34 | m["action"] = action 35 | m["captcha_token"] = p.CaptchaToken 36 | m["client_id"] = clientID 37 | m["device_id"] = p.DeviceId 38 | ts := fmt.Sprintf("%d", time.Now().UnixMilli()) 39 | str := clientID + client_version + package_name + p.DeviceId + ts 40 | 41 | for i := 0; i < len(md5Arr); i++ { 42 | alg := md5Arr[i].Alg 43 | salt := md5Arr[i].Salt 44 | if alg == "md5" { 45 | str = fmt.Sprintf("%x", md5.Sum([]byte(str+salt))) 46 | } 47 | } 48 | // logrus.Debug("captcha_sign: ", "1."+str) 49 | m["meta"] = map[string]string{ 50 | "captcha_sign": "1." + str, 51 | "user_id": p.Sub, 52 | "package_name": package_name, 53 | "client_version": client_version, 54 | "timestamp": ts, 55 | } 56 | m["redirect_uri"] = "ttps://api.mypikpak.com/v1/auth/callback" 57 | bs, err := jsoniter.Marshal(m) 58 | if err != nil { 59 | return err 60 | } 61 | req, err := http.NewRequest("POST", "https://user.mypikpak.com/v1/shield/captcha/init?client_id="+clientID, bytes.NewBuffer(bs)) 62 | if err != nil { 63 | return err 64 | } 65 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 66 | bs, err = p.sendRequest(req) 67 | if err != nil { 68 | return err 69 | } 70 | error_code := jsoniter.Get(bs, "error_code").ToInt() 71 | if error_code != 0 { 72 | return fmt.Errorf("auth captcha token error: %s", jsoniter.Get(bs, "error").ToString()) 73 | } 74 | p.CaptchaToken = jsoniter.Get(bs, "captcha_token").ToString() 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PikPak CLI 2 | 3 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/52funny/pikpakcli) 4 | ![GitHub](https://img.shields.io/github/license/52funny/pikpakcli) 5 | 6 | English | [简体中文](https://github.com/52funny/pikpakcli/blob/master/README_zhCN.md) 7 | 8 | PikPakCli is a command line tool for Pikpak Cloud. 9 | 10 | ![Build from source code.](./images/build.gif) 11 | 12 | ## Installation 13 | 14 | ### Compiling from source code 15 | 16 | To build the tool from the source code, ensure you have [Go](https://go.dev/doc/install) installed on your system. 17 | 18 | Clone the project: 19 | 20 | ```bash 21 | git clone https://github.com/52funny/pikpakcli 22 | ``` 23 | 24 | Build the project: 25 | 26 | ```bash 27 | go build 28 | ``` 29 | 30 | Run the tool: 31 | 32 | ``` 33 | ./pikpakcli 34 | ``` 35 | 36 | ### Build with Docker 37 | You can also run `pikpakcli` using Docker. 38 | Pull the Docker image: 39 | 40 | ```bash 41 | docker pull 52funny/pikpakcli:master 42 | ``` 43 | 44 | Run the tool: 45 | ```bash 46 | docker run --rm 52funny/pikpakcli:master --help 47 | ``` 48 | 49 | ### Download from Release 50 | 51 | Download the executable file you need from the [Releases](https://github.com/52funny/pikpakcli/releases) page, then run it. 52 | 53 | ## Configuration 54 | 55 | First, configure the `config_example.yml` file in the project, entering your account details. 56 | 57 | If your account uses a phone number, it must be preceded by the country code, like `+861xxxxxxxxxx`. 58 | 59 | Then, rename it to `config.yml`. 60 | 61 | The configuration file will first be read from the current directory (`config.yml`). If it doesn't exist there, it will be read from the user's default configuration directory. The default root directories for each platform are: 62 | 63 | - Linux: `$HOME/.config/pikpakcli` 64 | - Darwin: `$HOME/Library/Application Support/pikpakcli` 65 | - Windows: `%AppData%/pikpakcli` 66 | 67 | > **For Docker Users:** You need to mount the configuration file into the Docker container. For example, if your `config.yml` is located at `/path/to/your/config.yml`, you can run the Docker container like this: 68 | 69 | ```bash 70 | docker run -v /path/to/your/config.yml:/root/.config/pikpakcli/config.yml pikpakcli:latest ls 71 | # if your config.yml is in the project directory, you can just run: 72 | docker run -v $PWD/config.yml:/root/.config/pikpakcli/config.yml pikpakcli:latest ls 73 | ``` 74 | 75 | ## Get started 76 | 77 | After that you can run the `ls` command to see the files stored on **PikPak**. 78 | 79 | ```bash 80 | ./pikpakcli ls 81 | ``` 82 | 83 | ## Usage 84 | 85 | See [Command](docs/command.md) for more commands information. 86 | 87 | ## Contributors 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /docs/command.md: -------------------------------------------------------------------------------- 1 | # Command Usage 2 | 3 | > For docker users, please refer to the [Docker Command Usage](docs/command_docker.md). 4 | 5 | ## Upload 6 | 7 | - Uploads all files in the local directory to the Movies folder. 8 | 9 | ```bash 10 | pikpakcli upload -p Movies . 11 | ``` 12 | 13 | - Upload files in local directory except for `mp3`, `jpg` to Movies folder. 14 | 15 | ```bash 16 | pikpakcli upload -e .mp3,.jpg -p Movies . 17 | ``` 18 | 19 | - Select the number of concurrent tasks for the upload (default is 16). 20 | 21 | ```bash 22 | pikpakcli -c 20 -p Movies . 23 | ``` 24 | 25 | - Use the `-P` flag to set the `id` of the folder on the Pikpak cloud. 26 | 27 | ```bash 28 | pikpakcli upload -P AgmoDVmJPYbHn8ito1 . 29 | ``` 30 | 31 | ## Download 32 | 33 | - Download all files in a specific directory (e.g. `Movies`). 34 | 35 | ```bash 36 | pikpakcli download -p Movies 37 | ``` 38 | 39 | - Downloading a single file. 40 | 41 | ```bash 42 | pikpakcli download -p Movies Peppa_Pig.mp4 43 | # or 44 | pikpakcli download Movies/Peppa_Pig.mp4 45 | ``` 46 | 47 | - Limit the number of files that can be downloaded at the same time (default: 1) 48 | 49 | ```bash 50 | pikpakcli download -c 5 -p Movies 51 | ``` 52 | 53 | - Specify the output directory of the downloaded file. 54 | 55 | ```bash 56 | pikpakcli download -p Movies -o Film 57 | ``` 58 | 59 | - Use the `-g` flag to display status information during the download process. 60 | 61 | ```bash 62 | pikpakcli download -p Movies -o Film -g 63 | ``` 64 | 65 | ## Share 66 | 67 | - Share links to all files under Movies. 68 | 69 | ```bash 70 | pikpakcli share -p Movies 71 | ``` 72 | 73 | - Share the link to the specified file. 74 | 75 | ```bash 76 | pikpakcli share Movies/Peppa_Pig.mp4 77 | ``` 78 | 79 | - Share link output to a specified file. 80 | 81 | ```bash 82 | pikpakcli share --out sha.txt -p Movies 83 | ``` 84 | 85 | ## New 86 | 87 | ### New Folder 88 | 89 | - Create a new folder NewFolder under Movies 90 | 91 | ```bash 92 | pikpakcli new folder -p Movies NewFolder 93 | ``` 94 | 95 | ### New Sha File 96 | 97 | - Create a new Sha file under Movies. 98 | 99 | ```bash 100 | pikpakcli new sha -p /Movies 'PikPak://美国队长.mkv|22809693754|75BFE33237A0C06C725587F87981C567E4E478C3' 101 | ``` 102 | 103 | ### New Magnet File 104 | 105 | - Create new magnet file. 106 | 107 | ```bash 108 | pikpakcli new url 'magnet:?xt=urn:btih:e9c98e3ed488611abc169a81d8a21487fd1d0732' 109 | ``` 110 | 111 | ## Quota 112 | 113 | - Get space on your PikPak cloud drive. 114 | 115 | ```bash 116 | pikpakcli quota -H 117 | ``` 118 | 119 | ## Ls 120 | 121 | - Get information about all files in the root directory. 122 | 123 | ```bash 124 | pikpakcli ls -lH -p / 125 | ``` 126 | -------------------------------------------------------------------------------- /cmd/share/share.go: -------------------------------------------------------------------------------- 1 | package share 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/52funny/pikpakcli/conf" 9 | "github.com/52funny/pikpakcli/internal/pikpak" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ShareCommand = &cobra.Command{ 15 | Use: "share", 16 | Aliases: []string{"d"}, 17 | Short: `Share file links on the pikpak server`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 20 | err := p.Login() 21 | if err != nil { 22 | logrus.Errorln("Login Failed:", err) 23 | } 24 | // Output file handle 25 | var f = os.Stdout 26 | if strings.TrimSpace(output) != "" { 27 | file, err := os.Create(output) 28 | if err != nil { 29 | logrus.Errorln("Create file failed:", err) 30 | return 31 | } 32 | defer file.Close() 33 | f = file 34 | } 35 | 36 | if len(args) > 0 { 37 | shareFiles(&p, args, f) 38 | } else { 39 | shareFolder(&p, f) 40 | } 41 | }, 42 | } 43 | 44 | // Specifies the folder of the pikpak server 45 | // default is the root folder 46 | var folder string 47 | 48 | // Specifies the file to write 49 | // default is the stdout 50 | var output string 51 | 52 | var parentId string 53 | 54 | func init() { 55 | ShareCommand.Flags().StringVarP(&folder, "path", "p", "/", "specific the folder of the pikpak server") 56 | ShareCommand.Flags().StringVarP(&output, "output", "o", "", "specific the file to write") 57 | ShareCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "parent folder id") 58 | } 59 | 60 | // Share folder 61 | func shareFolder(p *pikpak.PikPak, f *os.File) { 62 | var err error 63 | if parentId == "" { 64 | parentId, err = p.GetDeepFolderId("", folder) 65 | if err != nil { 66 | logrus.Errorln("Get parent id failed:", err) 67 | return 68 | } 69 | } 70 | fileStat, err := p.GetFolderFileStatList(parentId) 71 | if err != nil { 72 | logrus.Errorln("Get folder file stat list failed:", err) 73 | return 74 | } 75 | for _, stat := range fileStat { 76 | // logrus.Debug(stat) 77 | if stat.Kind == "drive#file" { 78 | fmt.Fprintf(f, "PikPak://%s|%s|%s\n", stat.Name, stat.Size, stat.Hash) 79 | } 80 | } 81 | } 82 | 83 | // Share files 84 | func shareFiles(p *pikpak.PikPak, args []string, f *os.File) { 85 | var err error 86 | if parentId == "" { 87 | parentId, err = p.GetPathFolderId(folder) 88 | if err != nil { 89 | logrus.Errorln("get parent id failed:", err) 90 | return 91 | } 92 | } 93 | for _, path := range args { 94 | stat, err := p.GetFileStat(parentId, path) 95 | if err != nil { 96 | logrus.Errorln(path, "get file stat error:", err) 97 | continue 98 | } 99 | fmt.Fprintf(f, "PikPak://%s|%s|%s\n", stat.Name, stat.Size, stat.Hash) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /cmd/new/url/url.go: -------------------------------------------------------------------------------- 1 | package url 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/52funny/pikpakcli/conf" 11 | "github.com/52funny/pikpakcli/internal/pikpak" 12 | "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var NewUrlCommand = &cobra.Command{ 17 | Use: "url", 18 | Short: `Create a file according to url`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 21 | err := p.Login() 22 | if err != nil { 23 | logrus.Errorln("Login Failed:", err) 24 | } 25 | if cli { 26 | handleCli(&p) 27 | return 28 | } 29 | // input mode 30 | if strings.TrimSpace(input) != "" { 31 | f, err := os.OpenFile(input, os.O_RDONLY, 0666) 32 | if err != nil { 33 | logrus.Errorf("Open file %s failed: %s", input, err) 34 | return 35 | } 36 | reader := bufio.NewReader(f) 37 | shas := make([]string, 0) 38 | for { 39 | lineBytes, _, err := reader.ReadLine() 40 | if err == io.EOF { 41 | break 42 | } 43 | shas = append(shas, string(lineBytes)) 44 | } 45 | handleNewUrl(&p, shas) 46 | return 47 | } 48 | 49 | // args mode 50 | if len(args) > 0 { 51 | handleNewUrl(&p, args) 52 | } else { 53 | logrus.Errorln("Please input the folder name") 54 | } 55 | }, 56 | } 57 | 58 | var path string 59 | 60 | var parentId string 61 | 62 | var input string 63 | 64 | var cli bool 65 | 66 | func init() { 67 | NewUrlCommand.Flags().StringVarP(&path, "path", "p", "/", "The path of the folder") 68 | NewUrlCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "The parent id") 69 | NewUrlCommand.Flags().StringVarP(&input, "input", "i", "", "The input of the sha file") 70 | NewUrlCommand.Flags().BoolVarP(&cli, "cli", "c", false, "The cli mode") 71 | } 72 | 73 | // new folder 74 | func handleNewUrl(p *pikpak.PikPak, shas []string) { 75 | var err error 76 | if parentId == "" { 77 | parentId, err = p.GetPathFolderId(path) 78 | if err != nil { 79 | logrus.Errorf("Get parent id failed: %s\n", err) 80 | return 81 | } 82 | } 83 | for _, url := range shas { 84 | err := p.CreateUrlFile(parentId, url) 85 | if err != nil { 86 | logrus.Errorln("Create url file failed: ", err) 87 | continue 88 | } 89 | logrus.Infoln("Create url file success: ", url) 90 | } 91 | } 92 | 93 | func handleCli(p *pikpak.PikPak) { 94 | var err error 95 | if parentId == "" { 96 | parentId, err = p.GetPathFolderId(path) 97 | if err != nil { 98 | logrus.Errorf("Get parent id failed: %s\n", err) 99 | return 100 | } 101 | } 102 | 103 | reader := bufio.NewReader(os.Stdin) 104 | for { 105 | fmt.Print("> ") 106 | lineBytes, _, err := reader.ReadLine() 107 | if err == io.EOF { 108 | break 109 | } 110 | url := string(lineBytes) 111 | err = p.CreateUrlFile(parentId, url) 112 | if err != nil { 113 | logrus.Errorln("Create url file failed: ", err) 114 | continue 115 | } 116 | logrus.Infoln("Create url file success: ", url) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /cmd/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/52funny/pikpakcli/conf" 8 | "github.com/52funny/pikpakcli/internal/pikpak" 9 | "github.com/fatih/color" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var long bool 15 | var human bool 16 | var path string 17 | var parentId string 18 | 19 | var ListCmd = &cobra.Command{ 20 | Use: "ls", 21 | Short: `Get the directory information under the specified folder`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 24 | err := p.Login() 25 | if err != nil { 26 | logrus.Errorln("Login Failed:", err) 27 | } 28 | handle(&p, args) 29 | }, 30 | } 31 | 32 | func init() { 33 | ListCmd.Flags().BoolVarP(&human, "human", "H", false, "display human readable format") 34 | ListCmd.Flags().BoolVarP(&long, "long", "l", false, "display long format") 35 | ListCmd.Flags().StringVarP(&path, "path", "p", "/", "display the specified path") 36 | ListCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "display the specified parent id") 37 | } 38 | 39 | func handle(p *pikpak.PikPak, args []string) { 40 | var err error 41 | if parentId == "" { 42 | parentId, err = p.GetPathFolderId(path) 43 | if err != nil { 44 | logrus.Errorln("get path folder id error:", err) 45 | return 46 | } 47 | } 48 | files, err := p.GetFolderFileStatList(parentId) 49 | if err != nil { 50 | logrus.Errorln("get folder file stat list error:", err) 51 | return 52 | } 53 | for _, file := range files { 54 | if long { 55 | if human { 56 | display(3, &file) 57 | } else { 58 | display(2, &file) 59 | } 60 | } else { 61 | display(0, &file) 62 | } 63 | } 64 | } 65 | 66 | // lH 67 | // mode 0: normal print 68 | // mode 2: long format 69 | // mode 3: long format and human readable 70 | 71 | func display(mode int, file *pikpak.FileStat) { 72 | switch mode { 73 | case 0: 74 | if file.Kind == "drive#folder" { 75 | fmt.Printf("%-20s\n", color.GreenString(file.Name)) 76 | } else { 77 | fmt.Printf("%-20s\n", file.Name) 78 | } 79 | case 2: 80 | if file.Kind == "drive#folder" { 81 | fmt.Printf("%-26s %-6s %-14s %s\n", file.ID, file.Size, file.CreatedTime.Format("2006-01-02 15:04:05"), color.GreenString(file.Name)) 82 | } else { 83 | fmt.Printf("%-26s %-6s %-14s %s\n", file.ID, file.Size, file.CreatedTime.Format("2006-01-02 15:04:05"), file.Name) 84 | } 85 | case 3: 86 | if file.Kind == "drive#folder" { 87 | fmt.Printf("%-26s %-6s %-14s %s\n", file.ID, displayStorage(file.Size), file.CreatedTime.Format("2006-01-02 15:04:05"), color.GreenString(file.Name)) 88 | } else { 89 | fmt.Printf("%-26s %-6s %-14s %s\n", file.ID, displayStorage(file.Size), file.CreatedTime.Format("2006-01-02 15:04:05"), file.Name) 90 | } 91 | } 92 | } 93 | 94 | func displayStorage(s string) string { 95 | size, _ := strconv.ParseUint(s, 10, 64) 96 | cnt := 0 97 | for size > 1024 { 98 | cnt += 1 99 | if cnt > 5 { 100 | break 101 | } 102 | size /= 1024 103 | } 104 | res := strconv.Itoa(int(size)) 105 | switch cnt { 106 | case 0: 107 | res += "B" 108 | case 1: 109 | res += "KB" 110 | case 2: 111 | res += "MB" 112 | case 3: 113 | res += "GB" 114 | case 4: 115 | res += "TB" 116 | case 5: 117 | res += "PB" 118 | } 119 | return res 120 | } 121 | -------------------------------------------------------------------------------- /conf/config.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | type ConfigType struct { 16 | Proxy string 17 | Username string 18 | Password string 19 | } 20 | 21 | var Config ConfigType 22 | 23 | // UseProxy returns whether the proxy is used 24 | func (c *ConfigType) UseProxy() bool { 25 | return len(c.Proxy) != 0 26 | } 27 | 28 | // Initializing configuration information 29 | func InitConfig(path string) error { 30 | // Firstly, read the config info from executable file 31 | if readFromBinary() == nil { 32 | return nil 33 | } 34 | 35 | // Secondly, it reads config.yml from the given path. 36 | // If there is no config.yml in the given path, it reads it from the default config path. 37 | _, err := os.Stat(path) 38 | switch os.IsNotExist(err) { 39 | case true: 40 | if err := readFromConfigDir(); err != nil { 41 | return err 42 | } 43 | case false: 44 | if err := readFromPath(path); err != nil { 45 | return err 46 | } 47 | } 48 | 49 | // Not empty 50 | // Must contains '://' 51 | if len(Config.Proxy) != 0 && !strings.Contains(Config.Proxy, "://") { 52 | return fmt.Errorf("proxy should contains ://") 53 | } 54 | return nil 55 | } 56 | 57 | // Read config from binary in the end 58 | // config_bytes: n bytes 59 | // end_magic: 10 bytes 60 | // size: 4 bytes 61 | // ----------------------------------- 62 | // | config_bytes | size | end_magic | 63 | // ----------------------------------- 64 | func readFromBinary() error { 65 | f, err := os.Open(os.Args[0]) 66 | if err != nil { 67 | return err 68 | } 69 | defer f.Close() 70 | stat, err := f.Stat() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | var end_magic = make([]byte, 10) 76 | n, err := f.ReadAt(end_magic, stat.Size()-10) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | if n != 10 { 82 | return fmt.Errorf("read end_magic err: %d", n) 83 | } 84 | 85 | // Not have `config.yml` in the end 86 | if !bytes.Equal(end_magic, []byte("config.yml")) { 87 | return fmt.Errorf("not a pikpakcli binary") 88 | } 89 | 90 | var size = make([]byte, 4) 91 | n, err = f.ReadAt(size, stat.Size()-14) 92 | 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if n != 4 { 98 | return fmt.Errorf("read size err: %d", n) 99 | } 100 | 101 | configSize := int64(binary.LittleEndian.Uint32(size)) 102 | configBuf := make([]byte, configSize) 103 | 104 | n, err = f.ReadAt(configBuf, stat.Size()-14-configSize) 105 | 106 | if err != nil || n != int(configSize) { 107 | return err 108 | } 109 | 110 | if n != int(configSize) { 111 | return fmt.Errorf("read config size err: %d", n) 112 | } 113 | 114 | // Unmarshal config 115 | return yaml.Unmarshal(configBuf, &Config) 116 | } 117 | 118 | // Read configuration file from the given path 119 | func readFromPath(path string) error { 120 | f, err := os.Open(path) 121 | if err != nil { 122 | return err 123 | } 124 | defer f.Close() 125 | bs, err := io.ReadAll(f) 126 | if err != nil { 127 | return err 128 | } 129 | err = yaml.Unmarshal(bs, &Config) 130 | if err != nil { 131 | return err 132 | } 133 | return nil 134 | } 135 | 136 | // Read configuration file from config path 137 | func readFromConfigDir() error { 138 | configDir, err := os.UserConfigDir() 139 | if err != nil { 140 | return err 141 | } 142 | path := filepath.Join(configDir, "pikpakcli", "config.yml") 143 | f, err := os.Open(path) 144 | if err != nil { 145 | return err 146 | } 147 | defer f.Close() 148 | bs, err := io.ReadAll(f) 149 | if err != nil { 150 | return err 151 | } 152 | err = yaml.Unmarshal(bs, &Config) 153 | if err != nil { 154 | return err 155 | } 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /internal/pikpak/pikpak.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | 12 | "github.com/52funny/pikpakcli/conf" 13 | jsoniter "github.com/json-iterator/go" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const userAgent = `ANDROID-com.pikcloud.pikpak/1.21.0` 18 | const clientID = `YNxT9w7GMdWvEOKa` 19 | const clientSecret = `dbw2OtmVEeuUvIptb1Coyg` 20 | 21 | type PikPak struct { 22 | Account string `json:"account"` 23 | Password string `json:"password"` 24 | JwtToken string `json:"token"` 25 | refreshToken string 26 | CaptchaToken string `json:"captchaToken"` 27 | Sub string `json:"userId"` 28 | DeviceId string `json:"deviceId"` 29 | RefreshSecond int64 `json:"refreshSecond"` 30 | client *http.Client 31 | } 32 | 33 | func NewPikPak(account, password string) PikPak { 34 | client := &http.Client{ 35 | Transport: &http.Transport{ 36 | Proxy: http.ProxyFromEnvironment, 37 | }, 38 | } 39 | if conf.Config.UseProxy() { 40 | url, err := url.Parse(conf.Config.Proxy) 41 | if err != nil { 42 | logrus.Errorln("url parse proxy error", err) 43 | } 44 | p := http.ProxyURL(url) 45 | client.Transport = &http.Transport{ 46 | Proxy: p, 47 | } 48 | http.DefaultClient.Transport = &http.Transport{ 49 | Proxy: p, 50 | } 51 | } 52 | n := md5.Sum([]byte(account)) 53 | return PikPak{ 54 | Account: account, 55 | Password: password, 56 | DeviceId: hex.EncodeToString(n[:]), 57 | client: client, 58 | } 59 | } 60 | 61 | func (p *PikPak) Login() error { 62 | captchaToken, err := p.getCaptchaToken() 63 | if err != nil { 64 | return err 65 | } 66 | m := make(map[string]string) 67 | m["client_id"] = clientID 68 | m["client_secret"] = clientSecret 69 | m["grant_type"] = "password" 70 | m["username"] = p.Account 71 | m["password"] = p.Password 72 | m["captcha_token"] = captchaToken 73 | bs, err := jsoniter.Marshal(&m) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | req, err := http.NewRequest("POST", "https://user.mypikpak.com/v1/auth/signin", bytes.NewBuffer(bs)) 79 | if err != nil { 80 | return err 81 | } 82 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 83 | bs, err = p.sendRequest(req) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | error_code := jsoniter.Get(bs, "error_code").ToInt() 89 | 90 | if error_code != 0 { 91 | return fmt.Errorf("login error: %v", jsoniter.Get(bs, "error").ToString()) 92 | } 93 | 94 | p.JwtToken = jsoniter.Get(bs, "access_token").ToString() 95 | p.refreshToken = jsoniter.Get(bs, "refresh_token").ToString() 96 | p.Sub = jsoniter.Get(bs, "sub").ToString() 97 | p.RefreshSecond = jsoniter.Get(bs, "expires_in").ToInt64() 98 | return nil 99 | } 100 | 101 | func (p *PikPak) getCaptchaToken() (string, error) { 102 | m := make(map[string]any) 103 | m["client_id"] = clientID 104 | m["device_id"] = p.DeviceId 105 | m["action"] = "POST:https://user.mypikpak.com/v1/auth/signin" 106 | m["meta"] = map[string]string{ 107 | "username": p.Account, 108 | } 109 | body, err := jsoniter.Marshal(&m) 110 | if err != nil { 111 | return "", err 112 | } 113 | req, err := http.NewRequest("POST", "https://user.mypikpak.com/v1/shield/captcha/init", bytes.NewBuffer(body)) 114 | if err != nil { 115 | return "", err 116 | } 117 | req.Header.Add("Content-Type", "application/json") 118 | bs, err := p.sendRequest(req) 119 | if err != nil { 120 | return "", err 121 | } 122 | error_code := jsoniter.Get(bs, "error_code").ToInt() 123 | if error_code != 0 { 124 | return "", fmt.Errorf("get captcha error: %v", jsoniter.Get(bs, "error").ToString()) 125 | } 126 | return jsoniter.Get(bs, "captcha_token").ToString(), nil 127 | } 128 | 129 | func (p *PikPak) sendRequest(req *http.Request) ([]byte, error) { 130 | p.setHeader(req) 131 | resp, err := p.client.Do(req) 132 | if err != nil { 133 | return nil, err 134 | } 135 | bs, err := io.ReadAll(resp.Body) 136 | defer resp.Body.Close() 137 | if err != nil { 138 | return nil, err 139 | } 140 | return bs, nil 141 | } 142 | 143 | func (p *PikPak) setHeader(req *http.Request) { 144 | if p.JwtToken != "" { 145 | req.Header.Set("Authorization", "Bearer "+p.JwtToken) 146 | } 147 | req.Header.Set("User-Agent", userAgent) 148 | req.Header.Set("X-Device-Id", p.DeviceId) 149 | } 150 | -------------------------------------------------------------------------------- /cmd/embed/embed.go: -------------------------------------------------------------------------------- 1 | package embed 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/52funny/pikpakcli/internal/utils" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | const magic = "config.yml" 15 | 16 | var EmbedCmd = &cobra.Command{ 17 | Use: "embed", 18 | Short: `Embed config file`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | if len(args) <= 0 { 21 | logrus.Errorln("Please specify the config file path") 22 | os.Exit(1) 23 | } 24 | ok, err := checkEmbed() 25 | if err != nil { 26 | logrus.Errorln("check magic error", err) 27 | os.Exit(1) 28 | } 29 | 30 | if update && ok { 31 | err = updateEmbed(args[0], os.Args[0]) 32 | if err != nil { 33 | logrus.Errorln(err) 34 | os.Exit(1) 35 | } 36 | logrus.Infoln("Update embed config file success") 37 | os.Exit(0) 38 | } 39 | 40 | if ok { 41 | logrus.Warnln("config file has been embedded") 42 | os.Exit(1) 43 | } 44 | copyBin, err := copyBin(os.Args[0]) 45 | if err != nil { 46 | logrus.Errorln("create copy binary file error:", err.Error()) 47 | os.Exit(1) 48 | } 49 | err = embed(args[0], copyBin) 50 | if err != nil { 51 | logrus.Errorln(err) 52 | os.Exit(1) 53 | } 54 | logrus.Infoln("Embed config file success") 55 | }, 56 | } 57 | 58 | var update bool 59 | 60 | func init() { 61 | EmbedCmd.Flags().BoolVarP(&update, "update", "u", false, "update embed config") 62 | } 63 | 64 | func checkEmbed() (bool, error) { 65 | f, err := os.Open(os.Args[0]) 66 | if err != nil { 67 | return false, err 68 | } 69 | defer f.Close() 70 | fStat, _ := f.Stat() 71 | magicBuf := make([]byte, len(magic)) 72 | n, err := f.ReadAt(magicBuf, fStat.Size()-int64(len(magic))) 73 | if err != nil { 74 | return false, err 75 | } 76 | if n != len(magic) { 77 | return false, fmt.Errorf("read magic size error") 78 | } 79 | return string(magicBuf) == magic, nil 80 | } 81 | 82 | // embed config file to binary 83 | func embed(configPath string, binFile *os.File) error { 84 | f, err := os.Open(configPath) 85 | if err != nil { 86 | return fmt.Errorf("open config file error: %s", err.Error()) 87 | } 88 | defer f.Close() 89 | fStat, _ := f.Stat() 90 | bs, err := io.ReadAll(f) 91 | if err != nil { 92 | return fmt.Errorf("read config file error: %s", err.Error()) 93 | } 94 | sizeBytes := make([]byte, 4) 95 | binary.LittleEndian.PutUint32(sizeBytes, uint32(fStat.Size())) 96 | bs = append(bs, sizeBytes...) 97 | bs = append(bs, []byte("config.yml")...) 98 | // binFile, err := os.OpenFile(binPath, os.O_WRONLY|os.O_APPEND, 0666) 99 | // if err != nil { 100 | // return fmt.Errorf("open binary file error: %s", err.Error()) 101 | // } 102 | defer binFile.Close() 103 | n, err := binFile.Write(bs) 104 | if err != nil || n != len(bs) { 105 | return fmt.Errorf("write to binary error: %s", err.Error()) 106 | } 107 | return nil 108 | } 109 | 110 | // first remove embed config 111 | // second embed config 112 | func updateEmbed(configPath string, BinPath string) error { 113 | copyBin, err := copyBin(BinPath) 114 | if err != nil { 115 | return err 116 | } 117 | binStat, _ := copyBin.Stat() 118 | var size = make([]byte, 4) 119 | n, err := copyBin.ReadAt(size, binStat.Size()-14) 120 | 121 | if err != nil { 122 | return err 123 | } 124 | 125 | if n != 4 { 126 | return fmt.Errorf("read size err: %d", n) 127 | } 128 | 129 | configSize := int64(binary.LittleEndian.Uint32(size)) 130 | copyBin.Seek(binStat.Size()-14-configSize, 0) 131 | err = copyBin.Truncate(binStat.Size() - 14 - configSize) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | return embed(configPath, copyBin) 137 | } 138 | 139 | func copyBin(path string) (*os.File, error) { 140 | binFile, err := os.Open(path) 141 | if err != nil { 142 | return nil, fmt.Errorf("open binary file error: %s", err.Error()) 143 | } 144 | copyBinName := utils.GetEmbedBinName(os.Args[0]) 145 | binSt, _ := binFile.Stat() 146 | copyBin, err := os.OpenFile(copyBinName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, binSt.Mode()) 147 | if err != nil { 148 | return nil, fmt.Errorf("open copy binary file error: %s", err.Error()) 149 | } 150 | io.Copy(copyBin, binFile) 151 | binFile.Close() 152 | return copyBin, nil 153 | } 154 | 155 | // delete some bytes in the end of file 156 | func deleteBytes(f *os.File, n int64) error { 157 | fStat, _ := f.Stat() 158 | // read the last n bytes 159 | bs := make([]byte, n) 160 | _, err := f.ReadAt(bs, fStat.Size()-n) 161 | if err != nil { 162 | return err 163 | } 164 | // truncate file 165 | err = f.Truncate(fStat.Size() - n) 166 | if err != nil { 167 | return err 168 | } 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /internal/pikpak/folder.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/52funny/pikpakcli/internal/utils" 10 | jsoniter "github.com/json-iterator/go" 11 | "github.com/sirupsen/logrus" 12 | "github.com/tidwall/gjson" 13 | ) 14 | 15 | // 获取文件夹 id 16 | // dir 可以包括 /. 17 | // 若以 / 开头,函数会去除 /, 且会从 parent 目录开始查找 18 | func (p *PikPak) GetDeepFolderId(parentId string, dirPath string) (string, error) { 19 | dirPath = utils.Slash(dirPath) 20 | if dirPath == "" { 21 | return parentId, nil 22 | } 23 | 24 | dirS := utils.SplitSeparator(dirPath) 25 | 26 | for _, dir := range dirS { 27 | id, err := p.GetFolderId(parentId, dir) 28 | if err != nil { 29 | return "", err 30 | } 31 | parentId = id 32 | } 33 | return parentId, nil 34 | } 35 | 36 | func (p *PikPak) GetPathFolderId(dirPath string) (string, error) { 37 | return p.GetDeepFolderId("", dirPath) 38 | } 39 | 40 | // 获取文件夹 id 41 | // dir 不能包括 / 42 | func (p *PikPak) GetFolderId(parentId string, dir string) (string, error) { 43 | // slash the dir path 44 | dir = utils.Slash(dir) 45 | 46 | value := url.Values{} 47 | value.Add("parent_id", parentId) 48 | value.Add("page_token", "") 49 | value.Add("with_audit", "false") 50 | value.Add("thumbnail_size", "SIZE_LARGE") 51 | value.Add("limit", "500") 52 | for { 53 | req, err := http.NewRequest("GET", fmt.Sprintf("https://api-drive.mypikpak.com/drive/v1/files?"+value.Encode()), nil) 54 | if err != nil { 55 | return "", err 56 | } 57 | req.Header.Set("Country", "CN") 58 | req.Header.Set("X-Peer-Id", p.DeviceId) 59 | req.Header.Set("X-User-Region", "1") 60 | req.Header.Set("X-Alt-Capability", "3") 61 | req.Header.Set("X-Client-Version-Code", "10083") 62 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 63 | bs, err := p.sendRequest(req) 64 | if err != nil { 65 | return "", err 66 | } 67 | files := gjson.GetBytes(bs, "files").Array() 68 | 69 | for _, file := range files { 70 | kind := file.Get("kind").String() 71 | name := file.Get("name").String() 72 | trashed := file.Get("trashed").Bool() 73 | if kind == "drive#folder" && name == dir && !trashed { 74 | return file.Get("id").String(), nil 75 | } 76 | } 77 | nextToken := gjson.GetBytes(bs, "next_page_token").String() 78 | if nextToken == "" { 79 | break 80 | } 81 | value.Set("page_token", nextToken) 82 | } 83 | return "", ErrNotFoundFolder 84 | } 85 | 86 | func (p *PikPak) GetDeepFolderOrCreateId(parentId string, dirPath string) (string, error) { 87 | dirPath = utils.Slash(dirPath) 88 | if dirPath == "" || dirPath == "." { 89 | return parentId, nil 90 | } 91 | 92 | dirS := utils.SplitSeparator(dirPath) 93 | 94 | for _, dir := range dirS { 95 | id, err := p.GetFolderId(parentId, dir) 96 | if err != nil { 97 | logrus.Warn("dir ", err) 98 | if err == ErrNotFoundFolder { 99 | createId, err := p.CreateFolder(parentId, dir) 100 | if err != nil { 101 | return "", err 102 | } else { 103 | logrus.Info("create dir: ", dir) 104 | parentId = createId 105 | } 106 | } else { 107 | return "", err 108 | } 109 | } else { 110 | parentId = id 111 | } 112 | } 113 | return parentId, nil 114 | } 115 | 116 | // Create new folder in parent folder 117 | // parentId is parent folder id 118 | func (p *PikPak) CreateFolder(parentId, dir string) (string, error) { 119 | m := map[string]interface{}{ 120 | "kind": "drive#folder", 121 | "parent_id": parentId, 122 | "name": dir, 123 | } 124 | bs, err := jsoniter.Marshal(&m) 125 | if err != nil { 126 | return "", err 127 | } 128 | START: 129 | req, err := http.NewRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs)) 130 | if err != nil { 131 | return "", err 132 | } 133 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 134 | req.Header.Set("Product_flavor_name", "cha") 135 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 136 | req.Header.Set("X-Client-Version-Code", "10083") 137 | req.Header.Set("X-Peer-Id", p.DeviceId) 138 | req.Header.Set("X-User-Region", "1") 139 | req.Header.Set("X-Alt-Capability", "3") 140 | req.Header.Set("Country", "CN") 141 | bs, err = p.sendRequest(req) 142 | if err != nil { 143 | return "", err 144 | } 145 | error_code := gjson.GetBytes(bs, "error_code").Int() 146 | if error_code != 0 { 147 | if error_code == 9 { 148 | err := p.AuthCaptchaToken("POST:/drive/v1/files") 149 | if err != nil { 150 | return "", err 151 | } 152 | goto START 153 | } 154 | return "", fmt.Errorf("create folder error: %s", jsoniter.Get(bs, "error").ToString()) 155 | } 156 | id := gjson.GetBytes(bs, "file.id").String() 157 | return id, nil 158 | } 159 | -------------------------------------------------------------------------------- /cmd/upload/upload.go: -------------------------------------------------------------------------------- 1 | package upload 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/52funny/pikpakcli/conf" 11 | "github.com/52funny/pikpakcli/internal/pikpak" 12 | "github.com/52funny/pikpakcli/internal/utils" 13 | "github.com/sirupsen/logrus" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | var UploadCmd = &cobra.Command{ 18 | Use: "upload", 19 | Aliases: []string{"u"}, 20 | Short: `Upload file to pikpak server`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | pikpak.Concurrent = uploadConcurrency 23 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 24 | err := p.Login() 25 | if err != nil { 26 | logrus.Error(err) 27 | } 28 | err = p.AuthCaptchaToken("POST:/drive/v1/files") 29 | if err != nil { 30 | logrus.Error(err) 31 | } 32 | 33 | go func() { 34 | ticker := time.NewTicker(time.Second * 7200 * 3 / 4) 35 | defer ticker.Stop() 36 | for range ticker.C { 37 | err := p.RefreshToken() 38 | if err != nil { 39 | logrus.Warn(err) 40 | continue 41 | } 42 | } 43 | }() 44 | for _, v := range args { 45 | stat, err := os.Stat(v) 46 | if err != nil { 47 | logrus.Errorf("Get file %s stat failed: %s", v, err) 48 | continue 49 | } 50 | if stat.IsDir() { 51 | handleUploadFolder(&p, v) 52 | } else { 53 | handleUploadFile(&p, v) 54 | } 55 | } 56 | }, 57 | } 58 | 59 | // Specifies the folder of the pikpak server 60 | var uploadFolder string 61 | 62 | // Specifies the file to upload 63 | var uploadConcurrency int64 64 | 65 | // Sync mode 66 | var sync bool 67 | 68 | // Parent path id 69 | var parentId string 70 | 71 | // Init upload command 72 | func init() { 73 | UploadCmd.Flags().StringVarP(&uploadFolder, "path", "p", "/", "specific the folder of the pikpak server") 74 | UploadCmd.Flags().Int64VarP(&uploadConcurrency, "concurrency", "c", 1<<4, "specific the concurrency of the upload") 75 | UploadCmd.Flags().StringSliceVarP(&exclude, "exn", "e", []string{}, "specific the exclude file or folder") 76 | UploadCmd.Flags().BoolVarP(&sync, "sync", "s", false, "sync mode") 77 | UploadCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "parent folder id") 78 | } 79 | 80 | // Exclude string list 81 | var exclude []string 82 | 83 | var defaultExcludeRegexp []*regexp.Regexp = []*regexp.Regexp{ 84 | // exclude the hidden file 85 | regexp.MustCompile(`^\..+`), 86 | } 87 | 88 | // Dispose the exclude file or folder 89 | func disposeExclude() { 90 | for _, v := range exclude { 91 | defaultExcludeRegexp = append(defaultExcludeRegexp, regexp.MustCompile(v)) 92 | } 93 | } 94 | 95 | func handleUploadFile(p *pikpak.PikPak, path string) { 96 | var err error 97 | if parentId == "" { 98 | parentId, err = p.GetDeepFolderOrCreateId("", uploadFolder) 99 | if err != nil { 100 | logrus.Errorf("Get folder %s id failed: %s", uploadFolder, err) 101 | return 102 | } 103 | } 104 | err = p.UploadFile(parentId, path) 105 | if err != nil { 106 | logrus.Errorf("Upload file %s failed: %s\n", path, err) 107 | return 108 | } 109 | logrus.Infof("Upload file %s success!\n", path) 110 | } 111 | 112 | // upload files logic 113 | func handleUploadFolder(p *pikpak.PikPak, path string) { 114 | basePath := filepath.Base(filepath.ToSlash(path)) 115 | uploadFilePath, err := utils.GetUploadFilePath(path, defaultExcludeRegexp) 116 | if err != nil { 117 | logrus.Errorln(err) 118 | return 119 | } 120 | 121 | syncTxt, err := utils.NewSyncTxt(".pikpaksync.txt", sync) 122 | if err != nil { 123 | logrus.Errorln(err) 124 | return 125 | } 126 | defer syncTxt.Close() 127 | 128 | uploadFilePath = syncTxt.UnSync(uploadFilePath) 129 | 130 | logrus.Info("upload file list:") 131 | for _, f := range uploadFilePath { 132 | logrus.Infoln(filepath.Join(basePath, f)) 133 | } 134 | 135 | if parentId == "" { 136 | parentId, err = p.GetDeepFolderOrCreateId("", uploadFolder) 137 | if err != nil { 138 | logrus.Errorf("get folder %s id error: %s", uploadFolder, err) 139 | return 140 | } 141 | } 142 | 143 | logrus.Debug("upload folder: ", uploadFolder, " parentId: ", parentId) 144 | 145 | parentId, err = p.GetDeepFolderOrCreateId(parentId, basePath) 146 | if err != nil { 147 | logrus.Errorf("get base_upload_path %s id error: %s", basePath, err) 148 | return 149 | } 150 | parentIdMap := make(map[string]string) 151 | for _, v := range uploadFilePath { 152 | if strings.Contains(v, "/") || strings.Contains(v, "\\") { 153 | var id string 154 | base := filepath.Dir(v) 155 | 156 | // Avoid secondary query ids 157 | if mId, ok := parentIdMap[base]; !ok { 158 | id, err = p.GetDeepFolderOrCreateId(parentId, base) 159 | if err != nil { 160 | logrus.Error(err) 161 | } 162 | parentIdMap[base] = id 163 | } else { 164 | id = mId 165 | } 166 | 167 | err = p.UploadFile(id, filepath.Join(path, v)) 168 | if err != nil { 169 | logrus.Error(err) 170 | } 171 | syncTxt.WriteString(v + "\n") 172 | logrus.Infof("%s upload success!\n", v) 173 | } else { 174 | err = p.UploadFile(parentId, filepath.Join(path, v)) 175 | if err != nil { 176 | logrus.Error(err) 177 | } 178 | syncTxt.WriteString(v + "\n") 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c h1:ecJG8tmvgH6exVE4+I3rFPPA1Mk3/lNb8VZ6A7dtcyI= 2 | github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c/go.mod h1:YA/IS8XUrMTcrY+J4yOJ3CDgoyQ28NOOo4GnzOL6bTI= 3 | github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= 4 | github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= 5 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 12 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 13 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 14 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 15 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 16 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 17 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 18 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 19 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 20 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 21 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 22 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 23 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 24 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 26 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 27 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 28 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 32 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 33 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 34 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 35 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 36 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 37 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= 38 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= 39 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 40 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 41 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 42 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 43 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 45 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 46 | github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= 47 | github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 48 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 49 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 50 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= 51 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 52 | github.com/vbauerster/mpb/v8 v8.7.2 h1:SMJtxhNho1MV3OuFgS1DAzhANN1Ejc5Ct+0iSaIkB14= 53 | github.com/vbauerster/mpb/v8 v8.7.2/go.mod h1:ZFnrjzspgDHoxYLGvxIruiNk73GNTPG4YHgVNpR10VY= 54 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 57 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 61 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 62 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 64 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 65 | -------------------------------------------------------------------------------- /internal/pikpak/file.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | "unsafe" 10 | 11 | "github.com/tidwall/gjson" 12 | ) 13 | 14 | type FileStat struct { 15 | Kind string `json:"kind"` 16 | ID string `json:"id"` 17 | ParentID string `json:"parent_id"` 18 | Name string `json:"name"` 19 | UserID string `json:"user_id"` 20 | Size string `json:"size"` 21 | FileExtension string `json:"file_extension"` 22 | MimeType string `json:"mime_type"` 23 | CreatedTime time.Time `json:"created_time"` 24 | ModifiedTime time.Time `json:"modified_time"` 25 | IconLink string `json:"icon_link"` 26 | ThumbnailLink string `json:"thumbnail_link"` 27 | Md5Checksum string `json:"md5_checksum"` 28 | Hash string `json:"hash"` 29 | Phase string `json:"phase"` 30 | } 31 | type File struct { 32 | FileStat 33 | Revision string `json:"revision"` 34 | Starred bool `json:"starred"` 35 | WebContentLink string `json:"web_content_link"` 36 | Links struct { 37 | ApplicationOctetStream struct { 38 | URL string `json:"url"` 39 | Token string `json:"token"` 40 | Expire time.Time `json:"expire"` 41 | } `json:"application/octet-stream"` 42 | } `json:"links"` 43 | Audit struct { 44 | Status string `json:"status"` 45 | Message string `json:"message"` 46 | Title string `json:"title"` 47 | } `json:"audit"` 48 | Medias []struct { 49 | MediaID string `json:"media_id"` 50 | MediaName string `json:"media_name"` 51 | Video interface{} `json:"video"` 52 | Link struct { 53 | URL string `json:"url"` 54 | Token string `json:"token"` 55 | Expire time.Time `json:"expire"` 56 | } `json:"link"` 57 | NeedMoreQuota bool `json:"need_more_quota"` 58 | VipTypes []interface{} `json:"vip_types"` 59 | RedirectLink string `json:"redirect_link"` 60 | IconLink string `json:"icon_link"` 61 | IsDefault bool `json:"is_default"` 62 | Priority int `json:"priority"` 63 | IsOrigin bool `json:"is_origin"` 64 | ResolutionName string `json:"resolution_name"` 65 | IsVisible bool `json:"is_visible"` 66 | Category string `json:"category"` 67 | } `json:"medias"` 68 | Trashed bool `json:"trashed"` 69 | DeleteTime string `json:"delete_time"` 70 | OriginalURL string `json:"original_url"` 71 | Params struct { 72 | Platform string `json:"platform"` 73 | PlatformIcon string `json:"platform_icon"` 74 | } `json:"params"` 75 | OriginalFileIndex int `json:"original_file_index"` 76 | Space string `json:"space"` 77 | Apps []interface{} `json:"apps"` 78 | Writable bool `json:"writable"` 79 | FolderType string `json:"folder_type"` 80 | Collection interface{} `json:"collection"` 81 | } 82 | 83 | type fileListResult struct { 84 | NextPageToken string `json:"next_page_token"` 85 | Files []FileStat `json:"files"` 86 | } 87 | 88 | func (p *PikPak) GetFolderFileStatList(parentId string) ([]FileStat, error) { 89 | filters := `{"trashed":{"eq":false}}` 90 | query := url.Values{} 91 | query.Add("thumbnail_size", "SIZE_MEDIUM") 92 | query.Add("limit", "500") 93 | query.Add("parent_id", parentId) 94 | query.Add("with_audit", "false") 95 | query.Add("filters", filters) 96 | fileList := make([]FileStat, 0) 97 | 98 | for { 99 | req, err := http.NewRequest("GET", "https://api-drive.mypikpak.com/drive/v1/files?"+query.Encode(), nil) 100 | if err != nil { 101 | return fileList, err 102 | } 103 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 104 | req.Header.Set("Content-Type", "application/json") 105 | bs, err := p.sendRequest(req) 106 | if err != nil { 107 | return fileList, err 108 | } 109 | error_code := gjson.Get(*(*string)(unsafe.Pointer(&bs)), "error_code").Int() 110 | if error_code == 9 { 111 | err = p.AuthCaptchaToken("GET:/drive/v1/files") 112 | if err != nil { 113 | return fileList, err 114 | } 115 | } 116 | var result fileListResult 117 | err = json.Unmarshal(bs, &result) 118 | if err != nil { 119 | return fileList, err 120 | } 121 | fileList = append(fileList, result.Files...) 122 | if result.NextPageToken == "" { 123 | break 124 | } 125 | query.Set("page_token", result.NextPageToken) 126 | } 127 | return fileList, nil 128 | } 129 | 130 | // Find FileState similar to name in the parentId directory 131 | func (p *PikPak) GetFileStat(parentId string, name string) (FileStat, error) { 132 | stats, err := p.GetFolderFileStatList(parentId) 133 | if err != nil { 134 | return FileStat{}, err 135 | } 136 | for _, stat := range stats { 137 | if stat.Name == name { 138 | return stat, nil 139 | } 140 | } 141 | return FileStat{}, errors.New("file not found") 142 | } 143 | 144 | func (p *PikPak) GetFile(fileId string) (File, error) { 145 | var fileInfo File 146 | query := url.Values{} 147 | query.Add("thumbnail_size", "SIZE_MEDIUM") 148 | req, err := http.NewRequest("GET", "https://api-drive.mypikpak.com/drive/v1/files/"+fileId+"?"+query.Encode(), nil) 149 | if err != nil { 150 | return fileInfo, nil 151 | } 152 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 153 | req.Header.Set("X-Device-Id", p.DeviceId) 154 | bs, err := p.sendRequest(req) 155 | if err != nil { 156 | return fileInfo, nil 157 | } 158 | 159 | error_code := gjson.Get(*(*string)(unsafe.Pointer(&bs)), "error_code").Int() 160 | if error_code != 0 { 161 | if error_code == 9 { 162 | err = p.AuthCaptchaToken("GET:/drive/v1/files") 163 | if err != nil { 164 | return fileInfo, err 165 | } 166 | } 167 | err = errors.New(gjson.Get(*(*string)(unsafe.Pointer(&bs)), "error").String() + ":" + fileId) 168 | return fileInfo, err 169 | } 170 | err = json.Unmarshal(bs, &fileInfo) 171 | if err != nil { 172 | return fileInfo, err 173 | } 174 | return fileInfo, err 175 | } 176 | -------------------------------------------------------------------------------- /cmd/download/download.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strconv" 7 | "unicode/utf8" 8 | 9 | "github.com/52funny/pikpakcli/conf" 10 | "github.com/52funny/pikpakcli/internal/pikpak" 11 | "github.com/52funny/pikpakcli/internal/utils" 12 | "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | "github.com/vbauerster/mpb/v8" 15 | "github.com/vbauerster/mpb/v8/decor" 16 | ) 17 | 18 | var DownloadCmd = &cobra.Command{ 19 | Use: "download", 20 | Aliases: []string{"d"}, 21 | Short: `Download file from pikpak server`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | p := pikpak.NewPikPak(conf.Config.Username, conf.Config.Password) 24 | err := p.Login() 25 | if err != nil { 26 | logrus.Errorln("Login Failed:", err) 27 | } 28 | if len(args) > 0 { 29 | downloadFile(&p, args) 30 | } else { 31 | downloadFolder(&p) 32 | } 33 | }, 34 | } 35 | 36 | // Number of simultaneous downloads 37 | // 38 | // default 1 39 | var count int 40 | 41 | // Specifies the folder of the pikpak server 42 | // 43 | // default server root directory (.) 44 | var folder string 45 | 46 | // parent path id 47 | var parentId string 48 | 49 | // Output directory 50 | // 51 | // default current directory (.) 52 | var output string 53 | 54 | // Progress bar 55 | // 56 | // default false 57 | var progress bool 58 | 59 | type warpFile struct { 60 | f *pikpak.File 61 | output string 62 | } 63 | 64 | type warpStat struct { 65 | s pikpak.FileStat 66 | output string 67 | } 68 | 69 | func init() { 70 | DownloadCmd.Flags().IntVarP(&count, "count", "c", 1, "number of simultaneous downloads") 71 | DownloadCmd.Flags().StringVarP(&output, "output", "o", ".", "output directory") 72 | DownloadCmd.Flags().StringVarP(&folder, "path", "p", "/", "specific the folder of the pikpak server\nonly support download folder") 73 | DownloadCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "the parent path id") 74 | DownloadCmd.Flags().BoolVarP(&progress, "progress", "g", false, "show download progress") 75 | } 76 | 77 | // Downloads all files in the specified directory 78 | func downloadFolder(p *pikpak.PikPak) { 79 | base := filepath.Base(folder) 80 | var err error 81 | if parentId == "" { 82 | parentId, err = p.GetPathFolderId(folder) 83 | if err != nil { 84 | logrus.Errorln("Get Parent Folder Id Failed:", err) 85 | return 86 | } 87 | 88 | } 89 | collectStat := make([]warpStat, 0) 90 | recursive(p, &collectStat, parentId, filepath.Join(output, base)) 91 | 92 | statCh := make(chan warpStat, len(collectStat)) 93 | statDone := make(chan struct{}) 94 | 95 | fileCh := make(chan warpFile, len(collectStat)) 96 | fileDone := make(chan struct{}) 97 | 98 | for i := 0; i < 4; i += 1 { 99 | go func(fileCh chan<- warpFile, statCh <-chan warpStat, statDone chan<- struct{}) { 100 | for { 101 | stat, ok := <-statCh 102 | if !ok { 103 | break 104 | } 105 | file, err := p.GetFile(stat.s.ID) 106 | if err != nil { 107 | logrus.Errorln("Get File Failed:", err) 108 | } 109 | fileCh <- warpFile{ 110 | f: &file, 111 | output: stat.output, 112 | } 113 | statDone <- struct{}{} 114 | } 115 | }(fileCh, statCh, statDone) 116 | } 117 | 118 | if progress { 119 | pb := mpb.New(mpb.WithAutoRefresh()) 120 | for i := 0; i < count; i++ { 121 | // if progress is true then show progress bar 122 | go download(fileCh, fileDone, pb) 123 | } 124 | } else { 125 | go download(fileCh, fileDone, nil) 126 | } 127 | 128 | for i := 0; i < len(collectStat); i += 1 { 129 | err := utils.CreateDirIfNotExist(collectStat[i].output) 130 | if err != nil { 131 | logrus.Errorln("Create output directory failed:", err) 132 | return 133 | } 134 | statCh <- collectStat[i] 135 | } 136 | close(statCh) 137 | 138 | for i := 0; i < len(collectStat); i += 1 { 139 | <-statDone 140 | } 141 | close(statDone) 142 | 143 | for i := 0; i < len(collectStat); i += 1 { 144 | <-fileDone 145 | } 146 | } 147 | 148 | func recursive(p *pikpak.PikPak, collectWarpFile *[]warpStat, parentId string, parentPath string) { 149 | statList, err := p.GetFolderFileStatList(parentId) 150 | if err != nil { 151 | logrus.Errorln("Get Folder File Stat List Failed:", err) 152 | return 153 | } 154 | for _, r := range statList { 155 | if r.Kind == "drive#folder" { 156 | recursive(p, collectWarpFile, r.ID, filepath.Join(parentPath, r.Name)) 157 | } else { 158 | // file, _ := p.GetFile(r.ID) 159 | *collectWarpFile = append(*collectWarpFile, warpStat{ 160 | s: r, 161 | output: parentPath, 162 | }) 163 | // fmt.Println(r.Name, r.Size, r.Kind, parentPath) 164 | } 165 | } 166 | } 167 | 168 | func downloadFile(p *pikpak.PikPak, args []string) { 169 | var err error 170 | if parentId == "" { 171 | parentId, err = p.GetPathFolderId(folder) 172 | if err != nil { 173 | logrus.Errorln("get folder failed:", err) 174 | return 175 | } 176 | } 177 | 178 | // if output not exists then create. 179 | if err := utils.CreateDirIfNotExist(output); err != nil { 180 | logrus.Errorln("Create output directory failed:", err) 181 | return 182 | } 183 | 184 | sendCh := make(chan warpFile, 1) 185 | receiveCh := make(chan struct{}, len(args)) 186 | 187 | if progress { 188 | pb := mpb.New(mpb.WithAutoRefresh()) 189 | for i := 0; i < count; i++ { 190 | // if progress is true then show progress bar 191 | go download(sendCh, receiveCh, pb) 192 | } 193 | } else { 194 | go download(sendCh, receiveCh, nil) 195 | } 196 | 197 | for i := 0; i < count; i++ { 198 | // if progress is true then show progress bar 199 | switch progress { 200 | case true: 201 | go download(sendCh, receiveCh, mpb.New(mpb.WithAutoRefresh())) 202 | case false: 203 | go download(sendCh, receiveCh, nil) 204 | } 205 | } 206 | for _, path := range args { 207 | stat, err := p.GetFileStat(parentId, path) 208 | if err != nil { 209 | logrus.Errorln(path, "get parent id failed:", err) 210 | continue 211 | } 212 | 213 | file, err := p.GetFile(stat.ID) 214 | if err != nil { 215 | logrus.Errorln(path, "get file failed", err) 216 | continue 217 | } 218 | sendCh <- warpFile{ 219 | f: &file, 220 | output: output, 221 | } 222 | } 223 | close(sendCh) 224 | for i := 0; i < len(args); i++ { 225 | <-receiveCh 226 | } 227 | close(receiveCh) 228 | } 229 | 230 | func download(inCh <-chan warpFile, out chan<- struct{}, pb *mpb.Progress) { 231 | for { 232 | warp, ok := <-inCh 233 | if !ok { 234 | break 235 | } 236 | 237 | path := filepath.Join(warp.output, warp.f.Name) 238 | exist, err := utils.Exists(path) 239 | if err != nil { 240 | // logrus.Errorln("Access", path, "Failed:", err) 241 | out <- struct{}{} 242 | continue 243 | } 244 | flag := path + ".pikpakclidownload" 245 | hasFlag, err := utils.Exists(flag) 246 | if err != nil { 247 | // logrus.Errorln("Access", flag, "Failed:", err) 248 | out <- struct{}{} 249 | continue 250 | } 251 | if exist && !hasFlag { 252 | // logrus.Infoln("Skip downloaded file", warp.f.Name) 253 | out <- struct{}{} 254 | continue 255 | } 256 | err = utils.TouchFile(flag) 257 | if err != nil { 258 | // logrus.Errorln("Create flag file", flag, "Failed:", err) 259 | out <- struct{}{} 260 | continue 261 | } 262 | 263 | siz, err := strconv.ParseInt(warp.f.Size, 10, 64) 264 | if err != nil { 265 | // logrus.Errorln("Parse File size", warp.f.Size, "Failed:", err) 266 | out <- struct{}{} 267 | continue 268 | } 269 | 270 | var bar *mpb.Bar = nil 271 | 272 | // This is simple way to display part names 273 | // It may be replaced one day. 274 | trimeName := func(name string) string { 275 | // Fixed value, which is an unwise approach. 276 | maxLen := 30 277 | if utf8.RuneCountInString(name)+3 > maxLen { 278 | return name[:maxLen-3] + "..." 279 | } 280 | return name 281 | } 282 | 283 | if pb != nil { 284 | bar = pb.AddBar(siz, 285 | mpb.PrependDecorators( 286 | decor.Name(trimeName(warp.f.Name)), 287 | decor.Percentage(decor.WCSyncSpace), 288 | ), 289 | mpb.AppendDecorators( 290 | decor.EwmaETA(decor.ET_STYLE_GO, 30), 291 | decor.Name(" ] "), 292 | decor.EwmaSpeed(decor.SizeB1024(0), "% .2f", 60), 293 | ), 294 | ) 295 | } 296 | 297 | // start downloading 298 | err = warp.f.Download(path, bar) 299 | // if hasn't error then remove flag file 300 | if err == nil { 301 | if pb == nil { 302 | logrus.Infoln("Download", warp.f.Name, "Success") 303 | } 304 | os.Remove(flag) 305 | } else { 306 | if pb == nil { 307 | logrus.Errorln("Download", warp.f.Name, "Failed:", err) 308 | } 309 | } 310 | if bar != nil { 311 | bar.Abort(true) 312 | } 313 | out <- struct{}{} 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /internal/pikpak/upload.go: -------------------------------------------------------------------------------- 1 | package pikpak 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha1" 7 | "encoding/base64" 8 | "encoding/xml" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "math" 13 | "net/http" 14 | "net/url" 15 | "os" 16 | "path/filepath" 17 | "sort" 18 | "strconv" 19 | "strings" 20 | "sync" 21 | "time" 22 | 23 | "github.com/52funny/pikpakhash" 24 | jsoniter "github.com/json-iterator/go" 25 | "github.com/sirupsen/logrus" 26 | ) 27 | 28 | type OssArgs struct { 29 | Bucket string `json:"bucket"` 30 | AccessKeyId string `json:"access_key_id"` 31 | AccessKeySecret string `json:"access_key_secret"` 32 | EndPoint string `json:"endpoint"` 33 | Key string `json:"key"` 34 | SecurityToken string `json:"security_token"` 35 | } 36 | 37 | // 256k 38 | var defaultChunkSize int64 = 1 << 18 39 | var Concurrent int64 = 1 << 4 40 | 41 | var ErrNotFoundFolder = errors.New("not found pikpak folder") 42 | 43 | func (p *PikPak) UploadFile(parentId, path string) error { 44 | fileName := filepath.Base(path) 45 | fileState, err := os.Stat(path) 46 | if err != nil { 47 | return err 48 | } 49 | fileSize := fileState.Size() 50 | ph := pikpakhash.Default() 51 | hash, err := ph.HashFromPath(path) 52 | if err != nil { 53 | return err 54 | } 55 | m := map[string]interface{}{ 56 | "body": map[string]string{ 57 | "duration": "", 58 | "width": "", 59 | "height": "", 60 | }, 61 | "kind": "drive#file", 62 | "name": fileName, 63 | "size": fmt.Sprintf("%d", fileSize), 64 | "hash": hash, 65 | "upload_type": "UPLOAD_TYPE_RESUMABLE", 66 | "objProvider": map[string]string{ 67 | "provider": "UPLOAD_TYPE_UNKNOWN", 68 | }, 69 | } 70 | if parentId != "" { 71 | m["parent_id"] = parentId 72 | } 73 | bs, err := jsoniter.Marshal(&m) 74 | if err != nil { 75 | return err 76 | } 77 | START: 78 | req, err := http.NewRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs)) 79 | if err != nil { 80 | return err 81 | } 82 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 83 | req.Header.Set("Product_flavor_name", "cha") 84 | req.Header.Set("X-Captcha-Token", p.CaptchaToken) 85 | req.Header.Set("X-Client-Version-Code", "10083") 86 | req.Header.Set("X-Peer-Id", p.DeviceId) 87 | req.Header.Set("X-User-Region", "1") 88 | req.Header.Set("X-Alt-Capability", "3") 89 | req.Header.Set("Country", "CN") 90 | bs, err = p.sendRequest(req) 91 | if err != nil { 92 | return err 93 | } 94 | error_code := jsoniter.Get(bs, "error_code").ToInt() 95 | if error_code != 0 { 96 | if error_code == 9 { 97 | err = p.AuthCaptchaToken("POST:/drive/v1/files") 98 | if err != nil { 99 | return err 100 | } 101 | goto START 102 | } 103 | return fmt.Errorf("upload file error: %s", jsoniter.Get(bs, "error").ToString()) 104 | } 105 | // logrus.Debug(string(bs)) 106 | file := jsoniter.Get(bs, "file") 107 | phase := file.Get("phase").ToString() 108 | logrus.Debug("path: ", path, " phase: ", phase) 109 | 110 | switch phase { 111 | case "PHASE_TYPE_COMPLETE": 112 | logrus.Debug(path, " upload file complete") 113 | return nil 114 | case "PHASE_TYPE_PENDING": 115 | // break switch 116 | break 117 | } 118 | params := jsoniter.Get(bs, "resumable").Get("params") 119 | 120 | accessKeyId := params.Get("access_key_id").ToString() 121 | accessKeySecret := params.Get("access_key_secret").ToString() 122 | bucket := params.Get("bucket").ToString() 123 | endpoint := params.Get("endpoint").ToString() 124 | key := params.Get("key").ToString() 125 | securityToken := params.Get("security_token").ToString() 126 | ossArgs := OssArgs{ 127 | Bucket: bucket, 128 | AccessKeyId: accessKeyId, 129 | AccessKeySecret: accessKeySecret, 130 | EndPoint: endpoint, 131 | Key: key, 132 | SecurityToken: securityToken, 133 | } 134 | 135 | uploadId := p.beforeUpload(ossArgs) 136 | 137 | f, err := os.Open(path) 138 | if err != nil { 139 | return err 140 | } 141 | defer f.Close() 142 | 143 | wait := new(sync.WaitGroup) 144 | in_wait := new(sync.WaitGroup) 145 | ch := make(chan Part, Concurrent) 146 | 147 | var chunkSize = int64(math.Ceil(float64(fileSize) / 10000)) 148 | if chunkSize < defaultChunkSize { 149 | chunkSize = defaultChunkSize 150 | } 151 | 152 | for i := int64(0); i < Concurrent; i++ { 153 | wait.Add(1) 154 | go uploadChunk(wait, ch, f, chunkSize, fileSize, i, ossArgs, uploadId) 155 | } 156 | donePartSlice := make([]Part, 0) 157 | in_wait.Add(1) 158 | go func() { 159 | defer in_wait.Done() 160 | for p := range ch { 161 | donePartSlice = append(donePartSlice, p) 162 | } 163 | }() 164 | wait.Wait() 165 | close(ch) 166 | in_wait.Wait() 167 | sort.Slice(donePartSlice, func(i, j int) bool { 168 | iNum, _ := strconv.Atoi(donePartSlice[i].PartNumber) 169 | jNum, _ := strconv.Atoi(donePartSlice[j].PartNumber) 170 | return iNum < jNum 171 | }) 172 | args := CompleteMultipartUpload{ 173 | Part: donePartSlice, 174 | } 175 | err = p.afterUpload(&args, ossArgs, uploadId) 176 | if err != nil { 177 | return err 178 | } 179 | return nil 180 | } 181 | 182 | func uploadChunk(wait *sync.WaitGroup, ch chan Part, f *os.File, ChunkSize, fileSize int64, part int64, ossArgs OssArgs, uploadId string) { 183 | defer wait.Done() 184 | if part*ChunkSize >= fileSize { 185 | return 186 | } 187 | buf := make([]byte, ChunkSize) 188 | var offset = part * ChunkSize 189 | for offset < fileSize { 190 | n, _ := f.ReadAt(buf, offset) 191 | // if err != nil { 192 | // // logrus.Error(err) 193 | // } 194 | if n > 0 { 195 | value := url.Values{} 196 | value.Add("uploadId", uploadId) 197 | value.Add("partNumber", fmt.Sprintf("%d", part+1)) 198 | req, err := http.NewRequest("PUT", fmt.Sprintf("https://%s/%s?%s", 199 | ossArgs.EndPoint, 200 | ossArgs.Key, 201 | value.Encode()), bytes.NewBuffer(buf[:n])) 202 | if err != nil { 203 | continue 204 | } 205 | 206 | now := time.Now().UTC() 207 | req.Header.Set("Content-Type", "application/octet-stream") 208 | req.Header.Set("X-OSS-Security-Token", ossArgs.SecurityToken) 209 | req.Header.Set("Date", now.Format(http.TimeFormat)) 210 | req.Header.Set("Authorization", "OSS "+ossArgs.AccessKeyId+":"+hmacAuthorization(req, nil, now, ossArgs)) 211 | resp, err := http.DefaultClient.Do(req) 212 | if err != nil { 213 | continue 214 | } 215 | // bs, _ := io.ReadAll(resp.Body) 216 | eTag := strings.Trim(resp.Header.Get("ETag"), "\"") 217 | p := Part{ 218 | PartNumber: fmt.Sprintf("%d", part+1), 219 | ETag: eTag, 220 | } 221 | ch <- p 222 | resp.Body.Close() 223 | } 224 | part = part + Concurrent 225 | offset = part * ChunkSize 226 | } 227 | } 228 | 229 | type header struct { 230 | key string 231 | val string 232 | } 233 | 234 | type CompleteMultipartUpload struct { 235 | Part []Part `xml:"Part"` 236 | } 237 | type Part struct { 238 | PartNumber string `xml:"PartNumber"` 239 | ETag string `xml:"ETag"` 240 | } 241 | 242 | func hmacAuthorization(req *http.Request, body []byte, time time.Time, ossArgs OssArgs) string { 243 | date := time.UTC().Format(http.TimeFormat) 244 | stringBuilder := new(strings.Builder) 245 | stringBuilder.WriteString(req.Method + "\n") 246 | if body == nil { 247 | stringBuilder.WriteString("\n") 248 | } else { 249 | // digest := md5.New() 250 | // digest.Write(body) 251 | // sign := base64.StdEncoding.EncodeToString(digest.Sum(nil)) 252 | // stringBuilder.WriteString(sign + "\n") 253 | stringBuilder.WriteString("\n") 254 | } 255 | stringBuilder.WriteString(req.Header.Get("Content-Type") + "\n") 256 | stringBuilder.WriteString(date + "\n") 257 | 258 | headerSlice := make([]header, 0) 259 | for k, v := range req.Header { 260 | headerK := strings.ToLower(k) 261 | if strings.Contains(headerK, "x-oss-") && len(v) > 0 { 262 | headerSlice = append(headerSlice, header{headerK, v[0]}) 263 | } 264 | } 265 | 266 | // 从小到大排序 267 | sort.Slice(headerSlice, func(i, j int) bool { 268 | return headerSlice[i].key < headerSlice[j].key 269 | }) 270 | for _, hd := range headerSlice { 271 | stringBuilder.WriteString(hd.key + ":" + hd.val + "\n") 272 | } 273 | 274 | stringBuilder.WriteString("/" + ossArgs.Bucket + req.URL.Path + "?" + req.URL.RawQuery) 275 | 276 | h := hmac.New(sha1.New, []byte(ossArgs.AccessKeySecret)) 277 | h.Write([]byte(stringBuilder.String())) 278 | return base64.StdEncoding.EncodeToString(h.Sum(nil)) 279 | } 280 | 281 | func (p *PikPak) beforeUpload(ossArgs OssArgs) string { 282 | req, err := http.NewRequest("POST", "https://"+ossArgs.EndPoint+"/"+ossArgs.Key+"?uploads", nil) 283 | if err != nil { 284 | return "" 285 | } 286 | time := time.Now().UTC() 287 | req.Header.Set("Date", time.Format(http.TimeFormat)) 288 | req.Header.Set("Content-Type", "application/octet-stream") 289 | req.Header.Set("User-Agent", "aliyun-sdk-android/2.9.5(Linux/Android 11/ONEPLUS%20A6000;RKQ1.201217.002)") 290 | req.Header.Set("X-Oss-Security-Token", ossArgs.SecurityToken) 291 | req.Header.Set("Authorization", 292 | fmt.Sprintf("%s %s:%s", 293 | "OSS", 294 | ossArgs.AccessKeyId, 295 | hmacAuthorization(req, nil, time, ossArgs), 296 | )) 297 | 298 | resp, err := http.DefaultClient.Do(req) 299 | if err != nil { 300 | return "" 301 | } 302 | defer resp.Body.Close() 303 | bs, err := io.ReadAll(resp.Body) 304 | if err != nil { 305 | return "" 306 | } 307 | type InitiateMultipartUploadResult struct { 308 | Bucket string `xml:"Bucket"` 309 | Key string `xml:"Key"` 310 | UploadId string `xml:"UploadId"` 311 | } 312 | res := new(InitiateMultipartUploadResult) 313 | 314 | err = xml.Unmarshal(bs, res) 315 | if err != nil { 316 | return "" 317 | } 318 | return res.UploadId 319 | } 320 | 321 | func (p *PikPak) afterUpload(args *CompleteMultipartUpload, ossArgs OssArgs, uploadId string) error { 322 | bs, err := xml.Marshal(args) 323 | if err != nil { 324 | return err 325 | } 326 | req, err := http.NewRequest("POST", "https://"+ossArgs.EndPoint+"/"+ossArgs.Key+"?uploadId="+uploadId, bytes.NewBuffer(bs)) 327 | if err != nil { 328 | return err 329 | } 330 | time := time.Now().UTC() 331 | req.Header.Set("Date", time.Format(http.TimeFormat)) 332 | req.Header.Set("Content-Type", "application/octet-stream") 333 | req.Header.Set("User-Agent", "aliyun-sdk-android/2.9.5(Linux/Android 11/ONEPLUS%20A6000;RKQ1.201217.002)") 334 | req.Header.Set("X-Oss-Security-Token", ossArgs.SecurityToken) 335 | req.Header.Set("Authorization", 336 | fmt.Sprintf("%s %s:%s", 337 | "OSS", 338 | ossArgs.AccessKeyId, 339 | hmacAuthorization(req, nil, time, ossArgs), 340 | )) 341 | 342 | resp, err := http.DefaultClient.Do(req) 343 | if err != nil { 344 | return err 345 | } 346 | defer resp.Body.Close() 347 | _, err = io.ReadAll(resp.Body) 348 | if err != nil { 349 | return err 350 | } 351 | return nil 352 | } 353 | --------------------------------------------------------------------------------