Showing preview only (242K chars total). Download the full file or copy to clipboard to get everything.
Repository: 52funny/pikpakcli
Branch: master
Commit: fc6a7e2f2d46
Files: 66
Total size: 222.8 KB
Directory structure:
gitextract_u9bxj4ec/
├── .github/
│ └── workflows/
│ ├── dockerhub.yml
│ └── goreleaser.yml
├── .gitignore
├── .goreleaser.yaml
├── Dockerfile
├── LICENSE
├── README.md
├── README_zhCN.md
├── cli/
│ ├── del/
│ │ └── del.go
│ ├── download/
│ │ ├── download.go
│ │ ├── download_test.go
│ │ └── progress_test.go
│ ├── empty/
│ │ ├── empty.go
│ │ └── empty_test.go
│ ├── list/
│ │ └── list.go
│ ├── new/
│ │ ├── folder/
│ │ │ └── folder.go
│ │ ├── new.go
│ │ ├── sha/
│ │ │ └── sha.go
│ │ └── url/
│ │ └── url.go
│ ├── quota/
│ │ ├── quota.go
│ │ └── quota_test.go
│ ├── rename/
│ │ └── rename.go
│ ├── root.go
│ ├── rubbish/
│ │ ├── rubbish.go
│ │ └── rubbish_test.go
│ ├── share/
│ │ └── share.go
│ ├── shell.go
│ └── upload/
│ └── upload.go
├── conf/
│ └── config.go
├── config_example.yml
├── docs/
│ ├── command.md
│ ├── command_docker.md
│ ├── command_zhCN.md
│ ├── config.md
│ └── config_zhCN.md
├── go.mod
├── go.sum
├── internal/
│ ├── api/
│ │ ├── captcha_token.go
│ │ ├── constants.go
│ │ ├── download.go
│ │ ├── download_test.go
│ │ ├── file.go
│ │ ├── file_test.go
│ │ ├── folder.go
│ │ ├── glob.go
│ │ ├── glob_test.go
│ │ ├── pikpak.go
│ │ ├── quota.go
│ │ ├── quota_test.go
│ │ ├── refresh_token.go
│ │ ├── session.go
│ │ ├── sha.go
│ │ ├── upload.go
│ │ └── url.go
│ ├── logx/
│ │ └── logx.go
│ ├── shell/
│ │ ├── open.go
│ │ ├── shell.go
│ │ └── shell_test.go
│ └── utils/
│ ├── format.go
│ ├── format_test.go
│ ├── path.go
│ ├── path_test.go
│ └── sync.go
├── main.go
└── rules/
├── README.md
└── rubbish_rules.txt
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/dockerhub.yml
================================================
name: Publish Docker image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v2
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: 52funny/pikpakcli
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push Docker Hub
uses: docker/build-push-action@v6
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
file: ./Dockerfile
tags: ${{ steps.docker_meta.outputs.tags }}
================================================
FILE: .github/workflows/goreleaser.yml
================================================
name: goreleaser
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
================================================
FILE: .gitignore
================================================
.vscode
.pikpaksync.txt
config.yml
pikpakcli
dist
dist/
================================================
FILE: .goreleaser.yaml
================================================
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
format: zip
files:
- config_example.yml
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
================================================
FILE: Dockerfile
================================================
FROM golang:1.21-alpine AS builder
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags "-s -w" -o /usr/local/bin/pikpakcli ./main.go
FROM alpine:3.18
RUN apk add --no-cache ca-certificates
COPY --from=builder /usr/local/bin/pikpakcli /usr/local/bin/pikpakcli
WORKDIR /root
ENTRYPOINT ["/usr/local/bin/pikpakcli"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 52funny
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# PikPak CLI


English | [简体中文](https://github.com/52funny/pikpakcli/blob/master/README_zhCN.md)
PikPakCli is a command line tool for Pikpak Cloud.

## Installation
### Compiling from source code
To build the tool from the source code, ensure you have [Go](https://go.dev/doc/install) installed on your system.
Clone the project:
```bash
git clone https://github.com/52funny/pikpakcli
```
Build the project:
```bash
go build
```
Run the tool:
```
./pikpakcli
```
### Build with Docker
You can also run `pikpakcli` using Docker.
Pull the Docker image:
```bash
docker pull 52funny/pikpakcli:master
```
Run the tool:
```bash
docker run --rm 52funny/pikpakcli:master --help
```
### Download from Release
Download the executable file you need from the [Releases](https://github.com/52funny/pikpakcli/releases) page, then run it.
## Configuration
First, configure the `config_example.yml` file in the project, entering your account details.
If your account uses a phone number, it must be preceded by the country code, like `+861xxxxxxxxxx`.
Then, rename it to `config.yml`.
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:
- Linux: `$HOME/.config/pikpakcli`
- Darwin: `$HOME/Library/Application Support/pikpakcli`
- Windows: `%AppData%/pikpakcli`
The optional `open` section can override which local program is used by the interactive shell `open` builtin for different file categories.
> **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:
```bash
docker run -v /path/to/your/config.yml:/root/.config/pikpakcli/config.yml pikpakcli:latest ls
# if your config.yml is in the project directory, you can just run:
docker run -v $PWD/config.yml:/root/.config/pikpakcli/config.yml pikpakcli:latest ls
```
## Get started
After that you can run the `ls` command to see the files stored on **PikPak**.
```bash
./pikpakcli ls
```
## Usage
See [Command](docs/command.md) for more commands information.
## Contributors
<a href = "https://github.com/52funny/pikpakcli/graphs/contributors">
<img src = "https://contrib.rocks/image?repo=52funny/pikpakcli"/>
</a>
================================================
FILE: README_zhCN.md
================================================
# PikPak CLI


PikPakCli 是 PikPak 的命令行工具。

## 安装方法
### 从源码编译
要从源代码构建该工具,请确保您的系统已安装 [Go](https://go.dev/doc/install) 环境。
克隆项目
```bash
git clone https://github.com/52funny/pikpakcli
```
生成可执行文件
```bash
go build
```
运行
```bash
./pikpakcli
```
### 从 Release 下载
从 Release 下载你所需要的版本,然后运行。
## 配置文件
首先将项目中的 `config_example.yml` 配置一下,输入自己的账号密码
如果账号是手机号,手机号要以区号开头。如 `+861xxxxxxxxxx`
然后将其重命名为 `config.yml`
配置文件将会优先从当前目录进行读取 `config.yml`,如果当前目录下不存在 `config.yml` 将会从用户的配置数据的默认根目录进行读取,各个平台的默认根目录如下:
- Linux: `$HOME/.config/pikpakcli`
- Darwin: `$HOME/Library/Application Support/pikpakcli`
- Windows: `%AppData%/pikpakcli`
可选的 `open` 配置段可以覆盖交互式 shell 中 `open` 内置命令针对不同文件类型使用的本地程序。
## 开始
之后你就可以运行 `ls` 指令来查看存储在 **PikPak** 上的文件了
```bash
./pikpakcli ls
```
## 用法
参阅 [Command](docs/command_zhCN.md) 查看更多的指令
## 贡献者
<a href = "https://github.com/52funny/pikpakcli/graphs/contributors">
<img src = "https://contrib.rocks/image?repo=52funny/pikpakcli"/>
</a>
================================================
FILE: cli/del/del.go
================================================
package delete
import (
"fmt"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/spf13/cobra"
)
var path string
var DeleteCmd = &cobra.Command{
Use: "delete [file-or-folder ...]",
Aliases: []string{"del", "rm"},
Short: "Delete files or folders on the PikPak server",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
if err := p.Login(); err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
flagPathSpecified := cmd.Flags().Changed("path")
args, err := api.ExpandRemotePatterns(&p, path, args, flagPathSpecified)
if err != nil {
fmt.Println("Expand delete target failed")
logx.Error(err)
return
}
for parentPath, names := range groupDeleteTargets(args, flagPathSpecified) {
if err := deleteEntries(&p, parentPath, names); err != nil {
fmt.Println("Delete entries failed")
logx.Error(err)
}
}
},
}
func init() {
DeleteCmd.Flags().StringVarP(&path, "path", "p", "/", "The path where to look for the file")
}
func groupDeleteTargets(args []string, forceParentPath bool) map[string][]string {
targets := make(map[string][]string)
for _, arg := range args {
parentPath := path
name := arg
if !forceParentPath || strings.HasPrefix(arg, "/") || strings.Contains(arg, "/") {
resolvedParentPath, resolvedName := utils.SplitRemotePath(arg)
if resolvedName == "" {
continue
}
name = resolvedName
if resolvedParentPath == "" {
parentPath = "/"
} else {
parentPath = resolvedParentPath
}
}
targets[parentPath] = append(targets[parentPath], name)
}
return targets
}
func deleteEntries(p *api.PikPak, parentPath string, names []string) error {
parentID, err := p.GetPathFolderId(parentPath)
if err != nil {
return fmt.Errorf("get path folder id for %s failed: %w", parentPath, err)
}
files, err := p.GetFolderFileStatList(parentID)
if err != nil {
return fmt.Errorf("get file list for %s failed: %w", parentPath, err)
}
fileIndex := make(map[string]api.FileStat, len(files))
for _, file := range files {
fileIndex[file.Name] = file
}
for _, name := range names {
file, ok := fileIndex[name]
if !ok {
fmt.Printf("Entry not found in %s: %s\n", parentPath, name)
continue
}
if err := p.DeleteFile(file.ID); err != nil {
fmt.Printf("Delete %s from %s failed\n", name, parentPath)
logx.Error(err)
continue
}
fmt.Printf("Deleted %s from %s\n", name, parentPath)
}
return nil
}
================================================
FILE: cli/download/download.go
================================================
package download
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/spf13/cobra"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
)
var DownloadCmd = &cobra.Command{
Use: "download",
Aliases: []string{"d"},
Short: `Download file from pikpak server`,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
cmd.Help()
return
}
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
args, err = api.ExpandRemotePatterns(&p, folder, args, false)
if err != nil {
fmt.Println("Expand download target failed")
logx.Error(err)
return
}
handleDownload(cmd, &p, args)
},
}
// Number of simultaneous downloads
//
// default 1
var count int
// Specifies the folder of the pikpak server
//
// default server root directory (.)
var folder string
// parent path id
var parentId string
// Output directory
//
// default current directory (.)
var output string
// Progress bar
//
// default false
var progress bool
type warpFile struct {
f *api.File
output string
}
type warpStat struct {
s api.FileStat
output string
}
const progressNameMaxRunes = 36
func init() {
DownloadCmd.Flags().IntVarP(&count, "count", "c", 1, "number of simultaneous downloads")
DownloadCmd.Flags().StringVarP(&output, "output", "o", ".", "output directory")
DownloadCmd.Flags().StringVarP(&folder, "path", "p", "/", "specific the base path on the pikpak server")
DownloadCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "the parent path id")
DownloadCmd.Flags().BoolVarP(&progress, "progress", "g", false, "show download progress")
}
type downloadTargetResolver interface {
GetFileByPath(path string) (api.FileStat, error)
GetFileStat(parentId string, name string) (api.FileStat, error)
GetPathFolderId(dirPath string) (string, error)
}
func handleDownload(cmd *cobra.Command, p *api.PikPak, args []string) {
if err := utils.CreateDirIfNotExist(output); err != nil {
fmt.Println("Create output directory failed")
logx.Error(err)
return
}
if requiresExplicitOutputFlag(cmd, args) {
fmt.Println("Use -o to specify the output directory when downloading specific files")
return
}
for _, arg := range args {
downloadTarget(p, arg)
}
}
func requiresExplicitOutputFlag(cmd *cobra.Command, args []string) bool {
if cmd.Flags().Changed("output") || len(args) <= 1 {
return false
}
for _, arg := range args {
trimmed := strings.TrimSpace(arg)
if trimmed == "." || trimmed == ".." {
return true
}
}
return false
}
func downloadTarget(p *api.PikPak, arg string) {
stat, err := resolveDownloadTarget(p, arg)
if err != nil {
target := remoteTargetPath(arg)
fmt.Println("Resolve download target failed:", target)
logx.Error(err)
return
}
if stat.Kind == api.FileKindFolder {
downloadFolder(p, stat.ID, localOutputRoot(stat.Name))
return
}
downloadFiles(p, []warpFile{
{
f: mustGetFile(p, stat),
output: output,
},
})
}
func downloadFolder(p *api.PikPak, folderID string, rootOutput string) {
collectStat := make([]warpStat, 0)
recursive(p, &collectStat, folderID, rootOutput)
downloadStats(p, collectStat)
}
func downloadStats(p *api.PikPak, collectStat []warpStat) {
if len(collectStat) == 0 {
return
}
statCh := make(chan warpStat, len(collectStat))
statDone := make(chan struct{})
fileCh := make(chan warpFile, len(collectStat))
fileDone := make(chan struct{})
for i := 0; i < 4; i += 1 {
go func(fileCh chan<- warpFile, statCh <-chan warpStat, statDone chan<- struct{}) {
for {
stat, ok := <-statCh
if !ok {
break
}
file, err := p.GetFile(stat.s.ID)
if err != nil {
fmt.Println("Get file failed")
logx.Error(err)
}
fileCh <- warpFile{
f: &file,
output: stat.output,
}
statDone <- struct{}{}
}
}(fileCh, statCh, statDone)
}
pb := startDownloadWorkers(fileCh, fileDone)
for i := 0; i < len(collectStat); i += 1 {
err := utils.CreateDirIfNotExist(collectStat[i].output)
if err != nil {
fmt.Println("Create output directory failed")
logx.Error(err)
return
}
statCh <- collectStat[i]
}
close(statCh)
for i := 0; i < len(collectStat); i += 1 {
<-statDone
}
close(statDone)
for i := 0; i < len(collectStat); i += 1 {
<-fileDone
}
if pb != nil {
pb.Wait()
}
}
func recursive(p *api.PikPak, collectWarpFile *[]warpStat, parentId string, parentPath string) {
statList, err := p.GetFolderFileStatList(parentId)
if err != nil {
fmt.Println("Get folder file stat list failed")
logx.Error(err)
return
}
for _, r := range statList {
if r.Kind == api.FileKindFolder {
recursive(p, collectWarpFile, r.ID, filepath.Join(parentPath, r.Name))
} else {
// file, _ := p.GetFile(r.ID)
*collectWarpFile = append(*collectWarpFile, warpStat{
s: r,
output: parentPath,
})
// fmt.Println(r.Name, r.Size, r.Kind, parentPath)
}
}
}
func downloadFiles(p *api.PikPak, files []warpFile) {
sendCh := make(chan warpFile, len(files))
receiveCh := make(chan struct{}, len(files))
pb := startDownloadWorkers(sendCh, receiveCh)
for _, file := range files {
sendCh <- file
}
close(sendCh)
for i := 0; i < len(files); i++ {
<-receiveCh
}
close(receiveCh)
if pb != nil {
pb.Wait()
}
}
func startDownloadWorkers(sendCh <-chan warpFile, receiveCh chan<- struct{}) *mpb.Progress {
var pb *mpb.Progress
if progress {
pb = mpb.New(
mpb.WithWidth(30),
mpb.WithAutoRefresh(),
)
}
for i := 0; i < count; i++ {
go download(sendCh, receiveCh, pb)
}
return pb
}
func resolveDownloadTarget(p downloadTargetResolver, arg string) (api.FileStat, error) {
if target := strings.TrimSpace(arg); target == "" {
if parentId != "" {
return api.FileStat{
Kind: api.FileKindFolder,
ID: parentId,
Name: filepath.Base(filepath.Clean(folder)),
}, nil
}
remotePath := remoteTargetPath("")
if remotePath == string(filepath.Separator) {
id, err := p.GetPathFolderId(folder)
if err != nil {
return api.FileStat{}, err
}
return api.FileStat{
Kind: api.FileKindFolder,
ID: id,
Name: "",
}, nil
}
return p.GetFileByPath(remotePath)
}
if parentId != "" && !filepath.IsAbs(arg) && !strings.Contains(arg, string(filepath.Separator)) {
return p.GetFileStat(parentId, arg)
}
return p.GetFileByPath(remoteTargetPath(arg))
}
func remoteTargetPath(arg string) string {
base := strings.TrimSpace(folder)
target := strings.TrimSpace(arg)
if target == "" {
target = "."
}
if filepath.IsAbs(target) {
return filepath.Clean(target)
}
return filepath.Clean(filepath.Join(string(filepath.Separator), base, target))
}
func localOutputRoot(name string) string {
if strings.TrimSpace(name) == "" || name == string(filepath.Separator) || name == "." {
return output
}
return filepath.Join(output, name)
}
func mustGetFile(p *api.PikPak, stat api.FileStat) *api.File {
file, err := p.GetFile(stat.ID)
if err != nil {
fmt.Println("Get file failed")
logx.Error(err)
return &api.File{FileStat: stat}
}
return &file
}
func progressDisplayName(warp warpFile) string {
name := warp.f.Name
if base := filepath.Base(filepath.Clean(warp.output)); base != "." && base != string(filepath.Separator) && base != "" {
name = filepath.Join(base, name)
}
return trimRunes(name, progressNameMaxRunes)
}
func trimRunes(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
return value
}
if max <= 3 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}
func download(inCh <-chan warpFile, out chan<- struct{}, pb *mpb.Progress) {
for {
warp, ok := <-inCh
if !ok {
break
}
path := filepath.Join(warp.output, warp.f.Name)
exist, err := utils.Exists(path)
if err != nil {
// logrus.Errorln("Access", path, "Failed:", err)
out <- struct{}{}
continue
}
flag := path + ".pikpakclidownload"
hasFlag, err := utils.Exists(flag)
if err != nil {
// logrus.Errorln("Access", flag, "Failed:", err)
out <- struct{}{}
continue
}
if exist && !hasFlag {
// logrus.Infoln("Skip downloaded file", warp.f.Name)
out <- struct{}{}
continue
}
err = utils.TouchFile(flag)
if err != nil {
// logrus.Errorln("Create flag file", flag, "Failed:", err)
out <- struct{}{}
continue
}
siz, err := strconv.ParseInt(warp.f.Size, 10, 64)
if err != nil {
// logrus.Errorln("Parse File size", warp.f.Size, "Failed:", err)
out <- struct{}{}
continue
}
var bar *mpb.Bar
if pb != nil {
bar = pb.AddBar(siz,
mpb.PrependDecorators(
decor.Name(progressDisplayName(warp), decor.WC{W: progressNameMaxRunes + 2, C: decor.DSyncWidth}),
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncSpace),
decor.Percentage(decor.WCSyncSpace),
),
mpb.AppendDecorators(
decor.Name(" | ", decor.WCSyncSpace),
decor.Name("ETA ", decor.WCSyncSpace),
decor.EwmaETA(decor.ET_STYLE_GO, 30),
decor.Name(" | ", decor.WCSyncSpace),
decor.Name("SPD ", decor.WCSyncSpace),
decor.EwmaSpeed(decor.SizeB1024(0), "% .2f", 60),
),
)
}
// start downloading
err = warp.f.Download(path, bar)
// if hasn't error then remove flag file
if err == nil {
if pb == nil {
fmt.Println("Download", warp.f.Name, "Success")
}
os.Remove(flag)
if bar != nil {
bar.SetTotal(siz, true)
}
} else {
if pb == nil {
fmt.Println("Download failed:", warp.f.Name)
logx.Error(err)
}
if bar != nil {
bar.Abort(false)
}
}
out <- struct{}{}
}
}
================================================
FILE: cli/download/download_test.go
================================================
package download
import (
"errors"
"path/filepath"
"testing"
"github.com/52funny/pikpakcli/internal/api"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
type fakeTargetResolver struct {
getFileByPath func(path string) (api.FileStat, error)
getFileStat func(parentId string, name string) (api.FileStat, error)
getPathFolder func(dirPath string) (string, error)
}
func (f fakeTargetResolver) GetFileByPath(path string) (api.FileStat, error) {
return f.getFileByPath(path)
}
func (f fakeTargetResolver) GetFileStat(parentId string, name string) (api.FileStat, error) {
return f.getFileStat(parentId, name)
}
func (f fakeTargetResolver) GetPathFolderId(dirPath string) (string, error) {
return f.getPathFolder(dirPath)
}
func TestRemoteTargetPathJoinsBasePath(t *testing.T) {
originalFolder := folder
t.Cleanup(func() {
folder = originalFolder
})
folder = "/Movies"
require.Equal(t, filepath.Clean("/Movies/Kids/Peppa.mp4"), remoteTargetPath("Kids/Peppa.mp4"))
require.Equal(t, filepath.Clean("/TV"), remoteTargetPath("/TV"))
}
func TestResolveDownloadTargetUsesParentIDForDirectChild(t *testing.T) {
originalFolder := folder
originalParentID := parentId
t.Cleanup(func() {
folder = originalFolder
parentId = originalParentID
})
folder = "/Movies"
parentId = "parent-123"
resolver := fakeTargetResolver{
getFileStat: func(gotParentID string, gotName string) (api.FileStat, error) {
require.Equal(t, "parent-123", gotParentID)
require.Equal(t, "Peppa.mp4", gotName)
return api.FileStat{ID: "file-1", Name: "Peppa.mp4"}, nil
},
getFileByPath: func(path string) (api.FileStat, error) {
return api.FileStat{}, errors.New("should not resolve by path")
},
getPathFolder: func(dirPath string) (string, error) {
return "", errors.New("should not resolve folder id")
},
}
stat, err := resolveDownloadTarget(resolver, "Peppa.mp4")
require.NoError(t, err)
require.Equal(t, "file-1", stat.ID)
}
func TestResolveDownloadTargetJoinsBasePathForNestedArg(t *testing.T) {
originalFolder := folder
originalParentID := parentId
t.Cleanup(func() {
folder = originalFolder
parentId = originalParentID
})
folder = "/Movies"
parentId = "parent-123"
resolver := fakeTargetResolver{
getFileStat: func(parentId string, name string) (api.FileStat, error) {
return api.FileStat{}, errors.New("should not resolve direct child")
},
getFileByPath: func(path string) (api.FileStat, error) {
require.Equal(t, filepath.Clean("/Movies/Kids/Peppa.mp4"), path)
return api.FileStat{ID: "file-2", Name: "Peppa.mp4"}, nil
},
getPathFolder: func(dirPath string) (string, error) {
return "", errors.New("should not resolve folder id")
},
}
stat, err := resolveDownloadTarget(resolver, "Kids/Peppa.mp4")
require.NoError(t, err)
require.Equal(t, "file-2", stat.ID)
}
func TestResolveDownloadTargetWithoutArgsUsesBaseFolder(t *testing.T) {
originalFolder := folder
originalParentID := parentId
t.Cleanup(func() {
folder = originalFolder
parentId = originalParentID
})
folder = "/Movies"
parentId = ""
resolver := fakeTargetResolver{
getFileByPath: func(path string) (api.FileStat, error) {
require.Equal(t, filepath.Clean("/Movies"), path)
return api.FileStat{Kind: api.FileKindFolder, ID: "folder-1", Name: "Movies"}, nil
},
getFileStat: func(parentId string, name string) (api.FileStat, error) {
return api.FileStat{}, errors.New("should not resolve by parent id")
},
getPathFolder: func(dirPath string) (string, error) {
return "", errors.New("should not resolve folder id")
},
}
stat, err := resolveDownloadTarget(resolver, "")
require.NoError(t, err)
require.Equal(t, "folder-1", stat.ID)
require.Equal(t, api.FileKindFolder, stat.Kind)
}
func TestRequiresExplicitOutputFlag(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().StringP("output", "o", ".", "")
require.False(t, requiresExplicitOutputFlag(cmd, []string{"."}))
require.True(t, requiresExplicitOutputFlag(cmd, []string{"file.txt", "."}))
require.True(t, requiresExplicitOutputFlag(cmd, []string{"file.txt", ".."}))
require.False(t, requiresExplicitOutputFlag(cmd, []string{"file.txt"}))
require.NoError(t, cmd.Flags().Set("output", "."))
require.False(t, requiresExplicitOutputFlag(cmd, []string{"file.txt", "."}))
}
================================================
FILE: cli/download/progress_test.go
================================================
package download
import (
"path/filepath"
"testing"
"github.com/52funny/pikpakcli/internal/api"
"github.com/stretchr/testify/require"
)
func TestTrimRunes(t *testing.T) {
require.Equal(t, "abcdef", trimRunes("abcdef", 6))
require.Equal(t, "你好世...", trimRunes("你好世界欢迎你", 6))
}
func TestProgressDisplayNameIncludesParentDir(t *testing.T) {
warp := warpFile{
f: &api.File{FileStat: api.FileStat{Name: "Peppa.mp4"}},
output: filepath.Join("Film", "Kids"),
}
require.Equal(t, "Kids/Peppa.mp4", progressDisplayName(warp))
}
================================================
FILE: cli/empty/empty.go
================================================
package empty
import (
"context"
"errors"
"fmt"
"path/filepath"
"sync"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var targetPath string
var concurrency int
var deleteMode bool
type emptyFolderProvider interface {
GetPathFolderId(dirPath string) (string, error)
GetFolderFileStatList(parentId string) ([]api.FileStat, error)
DeleteFile(fileId string) error
}
var EmptyCmd = &cobra.Command{
Use: "empty [path]",
Short: "Recursively list empty folders on the PikPak server",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
path := targetPath
if len(args) > 0 {
path = args[0]
}
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
if err := p.Login(); err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
emptyFolders, err := handleEmptyFolders(cmd.Context(), &p, path, concurrency, deleteMode)
if err != nil {
if errors.Is(err, context.Canceled) {
fmt.Println("Empty folder scan canceled")
return
}
fmt.Println("Handle empty folders failed")
logx.Error(err)
return
}
if len(emptyFolders) == 0 {
fmt.Printf("No empty folders found under %s\n", path)
return
}
for _, folder := range emptyFolders {
if deleteMode {
fmt.Printf("Deleted empty folder: %s\n", folder)
continue
}
fmt.Printf("Empty folder: %s\n", folder)
}
},
}
func init() {
EmptyCmd.Flags().StringVarP(&targetPath, "path", "p", "/", "The path where to remove empty folders recursively")
EmptyCmd.Flags().IntVarP(&concurrency, "concurrency", "c", 8, "number of folders to process concurrently")
EmptyCmd.Flags().BoolVarP(&deleteMode, "delete", "d", false, "delete the empty folders instead of only listing them")
}
func handleEmptyFolders(ctx context.Context, p emptyFolderProvider, rootPath string, concurrency int, deleteMode bool) ([]string, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
rootID, err := p.GetPathFolderId(rootPath)
if err != nil {
return nil, err
}
if concurrency < 1 {
concurrency = 1
}
deleted := make([]string, 0)
state := emptyWalkState{
sem: make(chan struct{}, concurrency),
}
if _, err := walkEmptyFolders(ctx, p, rootID, filepath.Clean(rootPath), filepath.Clean(rootPath) != string(filepath.Separator), deleteMode, &deleted, &state); err != nil {
return nil, err
}
return deleted, nil
}
type emptyWalkState struct {
sem chan struct{}
mu sync.Mutex
}
type emptyFolderResult struct {
empty bool
err error
}
func walkEmptyFolders(ctx context.Context, p emptyFolderProvider, folderID, currentPath string, allowDeleteCurrent bool, deleteMode bool, deleted *[]string, state *emptyWalkState) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
files, err := p.GetFolderFileStatList(folderID)
if err != nil {
return false, err
}
hasFiles := false
hasRemainingFolders := false
results := make(chan emptyFolderResult, len(files))
var childFolders int
for _, file := range files {
if err := ctx.Err(); err != nil {
return false, err
}
if file.Kind != api.FileKindFolder {
hasFiles = true
continue
}
childFolders++
childPath := filepath.Join(currentPath, file.Name)
select {
case <-ctx.Done():
return false, ctx.Err()
case state.sem <- struct{}{}:
go func(file api.FileStat, childPath string) {
defer func() {
<-state.sem
}()
childEmpty, err := walkEmptyFolders(ctx, p, file.ID, childPath, true, deleteMode, deleted, state)
results <- emptyFolderResult{
empty: childEmpty,
err: err,
}
}(file, childPath)
default:
childEmpty, err := walkEmptyFolders(ctx, p, file.ID, childPath, true, deleteMode, deleted, state)
results <- emptyFolderResult{
empty: childEmpty,
err: err,
}
}
}
for i := 0; i < childFolders; i++ {
select {
case <-ctx.Done():
return false, ctx.Err()
case result := <-results:
if result.err != nil {
return false, result.err
}
if !result.empty {
hasRemainingFolders = true
}
}
}
isEmpty := !hasFiles && !hasRemainingFolders
if !isEmpty {
return false, nil
}
if !allowDeleteCurrent {
return true, nil
}
if deleteMode {
if err := ctx.Err(); err != nil {
return false, err
}
if err := p.DeleteFile(folderID); err != nil {
return false, err
}
}
state.mu.Lock()
*deleted = append(*deleted, currentPath)
state.mu.Unlock()
return true, nil
}
================================================
FILE: cli/empty/empty_test.go
================================================
package empty
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"github.com/52funny/pikpakcli/internal/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeEmptyFolderProvider struct {
rootID string
pathToID map[string]string
folders map[string][]api.FileStat
deletedFiles []string
mu sync.Mutex
}
func (f *fakeEmptyFolderProvider) GetPathFolderId(dirPath string) (string, error) {
if id, ok := f.pathToID[filepath.Clean(dirPath)]; ok {
return id, nil
}
return "", errors.New("path not found")
}
func (f *fakeEmptyFolderProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {
f.mu.Lock()
defer f.mu.Unlock()
files := f.folders[parentId]
cloned := make([]api.FileStat, len(files))
copy(cloned, files)
return cloned, nil
}
func (f *fakeEmptyFolderProvider) DeleteFile(fileId string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deletedFiles = append(f.deletedFiles, fileId)
for parentID, files := range f.folders {
filtered := files[:0]
for _, file := range files {
if file.ID != fileId {
filtered = append(filtered, file)
}
}
f.folders[parentID] = filtered
}
delete(f.folders, fileId)
return nil
}
func TestHandleEmptyFoldersDeletesNestedEmptyFolders(t *testing.T) {
provider := &fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/"): "root",
},
folders: map[string][]api.FileStat{
"root": {
{ID: "movies", Name: "Movies", Kind: api.FileKindFolder},
{ID: "music", Name: "Music", Kind: api.FileKindFolder},
{ID: "video", Name: "video.mp4", Kind: api.FileKindFile},
},
"movies": {
{ID: "kids", Name: "Kids", Kind: api.FileKindFolder},
},
"kids": {},
"music": {},
},
}
deleted, err := handleEmptyFolders(context.Background(), provider, "/", 4, true)
require.NoError(t, err)
assert.ElementsMatch(t, []string{filepath.Clean("/Movies/Kids"), filepath.Clean("/Movies"), filepath.Clean("/Music")}, deleted)
assert.ElementsMatch(t, []string{"kids", "movies", "music"}, provider.deletedFiles)
}
func TestHandleEmptyFoldersSkipsNonEmptyRootTarget(t *testing.T) {
provider := &fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/Movies"): "movies",
},
folders: map[string][]api.FileStat{
"movies": {
{ID: "episode", Name: "episode.mkv", Kind: api.FileKindFile},
},
},
}
deleted, err := handleEmptyFolders(context.Background(), provider, "/Movies", 4, true)
require.NoError(t, err)
assert.Empty(t, deleted)
assert.Empty(t, provider.deletedFiles)
}
func TestHandleEmptyFoldersDeletesTargetWhenItBecomesEmpty(t *testing.T) {
provider := &fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/Movies"): "movies",
},
folders: map[string][]api.FileStat{
"movies": {
{ID: "kids", Name: "Kids", Kind: api.FileKindFolder},
},
"kids": {},
},
}
deleted, err := handleEmptyFolders(context.Background(), provider, "/Movies", 4, true)
require.NoError(t, err)
assert.Equal(t, []string{filepath.Clean("/Movies/Kids"), filepath.Clean("/Movies")}, deleted)
assert.Equal(t, []string{"kids", "movies"}, provider.deletedFiles)
}
func TestHandleEmptyFoldersNormalizesInvalidConcurrency(t *testing.T) {
provider := &fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/Movies"): "movies",
},
folders: map[string][]api.FileStat{
"movies": {},
},
}
deleted, err := handleEmptyFolders(context.Background(), provider, "/Movies", 0, true)
require.NoError(t, err)
assert.Equal(t, []string{filepath.Clean("/Movies")}, deleted)
assert.Equal(t, []string{"movies"}, provider.deletedFiles)
}
func TestHandleEmptyFoldersListsWithoutDeleting(t *testing.T) {
provider := &fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/"): "root",
},
folders: map[string][]api.FileStat{
"root": {
{ID: "movies", Name: "Movies", Kind: api.FileKindFolder},
},
"movies": {},
},
}
emptyFolders, err := handleEmptyFolders(context.Background(), provider, "/", 4, false)
require.NoError(t, err)
assert.Equal(t, []string{filepath.Clean("/Movies")}, emptyFolders)
assert.Empty(t, provider.deletedFiles)
}
type blockingEmptyFolderProvider struct {
fakeEmptyFolderProvider
block chan struct{}
}
func (f *blockingEmptyFolderProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {
if parentId == "slow" {
<-f.block
}
return f.fakeEmptyFolderProvider.GetFolderFileStatList(parentId)
}
func TestHandleEmptyFoldersHonorsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
provider := &fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/"): "root",
},
folders: map[string][]api.FileStat{
"root": {},
},
}
deleted, err := handleEmptyFolders(ctx, provider, "/", 4, false)
require.ErrorIs(t, err, context.Canceled)
assert.Nil(t, deleted)
}
func TestHandleEmptyFoldersStopsWaitingAfterCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
provider := &blockingEmptyFolderProvider{
fakeEmptyFolderProvider: fakeEmptyFolderProvider{
pathToID: map[string]string{
filepath.Clean("/"): "root",
},
folders: map[string][]api.FileStat{
"root": {
{ID: "slow", Name: "slow", Kind: api.FileKindFolder},
},
"slow": {},
},
},
block: make(chan struct{}),
}
done := make(chan error, 1)
go func() {
_, err := handleEmptyFolders(ctx, provider, "/", 4, false)
done <- err
}()
cancel()
select {
case err := <-done:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
t.Fatal("handleEmptyFolders did not stop promptly after cancellation")
}
close(provider.block)
}
================================================
FILE: cli/list/list.go
================================================
package list
import (
"fmt"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
var long bool
var human bool
var path string
var parentId string
var ListCmd = &cobra.Command{
Use: "ls",
Short: `Get the directory information under the specified folder`,
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
long, _ := cmd.Flags().GetBool("long")
human, _ := cmd.Flags().GetBool("human")
path, _ := cmd.Flags().GetString("path")
parentId, _ := cmd.Flags().GetString("parent-id")
handle(&p, args, long, human, path, parentId)
},
}
func init() {
ListCmd.Flags().BoolVarP(&human, "human", "H", false, "display human readable format")
ListCmd.Flags().BoolVarP(&long, "long", "l", false, "display long format")
ListCmd.Flags().StringVarP(&path, "path", "p", "/", "display the specified path")
ListCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "display the specified parent id")
}
func handle(p *api.PikPak, args []string, long, human bool, path, parentId string) {
var err error
if len(args) > 0 {
path = args[0]
}
if parentId == "" {
parentId, err = p.GetPathFolderId(path)
if err != nil {
fmt.Println("Get path folder id error")
logx.Error(err)
return
}
}
files, err := p.GetFolderFileStatList(parentId)
if err != nil {
fmt.Println("Get folder file stat list error")
logx.Error(err)
return
}
for _, file := range files {
if long {
display(2, &file)
} else {
display(0, &file)
}
}
}
// mode 0: normal print
// mode 2: long format
func display(mode int, file *api.FileStat) {
size := utils.FormatStorage(file.Size, human)
switch mode {
case 0:
if file.Kind == api.FileKindFolder {
fmt.Printf("%-20s\n", color.GreenString(file.Name))
} else {
fmt.Printf("%-20s\n", file.Name)
}
case 2:
if file.Kind == api.FileKindFolder {
fmt.Printf("%-26s %-8s %-19s %s\n", file.ID, size, file.CreatedTime.Format("2006-01-02 15:04:05"), color.GreenString(file.Name))
} else {
fmt.Printf("%-26s %-8s %-19s %s\n", file.ID, size, file.CreatedTime.Format("2006-01-02 15:04:05"), file.Name)
}
}
}
================================================
FILE: cli/new/folder/folder.go
================================================
package folder
import (
"fmt"
"path/filepath"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var NewFolderCommand = &cobra.Command{
Use: "folder",
Short: `Create a folder to pikpak server`,
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
if len(args) > 0 {
handleNewFolder(&p, args)
} else {
fmt.Println("Please input the folder name")
}
},
}
var path string
var parentId string
func init() {
NewFolderCommand.Flags().StringVarP(&path, "path", "p", "/", "The path of the folder")
NewFolderCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "The parent id")
}
// new folder
func handleNewFolder(p *api.PikPak, folders []string) {
baseParentID := parentId
var err error
if baseParentID == "" {
baseParentID, err = p.GetPathFolderId(path)
if err != nil {
fmt.Println("Get parent id failed")
logx.Error(err)
return
}
}
for _, folder := range folders {
folder = strings.TrimSpace(folder)
if folder == "" {
fmt.Println("Folder name cannot be empty")
continue
}
cleanFolder := filepath.Clean(folder)
if cleanFolder == "." || cleanFolder == string(filepath.Separator) {
fmt.Printf("Folder path is invalid: %s\n", folder)
continue
}
createParentID := baseParentID
if filepath.IsAbs(cleanFolder) {
createParentID = ""
}
_, err := p.GetDeepFolderOrCreateId(createParentID, cleanFolder)
if err != nil {
fmt.Printf("Create folder %s failed\n", folder)
logx.Error(err)
} else {
fmt.Printf("Create folder %s success\n", folder)
}
}
}
================================================
FILE: cli/new/new.go
================================================
package new
import (
"github.com/52funny/pikpakcli/cli/new/folder"
"github.com/52funny/pikpakcli/cli/new/sha"
"github.com/52funny/pikpakcli/cli/new/url"
"github.com/spf13/cobra"
)
var NewCommand = &cobra.Command{
Use: "new",
Aliases: []string{"n"},
Short: `New can do something like create folder or other things`,
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
func init() {
NewCommand.AddCommand(folder.NewFolderCommand)
NewCommand.AddCommand(sha.NewShaCommand)
NewCommand.AddCommand(url.NewUrlCommand)
}
================================================
FILE: cli/new/sha/sha.go
================================================
package sha
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var NewShaCommand = &cobra.Command{
Use: "sha",
Short: `Create a file according to sha`,
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
// input mode
if strings.TrimSpace(input) != "" {
f, err := os.OpenFile(input, os.O_RDONLY, 0666)
if err != nil {
fmt.Printf("Open file %s failed\n", input)
logx.Error(err)
return
}
reader := bufio.NewReader(f)
shas := make([]string, 0)
for {
lineBytes, _, err := reader.ReadLine()
if err == io.EOF {
break
}
shas = append(shas, string(lineBytes))
}
handleNewSha(&p, shas)
return
}
// args mode
if len(args) > 0 {
handleNewSha(&p, args)
} else {
fmt.Println("Please input the folder name")
}
},
}
var path string
var parentId string
var input string
func init() {
NewShaCommand.Flags().StringVarP(&path, "path", "p", "/", "The path of the folder")
NewShaCommand.Flags().StringVarP(&input, "input", "i", "", "The input of the sha file")
NewShaCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "The parent id")
}
// new folder
func handleNewSha(p *api.PikPak, shas []string) {
var err error
if parentId == "" {
parentId, err = p.GetPathFolderId(path)
if err != nil {
fmt.Println("Get parent id failed")
logx.Error(err)
return
}
}
for _, sha := range shas {
sha = sha[strings.Index(sha, "://")+3:]
shaElements := strings.Split(sha, "|")
if len(shaElements) != 3 {
fmt.Println("The sha format is wrong:", sha)
continue
}
name, size, sha := shaElements[0], shaElements[1], shaElements[2]
err := p.CreateShaFile(parentId, name, size, sha)
if err != nil {
fmt.Println("Create sha file failed")
logx.Error(err)
continue
}
fmt.Println("Create sha file success:", name)
}
}
================================================
FILE: cli/new/url/url.go
================================================
package url
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var NewUrlCommand = &cobra.Command{
Use: "url",
Short: `Create a file according to url`,
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
if cli {
handleCli(&p)
return
}
// input mode
if strings.TrimSpace(input) != "" {
f, err := os.OpenFile(input, os.O_RDONLY, 0666)
if err != nil {
fmt.Printf("Open file %s failed\n", input)
logx.Error(err)
return
}
reader := bufio.NewReader(f)
shas := make([]string, 0)
for {
lineBytes, _, err := reader.ReadLine()
if err == io.EOF {
break
}
shas = append(shas, string(lineBytes))
}
handleNewUrl(&p, shas)
return
}
// args mode
if len(args) > 0 {
handleNewUrl(&p, args)
} else {
fmt.Println("Please input the folder name")
}
},
}
var path string
var parentId string
var input string
var cli bool
func init() {
NewUrlCommand.Flags().StringVarP(&path, "path", "p", "/", "The path of the folder")
NewUrlCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "The parent id")
NewUrlCommand.Flags().StringVarP(&input, "input", "i", "", "The input of the sha file")
NewUrlCommand.Flags().BoolVarP(&cli, "cli", "c", false, "The cli mode")
}
// new folder
func handleNewUrl(p *api.PikPak, shas []string) {
var err error
if parentId == "" {
parentId, err = p.GetPathFolderId(path)
if err != nil {
fmt.Println("Get parent id failed")
logx.Error(err)
return
}
}
for _, url := range shas {
err := p.CreateUrlFile(parentId, url)
if err != nil {
fmt.Println("Create url file failed")
logx.Error(err)
continue
}
fmt.Println("Create url file success:", url)
}
}
func handleCli(p *api.PikPak) {
var err error
if parentId == "" {
parentId, err = p.GetPathFolderId(path)
if err != nil {
fmt.Println("Get parent id failed")
logx.Error(err)
return
}
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("> ")
lineBytes, _, err := reader.ReadLine()
if err == io.EOF {
break
}
url := string(lineBytes)
err = p.CreateUrlFile(parentId, url)
if err != nil {
fmt.Println("Create url file failed")
logx.Error(err)
continue
}
fmt.Println("Create url file success:", url)
}
}
================================================
FILE: cli/quota/quota.go
================================================
package quota
import (
"fmt"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/spf13/cobra"
)
var human bool
var QuotaCmd = &cobra.Command{
Use: "quota",
Short: `Get the quota for the pikpak cloud`,
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
q, err := p.GetQuota()
if err != nil {
fmt.Println("Get cloud quota error")
logx.Error(err)
return
}
fmt.Println("Storage:")
fmt.Printf("%-20s%-20s\n", "total", "used")
if human {
fmt.Printf("%-20s%-20s\n", utils.FormatStorage(q.Quota.Limit, true), utils.FormatStorage(q.Quota.Usage, true))
} else {
fmt.Printf("%-20s%-20s\n", q.Quota.Limit, q.Quota.Usage)
}
displayCloudDownload(q.Quotas.CloudDownload)
transfer, err := p.GetTransferQuota()
if err != nil {
fmt.Println("Get transfer quota error")
logx.Error(err)
return
}
displayMonthlyTransferQuota(transfer.Base)
},
}
func init() {
QuotaCmd.Flags().BoolVarP(&human, "human", "H", false, "display human readable format")
}
func displayCloudDownload(cloudDownload api.Quota) {
fmt.Printf("\ncloud download:\n")
fmt.Printf("%-20s%-20s%-20s\n", "total", "used", "remaining")
remaining, err := cloudDownload.Remaining()
if err != nil {
fmt.Printf("%-20s%-20s%-20s\n", formatQuotaValue(cloudDownload.Limit), formatQuotaValue(cloudDownload.Usage), "N/A")
return
}
fmt.Printf("%-20s%-20s%-20s\n", formatQuotaValue(cloudDownload.Limit), formatQuotaValue(cloudDownload.Usage), formatTransferValue(remaining))
}
func displayMonthlyTransferQuota(base api.TransferQuotaBase) {
fmt.Printf("\nmonthly transfer:\n")
fmt.Printf("%-20s%-20s%-20s%-20s\n", "type", "total", "used", "remaining")
displayTransferRow("cloud download", base.Offline)
displayTransferRow("download", base.Download)
displayTransferRow("upload", base.Upload)
}
func displayTransferRow(name string, quota api.TransferQuota) {
fmt.Printf(
"%-20s%-20s%-20s%-20s\n",
name,
formatTransferValue(quota.TotalAssets),
formatTransferValue(quota.Assets),
formatTransferValue(quota.Remaining()),
)
}
func formatTransferValue(size int64) string {
return utils.FormatStorage(fmt.Sprintf("%d", size), human)
}
func formatQuotaValue(size string) string {
return utils.FormatStorage(size, human)
}
================================================
FILE: cli/quota/quota_test.go
================================================
package quota
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormatTransferValue(t *testing.T) {
human = false
assert.Equal(t, "2048", formatTransferValue(2048))
human = true
assert.Equal(t, "2KB", formatTransferValue(2048))
}
================================================
FILE: cli/rename/rename.go
================================================
package rename
import (
"fmt"
"path/filepath"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var RenameCmd = &cobra.Command{
Use: "rename <path> <new-name>",
Short: "Rename a file or folder on the PikPak drive",
Long: `Rename a file or folder on the PikPak drive.
Example: pikpakcli rename /my-folder/old-name.txt new-name.txt`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
if err := p.Login(); err != nil {
fmt.Println("Login failed")
logx.Error(err)
return nil
}
oldPath := args[0]
newName := strings.TrimSpace(args[1])
if newName == "" {
return fmt.Errorf("new name cannot be empty")
}
if filepath.Base(newName) != newName {
return fmt.Errorf("new name must not contain path separators")
}
expandedPaths, err := api.ExpandRemotePatterns(&p, "/", []string{oldPath}, false)
if err != nil {
fmt.Printf("Could not find file or folder at path '%s'\n", oldPath)
logx.Error(err)
return nil
}
if len(expandedPaths) != 1 {
return fmt.Errorf("rename target must match exactly one path")
}
oldPath = expandedPaths[0]
fileStat, err := p.GetFileByPath(oldPath)
if err != nil {
fmt.Printf("Could not find file or folder at path '%s'\n", oldPath)
logx.Error(err)
return nil
}
if err := p.Rename(fileStat.ID, newName); err != nil {
fmt.Printf("Failed to rename %s\n", oldPath)
logx.Error(err)
return nil
}
fmt.Printf("Successfully renamed '%s' to '%s'\n", oldPath, newName)
return nil
},
}
================================================
FILE: cli/root.go
================================================
package cli
import (
"fmt"
"os"
del "github.com/52funny/pikpakcli/cli/del"
"github.com/52funny/pikpakcli/cli/download"
"github.com/52funny/pikpakcli/cli/empty"
"github.com/52funny/pikpakcli/cli/list"
"github.com/52funny/pikpakcli/cli/new"
"github.com/52funny/pikpakcli/cli/quota"
"github.com/52funny/pikpakcli/cli/rename"
"github.com/52funny/pikpakcli/cli/rubbish"
"github.com/52funny/pikpakcli/cli/share"
"github.com/52funny/pikpakcli/cli/upload"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "pikpakcli",
Short: "Pikpakcli is a command line interface for Pikpak",
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
PersistentPreRun: func(cmd *cobra.Command, args []string) {
err := conf.InitConfig(configPath)
if err != nil {
fmt.Println("Init config failed")
logx.Error(err)
os.Exit(1)
}
logx.Init(debug, debugTopics)
},
}
// Config path
var configPath string
// Debug mode
var debug bool
var debugTopics []string
// Initialize the command line interface
func init() {
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "debug mode")
rootCmd.PersistentFlags().StringSliceVar(&debugTopics, "debug-topic", nil, "enable debug topics: api,session,transfer")
rootCmd.PersistentFlags().StringVar(&configPath, "config", "config.yml", "config file path")
rootCmd.AddCommand(upload.UploadCmd)
rootCmd.AddCommand(download.DownloadCmd)
rootCmd.AddCommand(share.ShareCommand)
rootCmd.AddCommand(new.NewCommand)
rootCmd.AddCommand(quota.QuotaCmd)
rootCmd.AddCommand(list.ListCmd)
rootCmd.AddCommand(del.DeleteCmd)
rootCmd.AddCommand(empty.EmptyCmd)
rootCmd.AddCommand(rubbish.RubbishCmd)
rootCmd.AddCommand(rename.RenameCmd)
rootCmd.AddCommand(shellCmd)
}
// Execute the command line interface
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
================================================
FILE: cli/rubbish/rubbish.go
================================================
package rubbish
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/spf13/cobra"
)
var rubbishPath string
var rulesPath string
var rubbishConcurrency int
var rubbishDeleteMode bool
var openRulesFile bool
var openRulesDir bool
var downloadRules bool
const (
defaultRulesRelativePath = "rules/rubbish_rules.txt"
defaultRulesDownloadURL = "https://raw.githubusercontent.com/52funny/pikpakcli/master/rules/rubbish_rules.txt"
)
type rubbishProvider interface {
GetPathFolderId(dirPath string) (string, error)
GetFolderFileStatList(parentId string) ([]api.FileStat, error)
DeleteFile(fileId string) error
}
type compiledRules struct {
includes []string
excludes []string
}
type rubbishMatch struct {
path string
pattern string
}
type rubbishWalkState struct {
sem chan struct{}
mu sync.Mutex
}
type rubbishFolderResult struct {
matches []rubbishMatch
err error
}
var RubbishCmd = &cobra.Command{
Use: "rubbish [path]",
Short: "Recursively find rubbish files on the PikPak server using text rules",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
path := rubbishPath
if len(args) > 0 {
path = args[0]
}
resolvedRulesPath, err := resolveRulesPath(rulesPath)
if err != nil {
fmt.Printf("Resolve rubbish rules failed: %v\n", err)
return
}
if downloadRules {
if err := downloadDefaultRules(resolvedRulesPath, defaultRulesDownloadURL); err != nil {
fmt.Printf("Download rubbish rules failed: %v\n", err)
return
}
fmt.Printf("Downloaded rubbish rules to %s\n", resolvedRulesPath)
}
if openRulesDir {
if err := ensureDefaultRulesFile(resolvedRulesPath); err != nil {
fmt.Printf("Prepare rubbish rules failed: %v\n", err)
return
}
if err := openLocalPath(filepath.Dir(resolvedRulesPath)); err != nil {
fmt.Printf("Open rules directory failed: %v\n", err)
return
}
fmt.Printf("Opened rules directory: %s\n", filepath.Dir(resolvedRulesPath))
return
}
if openRulesFile {
if err := ensureDefaultRulesFile(resolvedRulesPath); err != nil {
fmt.Printf("Prepare rubbish rules failed: %v\n", err)
return
}
if err := openLocalPath(resolvedRulesPath); err != nil {
fmt.Printf("Open rules file failed: %v\n", err)
return
}
fmt.Printf("Opened rules file: %s\n", resolvedRulesPath)
return
}
if strings.TrimSpace(rulesPath) == "" {
if err := ensureDefaultRulesFile(resolvedRulesPath); err != nil {
fmt.Printf("Prepare rubbish rules failed: %v\n", err)
return
}
}
rules, err := loadRules(resolvedRulesPath)
if err != nil {
fmt.Printf("Load rubbish rules failed: %v\n", err)
return
}
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
if err := p.Login(); err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
matches, err := handleRubbish(cmd.Context(), &p, path, rules, rubbishConcurrency, rubbishDeleteMode)
if err != nil {
if errors.Is(err, context.Canceled) {
fmt.Println("Rubbish scan canceled")
return
}
fmt.Println("Handle rubbish failed")
logx.Error(err)
return
}
if len(matches) == 0 {
fmt.Printf("No rubbish files matched under %s\n", path)
return
}
for _, match := range matches {
if rubbishDeleteMode {
fmt.Printf("Deleted rubbish: %s (matched %s)\n", match.path, match.pattern)
continue
}
fmt.Printf("Rubbish file: %s (matched %s)\n", match.path, match.pattern)
}
},
}
func init() {
RubbishCmd.Flags().StringVarP(&rubbishPath, "path", "p", "/", "The path where to scan rubbish files recursively")
RubbishCmd.Flags().StringVar(&rulesPath, "rules", "", "Path or URL to the rubbish rules file")
RubbishCmd.Flags().IntVarP(&rubbishConcurrency, "concurrency", "c", 8, "number of folders to process concurrently")
RubbishCmd.Flags().BoolVarP(&rubbishDeleteMode, "delete", "d", false, "delete matched rubbish files instead of only listing them")
RubbishCmd.Flags().BoolVar(&openRulesFile, "open-rules", false, "Open the rubbish rules file, downloading the default file to the config directory when needed")
RubbishCmd.Flags().BoolVar(&openRulesDir, "open-rules-dir", false, "Open the rubbish rules directory, downloading the default file to the config directory when needed")
RubbishCmd.Flags().BoolVar(&downloadRules, "download-rules", false, "Download the default rubbish rules file from GitHub into the config directory before running")
}
func loadRules(path string) (compiledRules, error) {
expandedPath := utils.ExpandLocalPath(path)
file, err := os.Open(expandedPath)
if err != nil {
return compiledRules{}, err
}
defer file.Close()
var rules compiledRules
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
exclude := strings.HasPrefix(line, "!")
if exclude {
line = strings.TrimSpace(strings.TrimPrefix(line, "!"))
}
if line == "" {
continue
}
if exclude {
rules.excludes = append(rules.excludes, line)
continue
}
rules.includes = append(rules.includes, line)
}
if err := scanner.Err(); err != nil {
return compiledRules{}, err
}
if len(rules.includes) == 0 {
return compiledRules{}, fmt.Errorf("no include rules found in %s", expandedPath)
}
return rules, nil
}
func resolveRulesPath(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return defaultRulesPath()
}
if isRemoteRulesSource(trimmed) {
target, err := defaultRulesPath()
if err != nil {
return "", err
}
if err := downloadDefaultRules(target, trimmed); err != nil {
return "", err
}
return target, nil
}
expanded := utils.ExpandLocalPath(trimmed)
info, err := os.Stat(expanded)
if err == nil && info.IsDir() {
return filepath.Join(expanded, filepath.Base(defaultRulesRelativePath)), nil
}
if err != nil && !os.IsNotExist(err) {
return "", err
}
return expanded, nil
}
func defaultRulesPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("get config dir error: %w", err)
}
return filepath.Join(configDir, "pikpakcli", defaultRulesRelativePath), nil
}
func ensureDefaultRulesFile(path string) error {
if path == "" {
return errors.New("rules path cannot be empty")
}
if _, err := os.Stat(path); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
return downloadDefaultRules(path, defaultRulesDownloadURL)
}
func downloadDefaultRules(targetPath string, sourceURL string) error {
if err := utils.CreateDirIfNotExist(filepath.Dir(targetPath)); err != nil {
return err
}
resp, err := http.Get(sourceURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download rules returned %s", resp.Status)
}
bs, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if err := os.WriteFile(targetPath, bs, 0o644); err != nil {
return err
}
return nil
}
func isRemoteRulesSource(path string) bool {
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")
}
func openLocalPath(path string) error {
name, args, err := buildLocalOpenCommand(runtime.GOOS, path)
if err != nil {
return err
}
return exec.Command(name, args...).Start()
}
func buildLocalOpenCommand(goos string, path string) (string, []string, error) {
switch goos {
case "darwin":
return "open", []string{path}, nil
case "linux":
return "xdg-open", []string{path}, nil
case "windows":
return "cmd", []string{"/c", "start", "", path}, nil
default:
return "", nil, fmt.Errorf("unsupported platform: %s", goos)
}
}
func handleRubbish(ctx context.Context, p rubbishProvider, rootPath string, rules compiledRules, concurrency int, deleteMode bool) ([]rubbishMatch, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
rootID, err := p.GetPathFolderId(rootPath)
if err != nil {
return nil, err
}
if concurrency < 1 {
concurrency = 1
}
matches := make([]rubbishMatch, 0)
state := rubbishWalkState{
sem: make(chan struct{}, concurrency),
}
if err := walkRubbish(ctx, p, rootID, filepath.Clean(rootPath), rules, deleteMode, &matches, &state); err != nil {
return nil, err
}
return matches, nil
}
func walkRubbish(ctx context.Context, p rubbishProvider, folderID, currentPath string, rules compiledRules, deleteMode bool, matches *[]rubbishMatch, state *rubbishWalkState) error {
if err := ctx.Err(); err != nil {
return err
}
files, err := p.GetFolderFileStatList(folderID)
if err != nil {
return err
}
results := make(chan rubbishFolderResult, len(files))
var childFolders int
for _, file := range files {
if err := ctx.Err(); err != nil {
return err
}
childPath := filepath.Join(currentPath, file.Name)
if file.Kind == api.FileKindFolder {
childFolders++
select {
case <-ctx.Done():
return ctx.Err()
case state.sem <- struct{}{}:
go func(file api.FileStat, childPath string) {
defer func() {
<-state.sem
}()
localMatches := make([]rubbishMatch, 0)
err := walkRubbish(ctx, p, file.ID, childPath, rules, deleteMode, &localMatches, state)
if err == nil {
if pattern, ok := rules.Match(childPath); ok {
if deleteMode {
err = p.DeleteFile(file.ID)
}
if err == nil {
localMatches = append(localMatches, rubbishMatch{path: childPath, pattern: pattern})
}
}
}
results <- rubbishFolderResult{matches: localMatches, err: err}
}(file, childPath)
default:
localMatches := make([]rubbishMatch, 0)
if err := walkRubbish(ctx, p, file.ID, childPath, rules, deleteMode, &localMatches, state); err != nil {
return err
}
if pattern, ok := rules.Match(childPath); ok {
if deleteMode {
if err := p.DeleteFile(file.ID); err != nil {
return err
}
}
localMatches = append(localMatches, rubbishMatch{path: childPath, pattern: pattern})
}
results <- rubbishFolderResult{matches: localMatches}
}
continue
}
pattern, ok := rules.Match(childPath)
if !ok {
continue
}
if deleteMode {
if err := p.DeleteFile(file.ID); err != nil {
return err
}
}
state.mu.Lock()
*matches = append(*matches, rubbishMatch{path: childPath, pattern: pattern})
state.mu.Unlock()
}
for i := 0; i < childFolders; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case result := <-results:
if result.err != nil {
return result.err
}
state.mu.Lock()
*matches = append(*matches, result.matches...)
state.mu.Unlock()
}
}
return nil
}
func (r compiledRules) Match(path string) (string, bool) {
normalizedPath := filepath.Clean(path)
if normalizedPath == "." {
normalizedPath = string(filepath.Separator)
}
name := filepath.Base(normalizedPath)
for _, pattern := range r.excludes {
if patternMatches(pattern, normalizedPath, name) {
return "", false
}
}
for _, pattern := range r.includes {
if patternMatches(pattern, normalizedPath, name) {
return pattern, true
}
}
return "", false
}
func patternMatches(pattern, fullPath, name string) bool {
pattern = filepath.Clean(strings.TrimSpace(pattern))
if pattern == "." {
return false
}
matchTarget := name
if strings.Contains(pattern, string(filepath.Separator)) {
matchTarget = strings.TrimPrefix(fullPath, string(filepath.Separator))
if strings.HasPrefix(pattern, string(filepath.Separator)) {
matchTarget = fullPath
}
}
if !hasWildcard(pattern) {
return matchTarget == pattern
}
matched, err := filepath.Match(pattern, matchTarget)
return err == nil && matched
}
func hasWildcard(pattern string) bool {
return strings.ContainsAny(pattern, "*?[")
}
================================================
FILE: cli/rubbish/rubbish_test.go
================================================
package rubbish
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"github.com/52funny/pikpakcli/internal/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeRubbishProvider struct {
pathToID map[string]string
folders map[string][]api.FileStat
deletedFiles []string
mu sync.Mutex
}
func (f *fakeRubbishProvider) GetPathFolderId(dirPath string) (string, error) {
if id, ok := f.pathToID[filepath.Clean(dirPath)]; ok {
return id, nil
}
return "", errors.New("path not found")
}
func (f *fakeRubbishProvider) GetFolderFileStatList(parentId string) ([]api.FileStat, error) {
f.mu.Lock()
defer f.mu.Unlock()
files := f.folders[parentId]
cloned := make([]api.FileStat, len(files))
copy(cloned, files)
return cloned, nil
}
func (f *fakeRubbishProvider) DeleteFile(fileId string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deletedFiles = append(f.deletedFiles, fileId)
for parentID, files := range f.folders {
filtered := files[:0]
for _, file := range files {
if file.ID != fileId {
filtered = append(filtered, file)
}
}
f.folders[parentID] = filtered
}
delete(f.folders, fileId)
return nil
}
func TestLoadRules(t *testing.T) {
dir := t.TempDir()
rulesFile := filepath.Join(dir, "rules.txt")
err := os.WriteFile(rulesFile, []byte("# comment\n\n.DS_Store\n*.tmp\n!important.tmp\n"), 0o644)
require.NoError(t, err)
rules, err := loadRules(rulesFile)
require.NoError(t, err)
assert.Equal(t, []string{".DS_Store", "*.tmp"}, rules.includes)
assert.Equal(t, []string{"important.tmp"}, rules.excludes)
}
func TestCompiledRulesMatch(t *testing.T) {
rules := compiledRules{
includes: []string{".DS_Store", "*.tmp", "cache/*.part", "/System/*"},
excludes: []string{"keep.tmp", "!ignored", "/System/keep/*"},
}
pattern, ok := rules.Match("/Movies/.DS_Store")
require.True(t, ok)
assert.Equal(t, ".DS_Store", pattern)
pattern, ok = rules.Match("/Movies/video.tmp")
require.True(t, ok)
assert.Equal(t, "*.tmp", pattern)
pattern, ok = rules.Match("/cache/file.part")
require.True(t, ok)
assert.Equal(t, "cache/*.part", pattern)
_, ok = rules.Match("/Movies/keep.tmp")
assert.False(t, ok)
pattern, ok = rules.Match("/System/logs")
require.True(t, ok)
assert.Equal(t, "/System/*", pattern)
_, ok = rules.Match("/System/keep/file")
assert.False(t, ok)
}
func TestHandleRubbishListsAndDeletesMatches(t *testing.T) {
provider := &fakeRubbishProvider{
pathToID: map[string]string{
filepath.Clean("/"): "root",
},
folders: map[string][]api.FileStat{
"root": {
{ID: "movies", Name: "Movies", Kind: api.FileKindFolder},
{ID: "ds", Name: ".DS_Store", Kind: api.FileKindFile},
{ID: "keep", Name: "keep.tmp", Kind: api.FileKindFile},
},
"movies": {
{ID: "partial", Name: "video.part", Kind: api.FileKindFile},
{ID: "poster", Name: "poster.jpg", Kind: api.FileKindFile},
},
},
}
rules := compiledRules{
includes: []string{".DS_Store", "*.part", "*.tmp"},
excludes: []string{"keep.tmp"},
}
matches, err := handleRubbish(context.Background(), provider, "/", rules, 4, false)
require.NoError(t, err)
assert.ElementsMatch(t, []rubbishMatch{
{path: filepath.Clean("/.DS_Store"), pattern: ".DS_Store"},
{path: filepath.Clean("/Movies/video.part"), pattern: "*.part"},
}, matches)
assert.Empty(t, provider.deletedFiles)
matches, err = handleRubbish(context.Background(), provider, "/", rules, 4, true)
require.NoError(t, err)
assert.ElementsMatch(t, []rubbishMatch{
{path: filepath.Clean("/.DS_Store"), pattern: ".DS_Store"},
{path: filepath.Clean("/Movies/video.part"), pattern: "*.part"},
}, matches)
assert.ElementsMatch(t, []string{"ds", "partial"}, provider.deletedFiles)
}
func TestHandleRubbishNormalizesConcurrency(t *testing.T) {
provider := &fakeRubbishProvider{
pathToID: map[string]string{
filepath.Clean("/"): "root",
},
folders: map[string][]api.FileStat{
"root": {
{ID: "tmp", Name: "a.tmp", Kind: api.FileKindFile},
},
},
}
matches, err := handleRubbish(context.Background(), provider, "/", compiledRules{includes: []string{"*.tmp"}}, 0, false)
require.NoError(t, err)
assert.Equal(t, []rubbishMatch{{path: filepath.Clean("/a.tmp"), pattern: "*.tmp"}}, matches)
}
func TestDefaultRulesPathUsesConfigDir(t *testing.T) {
configDir, err := os.UserConfigDir()
require.NoError(t, err)
path, err := defaultRulesPath()
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "pikpakcli", "rules", "rubbish_rules.txt"), path)
}
func TestResolveRulesPathForDirectory(t *testing.T) {
dir := t.TempDir()
path, err := resolveRulesPath(dir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, "rubbish_rules.txt"), path)
}
func TestDownloadDefaultRules(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(".DS_Store\n*.tmp\n"))
}))
defer server.Close()
targetDir := t.TempDir()
targetPath := filepath.Join(targetDir, "rules.txt")
err := downloadDefaultRules(targetPath, server.URL)
require.NoError(t, err)
bs, err := os.ReadFile(targetPath)
require.NoError(t, err)
assert.Equal(t, ".DS_Store\n*.tmp\n", string(bs))
}
func TestBuildLocalOpenCommand(t *testing.T) {
name, args, err := buildLocalOpenCommand("linux", "/tmp/rules.txt")
require.NoError(t, err)
assert.Equal(t, "xdg-open", name)
assert.Equal(t, []string{"/tmp/rules.txt"}, args)
}
================================================
FILE: cli/share/share.go
================================================
package share
import (
"fmt"
"os"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/spf13/cobra"
)
var ShareCommand = &cobra.Command{
Use: "share",
Aliases: []string{"d"},
Short: `Share file links on the pikpak server`,
Run: func(cmd *cobra.Command, args []string) {
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
if len(args) > 0 {
args, err = api.ExpandRemotePatterns(&p, folder, args, false)
if err != nil {
fmt.Println("Expand share target failed")
logx.Error(err)
return
}
}
// Output file handle
var f = os.Stdout
if strings.TrimSpace(output) != "" {
file, err := os.Create(output)
if err != nil {
fmt.Println("Create file failed")
logx.Error(err)
return
}
defer file.Close()
f = file
}
if len(args) > 0 {
shareFiles(&p, args, f)
} else {
shareFolder(&p, f)
}
},
}
// Specifies the folder of the pikpak server
// default is the root folder
var folder string
// Specifies the file to write
// default is the stdout
var output string
var parentId string
func init() {
ShareCommand.Flags().StringVarP(&folder, "path", "p", "/", "specific the folder of the pikpak server")
ShareCommand.Flags().StringVarP(&output, "output", "o", "", "specific the file to write")
ShareCommand.Flags().StringVarP(&parentId, "parent-id", "P", "", "parent folder id")
}
// Share folder
func shareFolder(p *api.PikPak, f *os.File) {
var err error
if parentId == "" {
parentId, err = p.GetDeepFolderId("", folder)
if err != nil {
fmt.Println("Get parent id failed")
logx.Error(err)
return
}
}
fileStat, err := p.GetFolderFileStatList(parentId)
if err != nil {
fmt.Println("Get folder file stat list failed")
logx.Error(err)
return
}
for _, stat := range fileStat {
// logrus.Debug(stat)
if stat.Kind == api.FileKindFile {
fmt.Fprintf(f, "PikPak://%s|%s|%s\n", stat.Name, stat.Size, stat.Hash)
}
}
}
// Share files
func shareFiles(p *api.PikPak, args []string, f *os.File) {
var err error
if parentId == "" {
parentId, err = p.GetPathFolderId(folder)
if err != nil {
fmt.Println("Get parent id failed")
logx.Error(err)
return
}
}
for _, path := range args {
stat, err := resolveShareTarget(p, parentId, path)
if err != nil {
fmt.Println(path, "get file stat error")
logx.Error(err)
continue
}
fmt.Fprintf(f, "PikPak://%s|%s|%s\n", stat.Name, stat.Size, stat.Hash)
}
}
func resolveShareTarget(p *api.PikPak, resolvedParentID string, target string) (api.FileStat, error) {
if strings.HasPrefix(target, "/") || strings.Contains(target, "/") {
return p.GetFileByPath(target)
}
return p.GetFileStat(resolvedParentID, target)
}
================================================
FILE: cli/shell.go
================================================
package cli
import (
ishell "github.com/52funny/pikpakcli/internal/shell"
"github.com/spf13/cobra"
)
var shellCmd = &cobra.Command{
Use: "shell",
Short: "Start an interactive PikPak shell",
Run: func(cmd *cobra.Command, args []string) {
ishell.Start(rootCmd)
},
}
================================================
FILE: cli/upload/upload.go
================================================
package upload
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/spf13/cobra"
)
var UploadCmd = &cobra.Command{
Use: "upload",
Aliases: []string{"u"},
Short: `Upload file to pikpak server`,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
cmd.Help()
return
}
api.Concurrent = uploadConcurrency
p := api.NewPikPakWithContext(cmd.Context(), conf.Config.Username, conf.Config.Password)
err := p.Login()
if err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
err = p.AuthCaptchaToken("POST:/drive/v1/files")
if err != nil {
fmt.Println("Auth captcha token failed")
logx.Error(err)
return
}
go func() {
ticker := time.NewTicker(time.Second * 7200 * 3 / 4)
defer ticker.Stop()
for range ticker.C {
err := p.RefreshToken()
if err != nil {
logx.Warn("session", "refresh token failed:", err)
continue
}
}
}()
for _, v := range args {
v = utils.ExpandLocalPath(v)
stat, err := os.Stat(v)
if err != nil {
fmt.Printf("Get file %s stat failed\n", v)
logx.Error(err)
continue
}
if stat.IsDir() {
handleUploadFolder(&p, v)
} else {
handleUploadFile(&p, v)
}
}
},
}
// Specifies the folder of the pikpak server
var uploadFolder string
// Specifies the file to upload
var uploadConcurrency int64
// Sync mode
var sync bool
// Parent path id
var parentId string
// Init upload command
func init() {
UploadCmd.Flags().StringVarP(&uploadFolder, "path", "p", "/", "specific the folder of the pikpak server")
UploadCmd.Flags().Int64VarP(&uploadConcurrency, "concurrency", "c", 1<<4, "specific the concurrency of the upload")
UploadCmd.Flags().StringSliceVarP(&exclude, "exn", "e", []string{}, "specific the exclude file or folder")
UploadCmd.Flags().BoolVarP(&sync, "sync", "s", false, "sync mode")
UploadCmd.Flags().StringVarP(&parentId, "parent-id", "P", "", "parent folder id")
}
// Exclude string list
var exclude []string
var defaultExcludeRegexp []*regexp.Regexp = []*regexp.Regexp{
// exclude the hidden file
regexp.MustCompile(`^\..+`),
}
// Dispose the exclude file or folder
func disposeExclude() {
for _, v := range exclude {
defaultExcludeRegexp = append(defaultExcludeRegexp, regexp.MustCompile(v))
}
}
func handleUploadFile(p *api.PikPak, path string) {
var err error
if parentId == "" {
parentId, err = p.GetDeepFolderOrCreateId("", uploadFolder)
if err != nil {
fmt.Printf("Get folder %s id failed\n", uploadFolder)
logx.Error(err)
return
}
}
err = p.UploadFile(parentId, path)
if err != nil {
fmt.Printf("Upload file %s failed\n", path)
logx.Error(err)
return
}
fmt.Printf("Upload file %s success!\n", path)
}
// upload files logic
func handleUploadFolder(p *api.PikPak, path string) {
basePath := filepath.Base(filepath.ToSlash(path))
uploadFilePath, err := utils.GetUploadFilePath(path, defaultExcludeRegexp)
if err != nil {
fmt.Println("Get upload file path failed")
logx.Error(err)
return
}
syncTxt, err := utils.NewSyncTxt(".pikpaksync.txt", sync)
if err != nil {
fmt.Println("Init sync file failed")
logx.Error(err)
return
}
defer syncTxt.Close()
uploadFilePath = syncTxt.UnSync(uploadFilePath)
fmt.Println("upload file list:")
for _, f := range uploadFilePath {
fmt.Println(filepath.Join(basePath, f))
}
if parentId == "" {
parentId, err = p.GetDeepFolderOrCreateId("", uploadFolder)
if err != nil {
fmt.Printf("Get folder %s id error\n", uploadFolder)
logx.Error(err)
return
}
}
logx.Debug("upload", "upload folder: ", uploadFolder, " parentId: ", parentId)
parentId, err = p.GetDeepFolderOrCreateId(parentId, basePath)
if err != nil {
fmt.Printf("Get base_upload_path %s id error\n", basePath)
logx.Error(err)
return
}
parentIdMap := make(map[string]string)
for _, v := range uploadFilePath {
if strings.Contains(v, "/") || strings.Contains(v, "\\") {
var id string
base := filepath.Dir(v)
// Avoid secondary query ids
if mId, ok := parentIdMap[base]; !ok {
id, err = p.GetDeepFolderOrCreateId(parentId, base)
if err != nil {
fmt.Println("Get folder id failed")
logx.Error(err)
}
parentIdMap[base] = id
} else {
id = mId
}
err = p.UploadFile(id, filepath.Join(path, v))
if err != nil {
fmt.Printf("%s upload failed\n", v)
logx.Error(err)
}
syncTxt.WriteString(v + "\n")
fmt.Printf("%s upload success!\n", v)
} else {
err = p.UploadFile(parentId, filepath.Join(path, v))
if err != nil {
fmt.Printf("%s upload failed\n", v)
logx.Error(err)
}
syncTxt.WriteString(v + "\n")
}
}
}
================================================
FILE: conf/config.go
================================================
package conf
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v2"
)
type ConfigType struct {
Proxy string `yaml:"proxy"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Open OpenConfig `yaml:"open"`
}
type OpenConfig struct {
DownloadDir string `yaml:"download_dir"`
Default []string `yaml:"default"`
Text []string `yaml:"text"`
Image []string `yaml:"image"`
Video []string `yaml:"video"`
Audio []string `yaml:"audio"`
PDF []string `yaml:"pdf"`
}
var Config ConfigType
// UseProxy returns whether the proxy is used
func (c *ConfigType) UseProxy() bool {
return len(c.Proxy) != 0
}
// Initializing configuration information
func InitConfig(path string) error {
// Firstly, read the config info from executable file
if readFromBinary() == nil {
return nil
}
// Secondly, it reads config.yml from the given path.
// If there is no config.yml in the given path, it reads it from the default config path.
_, err := os.Stat(path)
switch os.IsNotExist(err) {
case true:
if err := readFromConfigDir(); err != nil {
return err
}
case false:
if err := readFromPath(path); err != nil {
return err
}
}
// Not empty
// Must contains '://'
if len(Config.Proxy) != 0 && !strings.Contains(Config.Proxy, "://") {
return fmt.Errorf("proxy should contains ://")
}
return nil
}
// Read config from binary in the end
// config_bytes: n bytes
// end_magic: 10 bytes
// size: 4 bytes
// -----------------------------------
// | config_bytes | size | end_magic |
// -----------------------------------
func readFromBinary() error {
f, err := os.Open(os.Args[0])
if err != nil {
return err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return err
}
var end_magic = make([]byte, 10)
n, err := f.ReadAt(end_magic, stat.Size()-10)
if err != nil {
return err
}
if n != 10 {
return fmt.Errorf("read end_magic err: %d", n)
}
// Not have `config.yml` in the end
if !bytes.Equal(end_magic, []byte("config.yml")) {
return fmt.Errorf("not a pikpakcli binary")
}
var size = make([]byte, 4)
n, err = f.ReadAt(size, stat.Size()-14)
if err != nil {
return err
}
if n != 4 {
return fmt.Errorf("read size err: %d", n)
}
configSize := int64(binary.LittleEndian.Uint32(size))
configBuf := make([]byte, configSize)
n, err = f.ReadAt(configBuf, stat.Size()-14-configSize)
if err != nil || n != int(configSize) {
return err
}
if n != int(configSize) {
return fmt.Errorf("read config size err: %d", n)
}
// Unmarshal config
return yaml.Unmarshal(configBuf, &Config)
}
// Read configuration file from the given path
func readFromPath(path string) error {
return readConfig(path)
}
// Read configuration file from config path
func readFromConfigDir() error {
configDir, err := os.UserConfigDir()
if err != nil {
return err
}
return readConfig(filepath.Join(configDir, "pikpakcli", "config.yml"))
}
func readConfig(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
bs, err := io.ReadAll(f)
if err != nil {
return err
}
err = yaml.Unmarshal(bs, &Config)
if err != nil {
return err
}
return nil
}
================================================
FILE: config_example.yml
================================================
# Proxy URL, for example: http://127.0.0.1:7890
proxy:
# PikPak account username or phone number with country code
username: xxx
# PikPak account password
password: xxx
# Local open command settings used by the interactive shell builtin `open`
open:
# Local cache directory for files that need to be downloaded before opening
download_dir:
# Fallback command used when no file-type-specific command is configured
default: []
# Command used to open text and source files
text: []
# Command used to open image files
image: []
# Command used to open video files
video: []
# Command used to open audio files
audio: []
# Command used to open PDF files
pdf: []
================================================
FILE: docs/command.md
================================================
# Command Usage
> For docker users, please refer to the [Docker Command Usage](docs/command_docker.md).
## Upload
- Uploads all files in the local directory to the Movies folder.
```bash
pikpakcli upload -p Movies .
```
- Upload files in local directory except for `mp3`, `jpg` to Movies folder.
```bash
pikpakcli upload -e .mp3,.jpg -p Movies .
```
- Select the number of concurrent tasks for the upload (default is 16).
```bash
pikpakcli -c 20 -p Movies .
```
- Use the `-P` flag to set the `id` of the folder on the Pikpak cloud.
```bash
pikpakcli upload -P AgmoDVmJPYbHn8ito1 .
```
- Running `pikpakcli upload` without any local path arguments shows the command help.
## Download
- Download the target pointed to by `-p`. If it is a directory, download it recursively; if it is a file, download that file.
```bash
pikpakcli download -p Movies
pikpakcli download -p Movies/Peppa_Pig.mp4
```
- Use `-p` as the base remote path, then append the following argument to it. The CLI will decide whether the target is a file or a directory.
```bash
pikpakcli download -p Movies Peppa_Pig.mp4
pikpakcli download -p Movies Cartoons
pikpakcli download -p Movies Kids/Peppa_Pig.mp4
```
- Use an absolute remote path in the argument to override `-p`.
```bash
pikpakcli download -p Movies /TV/Peppa_Pig.mp4
```
- Limit the number of files that can be downloaded at the same time (default: 1).
```bash
pikpakcli download -c 5 -p Movies
```
- Specify the output directory of downloaded files.
```bash
pikpakcli download -p Movies -o Film
```
- Use the `-g` flag to display status information during the download process.
```bash
pikpakcli download -p Movies -o Film -g
```
## Share
- Share links to all files under Movies.
```bash
pikpakcli share -p Movies
```
- Share the link to the specified file.
```bash
pikpakcli share Movies/Peppa_Pig.mp4
```
- Share link output to a specified file.
```bash
pikpakcli share --out sha.txt -p Movies
```
## New
### New Folder
- Create a new folder NewFolder under Movies
```bash
pikpakcli new folder -p Movies NewFolder
```
### New Sha File
- Create a new Sha file under Movies.
```bash
pikpakcli new sha -p /Movies 'PikPak://美国队长.mkv|22809693754|75BFE33237A0C06C725587F87981C567E4E478C3'
```
### New Magnet File
- Create new magnet file.
```bash
pikpakcli new url 'magnet:?xt=urn:btih:e9c98e3ed488611abc169a81d8a21487fd1d0732'
```
## Quota
- Get space on your PikPak cloud drive.
```bash
pikpakcli quota -H
```
## Ls
- Get information about all files in the root directory.
```bash
pikpakcli ls -lH -p /
```
## Delete
- Delete a file by full path from the PikPak cloud.
```bash
pikpakcli delete /Movies/Peppa_Pig.mp4
```
- Delete entries from a specific directory using the `-p` flag.
```bash
pikpakcli delete -p /Movies Peppa_Pig.mp4
```
- Delete multiple entries under the same path.
```bash
pikpakcli delete -p /Movies File1.mp4 File2.mp4
```
## Rubbish
- Scan a directory recursively with the default rubbish rules. If the rule file does not exist in the user config directory, the CLI downloads it from this repository automatically.
```bash
pikpakcli rubbish
pikpakcli rubbish -p /Movies
```
- Preview matched rubbish files without deleting them, then delete them with `-d`.
```bash
pikpakcli rubbish -p /Movies
pikpakcli rubbish -p /Movies -d
```
- Open the local rules file or the local rules directory. If the default rule file is missing, it is downloaded first and then opened.
```bash
pikpakcli rubbish --open-rules
pikpakcli rubbish --open-rules-dir
```
- Download the default rules file explicitly, or use a custom local path or remote URL as the rules source.
```bash
pikpakcli rubbish --download-rules
pikpakcli rubbish --rules ~/.config/pikpakcli/rules/rubbish_rules.txt
pikpakcli rubbish --rules https://raw.githubusercontent.com/52funny/pikpakcli/master/rules/rubbish_rules.txt
```
## Rename
- Rename a file or folder by full path.
```bash
pikpakcli rename /Movies/Peppa_Pig.mp4 Peppa_Pig_S01E01.mp4
```
- Rename a folder.
```bash
pikpakcli rename /Movies/Cartoons Kids
```
## Shell
- Start the interactive shell.
```bash
pikpakcli shell
```
- Change directory and list files in the current path.
```bash
pikpakcli shell
cd "/Movies/Kids Cartoons"
ls
```
- Open a remote file from the shell with a local application.
```bash
pikpakcli shell
cd "/Movies"
open Peppa_Pig.mp4
```
================================================
FILE: docs/command_docker.md
================================================
# Docker Command Usage
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.
## Upload
- Uploads all files in the local directory (e.g., `/path/to/upload`) to the `Movies` folder.
```bash
# original cli: pikpakcli upload -p Movies .
# Docker cli
docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/upload/:/upload pikpakcli:latest upload -p Movies /upload
```
- Upload files in local directory except for `mp3`, `jpg` to Movies folder.
```bash
# original cli: pikpakcli upload -e .mp3,.jpg -p Movies .
# Docker cli
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
```
## Download
- Download the target pointed to by `-p`. The target can be a folder or a file.
```bash
# original cli: pikpakcli download -p Movies
# Docker cli
# the option -o is used to specify the folder in container to save downloaded files
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
```
- Use `-p` as the base remote path, then append the argument to it. The CLI will decide whether it is a file or a folder.
```bash
# original cli: pikpakcli download -p Movies Peppa_Pig.mp4
# Docker cli
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
```
- Use an absolute remote path in the argument to override `-p`.
```bash
# original cli: pikpakcli download -p Movies /TV/Peppa_Pig.mp4
# Docker cli
docker run --rm -v /path/to/config.yml:/root/.config/pikpakcli/config.yml -v /path/to/download/:/download pikpakcli:latest download -p Movies /TV/Peppa_Pig.mp4 -o /download
```
> Other download commands are omitted here, please refer to the original cli commands in [Command Usage](docs/command.md).
## Wrapper Script
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.
```bash
# Make the script executable
chmod +x docker_cli.sh
# Run the script for upload
./docker_cli.sh upload -p Movies ./pikpak_uploads
# Run the script for download
./docker_cli.sh download -p Movies -o ./pikpak_downloads
```
================================================
FILE: docs/command_zhCN.md
================================================
# 命令使用方法
## 上传
- 将本地目录下的所有文件上传至 Movies 文件夹内
```bash
pikpakcli upload -p Movies .
```
- 将本地目录下除了后缀名为`mp3`, `jpg`的文件上传至 Movies 文件夹内
```bash
pikpakcli upload -e .mp3,.jpg -p Movies .
```
- 指定上传的协程数目(默认为 16)
```bash
pikpakcli -c 20 -p Movies .
```
- 使用 `-P` 标志设置 Pikpak 云上文件夹的 `id`
```bash
pikpakcli upload -P AgmoDVmJPYbHn8ito1 .
```
- 直接运行 `pikpakcli upload` 且不带任何本地路径参数时,会显示该命令的帮助信息。
## 下载
- 下载 `-p` 指向的目标。如果该目标是文件夹则递归下载,如果是文件则下载该文件
```bash
pikpakcli download -p Movies
pikpakcli download -p Movies/Peppa_Pig.mp4
```
- 把 `-p` 作为远端基路径,再拼接后面的参数。CLI 会自动判断目标是文件还是文件夹
```bash
pikpakcli download -p Movies Peppa_Pig.mp4
pikpakcli download -p Movies Cartoons
pikpakcli download -p Movies Kids/Peppa_Pig.mp4
```
- 如果参数本身是绝对路径,则会覆盖 `-p`
```bash
pikpakcli download -p Movies /TV/Peppa_Pig.mp4
```
- 限制同时下载的文件个数 (默认: 1)
```bash
pikpakcli download -c 5 -p Movies
```
- 指定下载内容的输出目录
```bash
pikpakcli download -p Movies -o Film
```
- 使用 `-g` 标志显示下载过程中的状态信息
```bash
pikpakcli download -p Movies -o Film -g
```
## 分享
- 分享 Movies 下的所有文件的链接
```bash
pikpakcli share -p Movies
```
- 分享指定文件的链接
```bash
pikpakcli share Movies/Peppa_Pig.mp4
```
- 分享链接输出到指定文件
```bash
pikpakcli share --out sha.txt -p Movies
```
## 新建
### 新建文件夹
- 在 Movies 下新建文件夹 NewFolder
```bash
pikpakcli new folder -p Movies NewFolder
```
### 新建 Sha 文件
- 在 Movies 下新建 Sha 文件
```bash
pikpakcli new sha -p /Movies 'PikPak://美国队长.mkv|22809693754|75BFE33237A0C06C725587F87981C567E4E478C3'
```
### 新建磁力
- 新建磁力文件
```bash
pikpakcli new url 'magnet:?xt=urn:btih:e9c98e3ed488611abc169a81d8a21487fd1d0732'
```
## 配额
- 获取 PikPak 云盘的空间
```bash
pikpakcli quota -H
```
## 获取目录信息
- 获取根目录下面的所有文件信息
```bash
pikpakcli ls -lH -p /
```
## 删除
- 按完整路径删除文件
```bash
pikpakcli delete /Movies/Peppa_Pig.mp4
```
- 使用 `-p` 指定父目录后删除其中的文件或文件夹
```bash
pikpakcli delete -p /Movies Peppa_Pig.mp4
```
- 在同一路径下同时删除多个文件或文件夹
```bash
pikpakcli delete -p /Movies File1.mp4 File2.mp4
```
## 垃圾文件清理
- 使用默认垃圾文件规则递归扫描目录。如果用户配置目录中还没有规则文件,CLI 会自动从当前仓库下载默认规则。
```bash
pikpakcli rubbish
pikpakcli rubbish -p /Movies
```
- 默认只预览匹配结果,不会删除;加上 `-d` 后才会执行删除。
```bash
pikpakcli rubbish -p /Movies
pikpakcli rubbish -p /Movies -d
```
- 打开本地规则文件或规则目录。如果默认规则文件不存在,会先下载再打开。
```bash
pikpakcli rubbish --open-rules
pikpakcli rubbish --open-rules-dir
```
- 手动下载默认规则文件,或者指定自定义本地路径 / 远程 URL 作为规则来源。
```bash
pikpakcli rubbish --download-rules
pikpakcli rubbish --rules ~/.config/pikpakcli/rules/rubbish_rules.txt
pikpakcli rubbish --rules https://raw.githubusercontent.com/52funny/pikpakcli/master/rules/rubbish_rules.txt
```
## 重命名
- 按完整路径重命名文件或文件夹
```bash
pikpakcli rename /Movies/Peppa_Pig.mp4 Peppa_Pig_S01E01.mp4
```
- 重命名文件夹
```bash
pikpakcli rename /Movies/Cartoons Kids
```
## 交互 Shell
- 启动交互式 shell
```bash
pikpakcli shell
```
- 在 shell 中切换目录并查看当前目录文件
```bash
pikpakcli shell
cd "/Movies/Kids Cartoons"
ls
```
- 在 shell 中打开远端文件到本地默认程序
```bash
pikpakcli shell
cd "/Movies"
open Peppa_Pig.mp4
```
================================================
FILE: docs/config.md
================================================
## Configuration
The CLI reads the following fields from `config.yml`:
```yml
proxy:
username: xxx
password: xxx
open:
download_dir:
default: []
text: []
image: []
video: []
audio: []
pdf: []
```
### Basic Fields
- `username`: your PikPak account username or phone number with country code such as `+861xxxxxxxxxx`.
- `password`: your PikPak account password.
- `proxy`: optional proxy URL such as `http://127.0.0.1:7890`.
> `proxy` must contain `://`.
### Open Settings
The `open` section is used by the interactive shell builtin `open`.
- `download_dir`: optional local cache directory for files that must be downloaded before opening.
- `default`: fallback local command used when no file-type-specific command is configured.
- `text`: local command used to open text and source files.
- `image`: local command used to open image files.
- `video`: local command used to open video files.
- `audio`: local command used to open audio files.
- `pdf`: local command used to open PDF files.
Each command field is a YAML string array. The first item is the executable name and the remaining items are its arguments.
If the command array contains `{path}`, it will be replaced with the local file path or remote media URL. If `{path}` is not present, the path or URL is appended to the end of the command automatically.
For video files, the shell `open` command prefers opening a remote media URL directly. Other file types are downloaded to the local cache directory before opening.
### Default Open Behavior
If the `open` section is not configured, the builtin `open` uses platform defaults:
- macOS: `text -> TextEdit`, `image/pdf -> Preview`, `video/audio -> IINA`, others -> `open`
- Linux: `xdg-open`
- Windows: `cmd /c start`
### Example
```yml
proxy: http://127.0.0.1:7890
username: +861xxxxxxxxxx
password: your-password
open:
download_dir: ~/Downloads/pikpak-open
default: ["open"]
text: ["zed"]
image: ["open", "-a", "Preview"]
video: ["open", "-a", "IINA"]
audio: ["open", "-a", "IINA"]
pdf: ["open", "-a", "Preview"]
```
================================================
FILE: docs/config_zhCN.md
================================================
## 配置说明
CLI 会从 `config.yml` 中读取以下字段:
```yml
proxy:
username: xxx
password: xxx
open:
download_dir:
default: []
text: []
image: []
video: []
audio: []
pdf: []
```
### 基础字段
- `username`:你的 PikPak 账号用户名,或者带区号的手机号,例如 `+861xxxxxxxxxx`。
- `password`:你的 PikPak 账号密码。
- `proxy`:可选代理地址,例如 `http://127.0.0.1:7890`。
> `proxy` 必须包含 `://`。
### Open 配置
`open` 配置段用于交互式 shell 中的内置 `open` 命令。
- `download_dir`:可选的本地缓存目录,用于存放打开前需要先下载的文件。
- `default`:当没有匹配到具体文件类型配置时使用的兜底本地命令。
- `text`:用于打开文本文件和源码文件的本地命令。
- `image`:用于打开图片文件的本地命令。
- `video`:用于打开视频文件的本地命令。
- `audio`:用于打开音频文件的本地命令。
- `pdf`:用于打开 PDF 文件的本地命令。
每个命令字段都使用 YAML 字符串数组。第一个元素是可执行程序名,后面的元素是它的参数。
如果命令数组中包含 `{path}`,运行时会将它替换为本地文件路径或远端媒体 URL。如果没有写 `{path}`,程序会自动把路径或 URL 追加到命令末尾。
对于视频文件,shell 中的 `open` 命令会优先直接打开远端媒体 URL。其他文件类型会先下载到本地缓存目录,再调用本地程序打开。
### 默认打开行为
如果没有配置 `open`,内置 `open` 会使用各平台默认行为:
- macOS:`text -> TextEdit`,`image/pdf -> Preview`,`video/audio -> IINA`,其他类型 -> `open`
- Linux:`xdg-open`
- Windows:`cmd /c start`
### 示例
```yml
proxy: http://127.0.0.1:7890
username: +861xxxxxxxxxx
password: your-password
open:
download_dir: ~/Downloads/pikpak-open
default: ["open"]
text: ["zed"]
image: ["open", "-a", "Preview"]
video: ["open", "-a", "IINA"]
audio: ["open", "-a", "IINA"]
pdf: ["open", "-a", "Preview"]
```
================================================
FILE: go.mod
================================================
module github.com/52funny/pikpakcli
go 1.21.3
require (
github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c
github.com/chzyer/readline v1.5.1
github.com/fatih/color v1.15.0
github.com/json-iterator/go v1.1.12
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.14.4
github.com/vbauerster/mpb/v8 v8.7.2
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.16.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c h1:ecJG8tmvgH6exVE4+I3rFPPA1Mk3/lNb8VZ6A7dtcyI=
github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c/go.mod h1:YA/IS8XUrMTcrY+J4yOJ3CDgoyQ28NOOo4GnzOL6bTI=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/vbauerster/mpb/v8 v8.7.2 h1:SMJtxhNho1MV3OuFgS1DAzhANN1Ejc5Ct+0iSaIkB14=
github.com/vbauerster/mpb/v8 v8.7.2/go.mod h1:ZFnrjzspgDHoxYLGvxIruiNk73GNTPG4YHgVNpR10VY=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: internal/api/captcha_token.go
================================================
package api
import (
"bytes"
"crypto/md5"
"fmt"
"time"
"github.com/52funny/pikpakcli/internal/logx"
jsoniter "github.com/json-iterator/go"
)
const package_name = `com.pikcloud.pikpak`
const client_version = `1.21.0`
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"}]`
type md5Obj struct {
Alg string `json:"alg"`
Salt string `json:"salt"`
}
var md5Arr []md5Obj
func init() {
err := jsoniter.Unmarshal([]byte(md5_obj), &md5Arr)
if err != nil {
logx.Warn("api", err)
}
}
func (p *PikPak) AuthCaptchaToken(action string) error {
m := make(map[string]interface{})
m["action"] = action
m["captcha_token"] = p.CaptchaToken
m["client_id"] = clientID
m["device_id"] = p.DeviceId
ts := fmt.Sprintf("%d", time.Now().UnixMilli())
str := clientID + client_version + package_name + p.DeviceId + ts
for i := 0; i < len(md5Arr); i++ {
alg := md5Arr[i].Alg
salt := md5Arr[i].Salt
if alg == "md5" {
str = fmt.Sprintf("%x", md5.Sum([]byte(str+salt)))
}
}
// logrus.Debug("captcha_sign: ", "1."+str)
m["meta"] = map[string]string{
"captcha_sign": "1." + str,
"user_id": p.Sub,
"package_name": package_name,
"client_version": client_version,
"timestamp": ts,
}
m["redirect_uri"] = "ttps://api.mypikpak.com/v1/auth/callback"
bs, err := jsoniter.Marshal(m)
if err != nil {
return err
}
req, err := p.newRequest("POST", "https://user.mypikpak.com/v1/shield/captcha/init?client_id="+clientID, bytes.NewBuffer(bs))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
bs, err = p.sendRequest(req)
if err != nil {
return err
}
error_code := jsoniter.Get(bs, "error_code").ToInt()
if error_code != 0 {
return fmt.Errorf("auth captcha token error: %s", jsoniter.Get(bs, "error").ToString())
}
p.CaptchaToken = jsoniter.Get(bs, "captcha_token").ToString()
return nil
}
================================================
FILE: internal/api/constants.go
================================================
package api
const (
FileKindFolder = "drive#folder"
FileKindFile = "drive#file"
)
================================================
FILE: internal/api/download.go
================================================
package api
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/vbauerster/mpb/v8"
)
const maxDownloadRetries = 3
var errRestartDownload = errors.New("restart download from beginning")
type retryableDownloadError struct {
err error
}
func (f *File) requestContext() context.Context {
if f != nil && f.ctx != nil {
return f.ctx
}
return context.Background()
}
func (e *retryableDownloadError) Error() string {
return e.err.Error()
}
func (e *retryableDownloadError) Unwrap() error {
return e.err
}
func retryableDownload(err error) error {
if err == nil {
return nil
}
return &retryableDownloadError{err: err}
}
func isRetryableDownloadError(err error) bool {
var target *retryableDownloadError
return errors.As(err, &target)
}
// Download file
func (f *File) Download(path string, bar *mpb.Bar) error {
expectedSize, err := strconv.ParseInt(f.Size, 10, 64)
if err != nil {
expectedSize = -1
}
var lastErr error
for attempt := 0; attempt < maxDownloadRetries; attempt++ {
lastErr = f.download(path, bar, expectedSize)
if lastErr == nil {
return nil
}
if !isRetryableDownloadError(lastErr) {
return lastErr
}
if attempt == maxDownloadRetries-1 {
break
}
logx.Warnf("transfer", "Download %s interrupted, retrying (%d/%d): %v", f.Name, attempt+1, maxDownloadRetries-1, lastErr)
}
return lastErr
}
func (f *File) download(path string, bar *mpb.Bar, expectedSize int64) error {
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer outFile.Close()
info, err := outFile.Stat()
if err != nil {
return err
}
offset := info.Size()
if expectedSize >= 0 && offset > expectedSize {
if err := outFile.Truncate(0); err != nil {
return err
}
offset = 0
}
if _, err := outFile.Seek(offset, io.SeekStart); err != nil {
return err
}
req, err := http.NewRequestWithContext(f.requestContext(), "GET", f.Links.ApplicationOctetStream.URL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
if offset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
if bar != nil {
bar.SetCurrent(offset)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return retryableDownload(err)
}
defer resp.Body.Close()
switch {
case offset > 0 && resp.StatusCode == http.StatusRequestedRangeNotSatisfiable:
if expectedSize >= 0 && offset == expectedSize {
if bar != nil {
bar.SetCurrent(expectedSize)
}
return nil
}
if err := outFile.Truncate(0); err != nil {
return err
}
if bar != nil {
bar.SetCurrent(0)
}
return retryableDownload(errRestartDownload)
case offset > 0 && resp.StatusCode == http.StatusOK:
logx.Warnf("transfer", "Resume file %s failed: server ignored range request, restarting from the beginning", f.Name)
if err := outFile.Truncate(0); err != nil {
return err
}
if bar != nil {
bar.SetCurrent(0)
}
return retryableDownload(errRestartDownload)
case offset > 0 && resp.StatusCode != http.StatusPartialContent:
if resp.StatusCode >= http.StatusInternalServerError || resp.StatusCode == http.StatusTooManyRequests {
return retryableDownload(fmt.Errorf("download request failed: %s", resp.Status))
}
return fmt.Errorf("download request failed: %s", resp.Status)
case offset == 0 && (resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices):
if resp.StatusCode >= http.StatusInternalServerError || resp.StatusCode == http.StatusTooManyRequests {
return retryableDownload(fmt.Errorf("download request failed: %s", resp.Status))
}
return fmt.Errorf("download request failed: %s", resp.Status)
}
var reader io.ReadCloser
if bar != nil {
reader = bar.ProxyReader(resp.Body)
} else {
reader = resp.Body
}
defer reader.Close()
buf := make([]byte, 1024*128)
written, err := io.CopyBuffer(outFile, reader, buf)
if err != nil {
var netErr net.Error
if errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) {
return retryableDownload(err)
}
return retryableDownload(err)
}
contentLengthHeader := resp.Header.Get("Content-Length")
if contentLengthHeader != "" {
contentLength, err := strconv.ParseInt(contentLengthHeader, 10, 64)
if err != nil {
return fmt.Errorf("parse content length failed: %w", err)
}
if contentLength != written {
return retryableDownload(fmt.Errorf("content length not equal to written"))
}
}
if expectedSize >= 0 && offset+written != expectedSize {
return retryableDownload(fmt.Errorf("download incomplete: got %d of %d bytes", offset+written, expectedSize))
}
return nil
}
================================================
FILE: internal/api/download_test.go
================================================
package api
import (
"bufio"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
func TestDownloadResumesAfterInterruptedTransfer(t *testing.T) {
content := []byte("hello world")
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch requests.Add(1) {
case 1:
require.Empty(t, r.Header.Get("Range"))
hj, ok := w.(http.Hijacker)
require.True(t, ok)
conn, rw, err := hj.Hijack()
require.NoError(t, err)
defer conn.Close()
_, err = fmt.Fprintf(rw, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(content))
require.NoError(t, err)
_, err = rw.Write(content[:5])
require.NoError(t, err)
require.NoError(t, rw.Flush())
case 2:
require.Equal(t, "bytes=5-", r.Header.Get("Range"))
remaining := content[5:]
w.Header().Set("Content-Length", strconv.Itoa(len(remaining)))
w.Header().Set("Content-Range", fmt.Sprintf("bytes 5-%d/%d", len(content)-1, len(content)))
w.WriteHeader(http.StatusPartialContent)
_, err := w.Write(remaining)
require.NoError(t, err)
default:
t.Fatalf("unexpected request count: %d", requests.Load())
}
}))
defer server.Close()
file := File{
FileStat: FileStat{
Name: "resume.bin",
Size: strconv.Itoa(len(content)),
},
}
file.Links.ApplicationOctetStream.URL = server.URL
target := filepath.Join(t.TempDir(), file.Name)
require.NoError(t, file.Download(target, nil))
downloaded, err := os.ReadFile(target)
require.NoError(t, err)
require.Equal(t, content, downloaded)
require.EqualValues(t, 2, requests.Load())
}
func TestDownloadRestartsWhenServerIgnoresRangeRequest(t *testing.T) {
content := []byte("hello world")
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch requests.Add(1) {
case 1:
require.Equal(t, "bytes=5-", r.Header.Get("Range"))
case 2:
require.Empty(t, r.Header.Get("Range"))
default:
t.Fatalf("unexpected request count: %d", requests.Load())
}
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
w.WriteHeader(http.StatusOK)
_, err := w.Write(content)
require.NoError(t, err)
}))
defer server.Close()
file := File{
FileStat: FileStat{
Name: "restart.bin",
Size: strconv.Itoa(len(content)),
},
}
file.Links.ApplicationOctetStream.URL = server.URL
target := filepath.Join(t.TempDir(), file.Name)
require.NoError(t, os.WriteFile(target, content[:5], 0644))
require.NoError(t, file.Download(target, nil))
downloaded, err := os.ReadFile(target)
require.NoError(t, err)
require.Equal(t, content, downloaded)
require.EqualValues(t, 2, requests.Load())
}
func TestDownloadTreatsSatisfiedRangeAsSuccess(t *testing.T) {
content := []byte("hello world")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "bytes=11-", r.Header.Get("Range"))
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
}))
defer server.Close()
file := File{
FileStat: FileStat{
Name: "complete.bin",
Size: strconv.Itoa(len(content)),
},
}
file.Links.ApplicationOctetStream.URL = server.URL
target := filepath.Join(t.TempDir(), file.Name)
require.NoError(t, os.WriteFile(target, content, 0644))
require.NoError(t, file.Download(target, nil))
f, err := os.Open(target)
require.NoError(t, err)
defer f.Close()
reader := bufio.NewReader(f)
got, err := reader.Peek(len(content))
require.NoError(t, err)
require.Equal(t, content, got)
}
================================================
FILE: internal/api/file.go
================================================
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/url"
"strings"
"time"
"unsafe"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/tidwall/gjson"
)
type FileStat struct {
Kind string `json:"kind"`
ID string `json:"id"`
ParentID string `json:"parent_id"`
Name string `json:"name"`
UserID string `json:"user_id"`
Size string `json:"size"`
FileExtension string `json:"file_extension"`
MimeType string `json:"mime_type"`
CreatedTime time.Time `json:"created_time"`
ModifiedTime time.Time `json:"modified_time"`
IconLink string `json:"icon_link"`
ThumbnailLink string `json:"thumbnail_link"`
Md5Checksum string `json:"md5_checksum"`
Hash string `json:"hash"`
Phase string `json:"phase"`
}
type File struct {
FileStat
Revision string `json:"revision"`
Starred bool `json:"starred"`
WebContentLink string `json:"web_content_link"`
Links struct {
ApplicationOctetStream struct {
URL string `json:"url"`
Token string `json:"token"`
Expire time.Time `json:"expire"`
} `json:"application/octet-stream"`
} `json:"links"`
Audit struct {
Status string `json:"status"`
Message string `json:"message"`
Title string `json:"title"`
} `json:"audit"`
Medias []struct {
MediaID string `json:"media_id"`
MediaName string `json:"media_name"`
Video interface{} `json:"video"`
Link struct {
URL string `json:"url"`
Token string `json:"token"`
Expire time.Time `json:"expire"`
} `json:"link"`
NeedMoreQuota bool `json:"need_more_quota"`
VipTypes []interface{} `json:"vip_types"`
RedirectLink string `json:"redirect_link"`
IconLink string `json:"icon_link"`
IsDefault bool `json:"is_default"`
Priority int `json:"priority"`
IsOrigin bool `json:"is_origin"`
ResolutionName string `json:"resolution_name"`
IsVisible bool `json:"is_visible"`
Category string `json:"category"`
} `json:"medias"`
Trashed bool `json:"trashed"`
DeleteTime string `json:"delete_time"`
OriginalURL string `json:"original_url"`
Params struct {
Platform string `json:"platform"`
PlatformIcon string `json:"platform_icon"`
} `json:"params"`
OriginalFileIndex int `json:"original_file_index"`
Space string `json:"space"`
Apps []interface{} `json:"apps"`
Writable bool `json:"writable"`
FolderType string `json:"folder_type"`
Collection interface{} `json:"collection"`
ctx context.Context
}
type fileListResult struct {
NextPageToken string `json:"next_page_token"`
Files []FileStat `json:"files"`
}
const maxListRetries = 3
func (p *PikPak) GetFolderFileStatList(parentId string) ([]FileStat, error) {
filters := `{"trashed":{"eq":false}}`
query := url.Values{}
query.Add("thumbnail_size", "SIZE_MEDIUM")
query.Add("limit", "500")
query.Add("parent_id", parentId)
query.Add("with_audit", "false")
query.Add("filters", filters)
fileList := make([]FileStat, 0)
for {
bs, err := p.getFolderFileStatPage(query)
if err != nil {
return fileList, err
}
error_code := gjson.Get(*(*string)(unsafe.Pointer(&bs)), "error_code").Int()
if error_code == 9 {
err = p.AuthCaptchaToken("GET:/drive/v1/files")
if err != nil {
return fileList, err
}
}
var result fileListResult
err = json.Unmarshal(bs, &result)
if err != nil {
return fileList, err
}
fileList = append(fileList, result.Files...)
if result.NextPageToken == "" {
break
}
query.Set("page_token", result.NextPageToken)
}
return fileList, nil
}
func (p *PikPak) getFolderFileStatPage(query url.Values) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < maxListRetries; attempt++ {
req, err := p.newRequest("GET", "https://api-drive.mypikpak.com/drive/v1/files?"+query.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("Content-Type", "application/json")
bs, err := p.sendRequest(req)
if err == nil {
return bs, nil
}
lastErr = err
if !isRetryableListError(err) || attempt == maxListRetries-1 {
break
}
logx.Warnf("transfer", "List folder interrupted, retrying (%d/%d): %v", attempt+1, maxListRetries-1, err)
time.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond)
}
return nil, lastErr
}
func isRetryableListError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "unexpected eof") ||
strings.Contains(message, "connection reset by peer") ||
strings.Contains(message, "connection closed") ||
strings.Contains(message, "broken pipe")
}
// Find FileState similar to name in the parentId directory
func (p *PikPak) GetFileStat(parentId string, name string) (FileStat, error) {
stats, err := p.GetFolderFileStatList(parentId)
if err != nil {
return FileStat{}, err
}
for _, stat := range stats {
if stat.Name == name {
return stat, nil
}
}
return FileStat{}, errors.New("file not found")
}
func (p *PikPak) GetFileByPath(path string) (FileStat, error) {
parentPath, name := utils.SplitRemotePath(path)
if name == "" {
return FileStat{}, errors.New("cannot get info of root directory")
}
parentID := ""
var err error
if parentPath != "" {
parentID, err = p.GetPathFolderId(parentPath)
if err != nil {
return FileStat{}, err
}
}
return p.GetFileStat(parentID, name)
}
func (p *PikPak) GetFile(fileId string) (File, error) {
var fileInfo File
query := url.Values{}
query.Add("thumbnail_size", "SIZE_MEDIUM")
req, err := p.newRequest("GET", "https://api-drive.mypikpak.com/drive/v1/files/"+fileId+"?"+query.Encode(), nil)
if err != nil {
return fileInfo, err
}
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Device-Id", p.DeviceId)
bs, err := p.sendRequest(req)
if err != nil {
return fileInfo, err
}
error_code := gjson.Get(*(*string)(unsafe.Pointer(&bs)), "error_code").Int()
if error_code != 0 {
if error_code == 9 {
err = p.AuthCaptchaToken("GET:/drive/v1/files")
if err != nil {
return fileInfo, err
}
}
err = errors.New(gjson.Get(*(*string)(unsafe.Pointer(&bs)), "error").String() + ":" + fileId)
return fileInfo, err
}
err = json.Unmarshal(bs, &fileInfo)
if err != nil {
return fileInfo, err
}
fileInfo.ctx = p.requestContext()
return fileInfo, err
}
func (p *PikPak) DeleteFile(fileId string) error {
START:
req, err := p.newRequest("DELETE", "https://api-drive.mypikpak.com/drive/v1/files/"+fileId, nil)
if err != nil {
return err
}
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Device-Id", p.DeviceId)
bs, err := p.sendRequest(req)
if err != nil {
return err
}
error_code := gjson.GetBytes(bs, "error_code").Int()
if error_code != 0 {
if error_code == 9 {
err = p.AuthCaptchaToken("DELETE:/drive/v1/files")
if err != nil {
return err
}
goto START
}
return fmt.Errorf("%s: %s", gjson.GetBytes(bs, "error").String(), fileId)
}
return nil
}
func (p *PikPak) Rename(fileId string, newName string) error {
if newName == "" {
return errors.New("new name cannot be empty")
}
apiURL := "https://api-drive.mypikpak.com/drive/v1/files/" + fileId
body := map[string]string{"name": newName}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
START:
req, err := p.newRequest("PATCH", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Device-Id", p.DeviceId)
bs, err := p.sendRequest(req)
if err != nil {
return err
}
errorCode := gjson.GetBytes(bs, "error_code").Int()
if errorCode != 0 {
if errorCode == 9 {
err = p.AuthCaptchaToken("PATCH:/drive/v1/files")
if err != nil {
return err
}
goto START
}
return fmt.Errorf("%s: %s", gjson.GetBytes(bs, "error").String(), fileId)
}
return nil
}
================================================
FILE: internal/api/file_test.go
================================================
package api
import (
"context"
"errors"
"io"
"net"
"testing"
"github.com/stretchr/testify/assert"
)
type fakeNetError struct{}
func (fakeNetError) Error() string { return "i/o timeout" }
func (fakeNetError) Timeout() bool { return true }
func (fakeNetError) Temporary() bool { return true }
func TestIsRetryableListError(t *testing.T) {
assert.True(t, isRetryableListError(io.ErrUnexpectedEOF))
assert.True(t, isRetryableListError(errors.New("unexpected EOF")))
assert.True(t, isRetryableListError(fakeNetError{}))
assert.True(t, isRetryableListError(errors.New("read: connection reset by peer")))
assert.False(t, isRetryableListError(errors.New("permission denied")))
assert.False(t, isRetryableListError(nil))
}
func TestFakeNetErrorImplementsNetError(t *testing.T) {
var err net.Error = fakeNetError{}
assert.True(t, err.Timeout())
assert.True(t, err.Temporary())
}
func TestPikPakWithContext(t *testing.T) {
base := NewPikPak("user", "pass")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
derived := base.WithContext(ctx)
assert.NotNil(t, derived)
assert.NotSame(t, &base, derived)
assert.Equal(t, ctx, derived.requestContext())
assert.NotEqual(t, ctx, base.requestContext())
}
================================================
FILE: internal/api/folder.go
================================================
package api
import (
"bytes"
"fmt"
"net/url"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
// 获取文件夹 id
// dir 可以包括 /.
// 若以 / 开头,函数会去除 /, 且会从 parent 目录开始查找
func (p *PikPak) GetDeepFolderId(parentId string, dirPath string) (string, error) {
dirPath = utils.Slash(dirPath)
if dirPath == "" {
return parentId, nil
}
dirS := utils.SplitSeparator(dirPath)
for _, dir := range dirS {
id, err := p.GetFolderId(parentId, dir)
if err != nil {
return "", err
}
parentId = id
}
return parentId, nil
}
func (p *PikPak) GetPathFolderId(dirPath string) (string, error) {
return p.GetDeepFolderId("", dirPath)
}
// 获取文件夹 id
// dir 不能包括 /
func (p *PikPak) GetFolderId(parentId string, dir string) (string, error) {
// slash the dir path
dir = utils.Slash(dir)
value := url.Values{}
value.Add("parent_id", parentId)
value.Add("page_token", "")
value.Add("with_audit", "false")
value.Add("thumbnail_size", "SIZE_LARGE")
value.Add("limit", "500")
for {
req, err := p.newRequest("GET", fmt.Sprintf("https://api-drive.mypikpak.com/drive/v1/files?"+value.Encode()), nil)
if err != nil {
return "", err
}
req.Header.Set("Country", "CN")
req.Header.Set("X-Peer-Id", p.DeviceId)
req.Header.Set("X-User-Region", "1")
req.Header.Set("X-Alt-Capability", "3")
req.Header.Set("X-Client-Version-Code", "10083")
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
bs, err := p.sendRequest(req)
if err != nil {
return "", err
}
files := gjson.GetBytes(bs, "files").Array()
for _, file := range files {
kind := file.Get("kind").String()
name := file.Get("name").String()
trashed := file.Get("trashed").Bool()
if kind == FileKindFolder && name == dir && !trashed {
return file.Get("id").String(), nil
}
}
nextToken := gjson.GetBytes(bs, "next_page_token").String()
if nextToken == "" {
break
}
value.Set("page_token", nextToken)
}
return "", ErrNotFoundFolder
}
func (p *PikPak) GetDeepFolderOrCreateId(parentId string, dirPath string) (string, error) {
dirPath = utils.Slash(dirPath)
if dirPath == "" || dirPath == "." {
return parentId, nil
}
dirS := utils.SplitSeparator(dirPath)
for _, dir := range dirS {
id, err := p.GetFolderId(parentId, dir)
if err != nil {
logx.Warn("api", "dir ", err)
if err == ErrNotFoundFolder {
createId, err := p.CreateFolder(parentId, dir)
if err != nil {
return "", err
} else {
logx.Debug("api", "create dir: ", dir)
parentId = createId
}
} else {
return "", err
}
} else {
parentId = id
}
}
return parentId, nil
}
// Create new folder in parent folder
// parentId is parent folder id
func (p *PikPak) CreateFolder(parentId, dir string) (string, error) {
m := map[string]interface{}{
"kind": FileKindFolder,
"parent_id": parentId,
"name": dir,
}
bs, err := jsoniter.Marshal(&m)
if err != nil {
return "", err
}
START:
req, err := p.newRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Product_flavor_name", "cha")
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Client-Version-Code", "10083")
req.Header.Set("X-Peer-Id", p.DeviceId)
req.Header.Set("X-User-Region", "1")
req.Header.Set("X-Alt-Capability", "3")
req.Header.Set("Country", "CN")
bs, err = p.sendRequest(req)
if err != nil {
return "", err
}
error_code := gjson.GetBytes(bs, "error_code").Int()
if error_code != 0 {
if error_code == 9 {
err := p.AuthCaptchaToken("POST:/drive/v1/files")
if err != nil {
return "", err
}
goto START
}
return "", fmt.Errorf("create folder error: %s", jsoniter.Get(bs, "error").ToString())
}
id := gjson.GetBytes(bs, "file.id").String()
return id, nil
}
================================================
FILE: internal/api/glob.go
================================================
package api
import (
"fmt"
"path"
"strings"
)
type remotePatternProvider interface {
GetPathFolderId(dirPath string) (string, error)
GetFolderFileStatList(parentId string) ([]FileStat, error)
}
func ExpandRemotePatterns(p remotePatternProvider, basePath string, patterns []string, keepRelative bool) ([]string, error) {
expanded := make([]string, 0, len(patterns))
for _, pattern := range patterns {
matches, err := expandRemotePattern(p, basePath, pattern, keepRelative)
if err != nil {
return nil, err
}
expanded = append(expanded, matches...)
}
return expanded, nil
}
func expandRemotePattern(p remotePatternProvider, basePath string, pattern string, keepRelative bool) ([]string, error) {
if !hasRemoteWildcard(pattern) {
return []string{pattern}, nil
}
resolvedPattern := path.Clean(pattern)
if !path.IsAbs(resolvedPattern) {
resolvedPattern = path.Clean(path.Join("/", basePath, pattern))
}
parentPath := path.Dir(resolvedPattern)
if parentPath == "." {
parentPath = "/"
}
parentID := ""
var err error
if parentPath != "/" {
parentID, err = p.GetPathFolderId(parentPath)
if err != nil {
return nil, err
}
}
files, err := p.GetFolderFileStatList(parentID)
if err != nil {
return nil, err
}
matches := make([]string, 0)
namePattern := path.Base(resolvedPattern)
for _, file := range files {
matched, err := path.Match(namePattern, file.Name)
if err != nil {
return nil, fmt.Errorf("invalid wildcard pattern %s: %w", pattern, err)
}
if !matched {
continue
}
matchPath := path.Join(parentPath, file.Name)
if keepRelative && !path.IsAbs(pattern) {
matches = append(matches, relativeRemotePath(basePath, matchPath))
continue
}
matches = append(matches, matchPath)
}
if len(matches) == 0 {
return nil, fmt.Errorf("no matches found for %s", pattern)
}
return matches, nil
}
func hasRemoteWildcard(value string) bool {
return strings.ContainsAny(value, "*?[")
}
func relativeRemotePath(basePath string, fullPath string) string {
base := path.Clean(basePath)
full := path.Clean(fullPath)
if base == "." || base == "" || base == "/" {
return strings.TrimPrefix(full, "/")
}
prefix := base + "/"
if strings.HasPrefix(full, prefix) {
return strings.TrimPrefix(full, prefix)
}
return full
}
================================================
FILE: internal/api/glob_test.go
================================================
package api
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
)
type fakeRemotePatternProvider struct {
getPathFolderID func(dirPath string) (string, error)
getFolderFileStatList func(parentId string) ([]FileStat, error)
}
func (f fakeRemotePatternProvider) GetPathFolderId(dirPath string) (string, error) {
return f.getPathFolderID(dirPath)
}
func (f fakeRemotePatternProvider) GetFolderFileStatList(parentId string) ([]FileStat, error) {
return f.getFolderFileStatList(parentId)
}
func TestExpandRemotePatternsReturnsAbsoluteMatches(t *testing.T) {
provider := fakeRemotePatternProvider{
getPathFolderID: func(dirPath string) (string, error) {
require.Equal(t, "/Movies", dirPath)
return "movies-id", nil
},
getFolderFileStatList: func(parentId string) ([]FileStat, error) {
require.Equal(t, "movies-id", parentId)
return []FileStat{
{Name: "a.mp4"},
{Name: "b.mp4"},
{Name: "note.txt"},
}, nil
},
}
matches, err := ExpandRemotePatterns(provider, "/Movies", []string{"*.mp4"}, false)
require.NoError(t, err)
require.Equal(t, []string{"/Movies/a.mp4", "/Movies/b.mp4"}, matches)
}
func TestExpandRemotePatternsCanKeepRelativeMatches(t *testing.T) {
provider := fakeRemotePatternProvider{
getPathFolderID: func(dirPath string) (string, error) {
require.Equal(t, "/Movies/Kids", dirPath)
return "kids-id", nil
},
getFolderFileStatList: func(parentId string) ([]FileStat, error) {
require.Equal(t, "kids-id", parentId)
return []FileStat{
{Name: "a.srt"},
{Name: "b.srt"},
}, nil
},
}
matches, err := ExpandRemotePatterns(provider, "/Movies", []string{"Kids/*.srt"}, true)
require.NoError(t, err)
require.Equal(t, []string{"Kids/a.srt", "Kids/b.srt"}, matches)
}
func TestExpandRemotePatternsReturnsNoMatchError(t *testing.T) {
provider := fakeRemotePatternProvider{
getPathFolderID: func(dirPath string) (string, error) {
return "movies-id", nil
},
getFolderFileStatList: func(parentId string) ([]FileStat, error) {
return []FileStat{{Name: "note.txt"}}, nil
},
}
_, err := ExpandRemotePatterns(provider, "/Movies", []string{"*.mp4"}, false)
require.EqualError(t, err, "no matches found for *.mp4")
}
func TestExpandRemotePatternsPropagatesLookupErrors(t *testing.T) {
provider := fakeRemotePatternProvider{
getPathFolderID: func(dirPath string) (string, error) {
return "", errors.New("lookup failed")
},
getFolderFileStatList: func(parentId string) ([]FileStat, error) {
return nil, errors.New("should not list")
},
}
_, err := ExpandRemotePatterns(provider, "/Movies", []string{"Kids/*.mp4"}, false)
require.EqualError(t, err, "lookup failed")
}
================================================
FILE: internal/api/pikpak.go
================================================
package api
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/logx"
jsoniter "github.com/json-iterator/go"
)
const userAgent = `ANDROID-com.pikcloud.pikpak/1.21.0`
const clientID = `YNxT9w7GMdWvEOKa`
const clientSecret = `dbw2OtmVEeuUvIptb1Coyg`
type PikPak struct {
Account string `json:"account"`
Password string `json:"password"`
JwtToken string `json:"token"`
refreshToken string
CaptchaToken string `json:"captchaToken"`
Sub string `json:"userId"`
DeviceId string `json:"deviceId"`
RefreshSecond int64 `json:"refreshSecond"`
client *http.Client
ctx context.Context
}
func NewPikPak(account, password string) PikPak {
return NewPikPakWithContext(context.Background(), account, password)
}
func NewPikPakWithContext(ctx context.Context, account, password string) PikPak {
if ctx == nil {
ctx = context.Background()
}
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}
if conf.Config.UseProxy() {
proxyUrl, err := url.Parse(conf.Config.Proxy)
if err != nil {
logx.Warn("api", "url parse proxy error", err)
}
p := http.ProxyURL(proxyUrl)
client.Transport = &http.Transport{
Proxy: p,
}
http.DefaultClient.Transport = &http.Transport{
Proxy: p,
}
}
n := md5.Sum([]byte(account))
return PikPak{
Account: account,
Password: password,
DeviceId: hex.EncodeToString(n[:]),
client: client,
ctx: ctx,
}
}
func (p *PikPak) requestContext() context.Context {
if p != nil && p.ctx != nil {
return p.ctx
}
return context.Background()
}
func (p *PikPak) WithContext(ctx context.Context) *PikPak {
if p == nil {
return nil
}
clone := *p
if ctx == nil {
ctx = context.Background()
}
clone.ctx = ctx
return &clone
}
func (p *PikPak) newRequest(method, url string, body io.Reader) (*http.Request, error) {
return http.NewRequestWithContext(p.requestContext(), method, url, body)
}
// login performs the full credential-based login flow.
func (p *PikPak) login() error {
captchaToken, err := p.getCaptchaToken()
if err != nil {
return err
}
m := make(map[string]string)
m["client_id"] = clientID
m["client_secret"] = clientSecret
m["grant_type"] = "password"
m["username"] = p.Account
m["password"] = p.Password
m["captcha_token"] = captchaToken
bs, err := jsoniter.Marshal(&m)
if err != nil {
return err
}
req, err := p.newRequest("POST", "https://user.mypikpak.com/v1/auth/signin", bytes.NewBuffer(bs))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
bs, err = p.sendRequest(req)
if err != nil {
return err
}
error_code := jsoniter.Get(bs, "error_code").ToInt()
if error_code != 0 {
return fmt.Errorf("login error: %v", jsoniter.Get(bs, "error").ToString())
}
p.JwtToken = jsoniter.Get(bs, "access_token").ToString()
p.refreshToken = jsoniter.Get(bs, "refresh_token").ToString()
p.Sub = jsoniter.Get(bs, "sub").ToString()
p.RefreshSecond = jsoniter.Get(bs, "expires_in").ToInt64()
return nil
}
func (p *PikPak) getCaptchaToken() (string, error) {
m := make(map[string]any)
m["client_id"] = clientID
m["device_id"] = p.DeviceId
m["action"] = "POST:https://user.mypikpak.com/v1/auth/signin"
m["meta"] = map[string]string{
"username": p.Account,
}
body, err := jsoniter.Marshal(&m)
if err != nil {
return "", err
}
req, err := p.newRequest("POST", "https://user.mypikpak.com/v1/shield/captcha/init", bytes.NewBuffer(body))
if err != nil {
return "", err
}
req.Header.Add("Content-Type", "application/json")
bs, err := p.sendRequest(req)
if err != nil {
return "", err
}
error_code := jsoniter.Get(bs, "error_code").ToInt()
if error_code != 0 {
return "", fmt.Errorf("get captcha error: %v", jsoniter.Get(bs, "error").ToString())
}
return jsoniter.Get(bs, "captcha_token").ToString(), nil
}
func (p *PikPak) sendRequest(req *http.Request) ([]byte, error) {
p.setHeader(req)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
bs, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err != nil {
return nil, err
}
return bs, nil
}
func (p *PikPak) setHeader(req *http.Request) {
if p.JwtToken != "" {
req.Header.Set("Authorization", "Bearer "+p.JwtToken)
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("X-Device-Id", p.DeviceId)
}
// Login reuses a cached session first and falls back to full login when needed.
func (p *PikPak) Login() error {
if err := p.loadSession(); err == nil {
if !p.isTokenExpired() {
logx.Debugln("session", "session valid, skip login")
return nil
}
logx.Debugln("session", "access_token expired, trying refresh_token")
if err = p.RefreshToken(); err == nil {
p.saveSessionBestEffort()
return nil
}
logx.Debugln("session", "refresh failed, fallback to full login:", err)
} else {
logx.Debugln("session", "load session failed, fallback to full login:", err)
}
if err := p.login(); err != nil {
return err
}
p.saveSessionBestEffort()
return nil
}
================================================
FILE: internal/api/quota.go
================================================
package api
import (
"strconv"
jsoniter "github.com/json-iterator/go"
)
type QuotaMessage struct {
Kind string `json:"kind"`
Quota Quota `json:"quota"`
ExpiresAt string `json:"expires_at"`
Quotas Quotas `json:"quotas"`
}
type Quota struct {
Kind string `json:"kind"`
Limit string `json:"limit"`
Usage string `json:"usage"`
UsageInTrash string `json:"usage_in_trash"`
PlayTimesLimit string `json:"play_times_limit"`
PlayTimesUsage string `json:"play_times_usage"`
}
// Remaining returns the unused quota amount.
func (q Quota) Remaining() (int64, error) {
limit, err := strconv.ParseInt(q.Limit, 10, 64)
if err != nil {
return 0, err
}
usage, err := strconv.ParseInt(q.Usage, 10, 64)
if err != nil {
return 0, err
}
return limit - usage, nil
}
type Quotas struct {
CloudDownload Quota `json:"cloud_download"`
}
type TransferMessage struct {
Transfer TransferQuotaCollection `json:"transfer"`
Base TransferQuotaBase `json:"base"`
}
type TransferQuotaCollection struct {
Offline TransferQuota `json:"offline"`
Download TransferQuota `json:"download"`
Upload TransferQuota `json:"upload"`
}
type TransferQuotaBase struct {
Offline TransferQuota `json:"offline"`
Download TransferQuota `json:"download"`
Upload TransferQuota `json:"upload"`
}
type TransferQuota struct {
Info string `json:"info"`
TotalAssets int64 `json:"total_assets"`
Assets int64 `json:"assets"`
Size int64 `json:"size"`
}
func (q TransferQuota) Remaining() int64 {
return q.TotalAssets - q.Assets
}
// GetQuota get cloud quota
func (p *PikPak) GetQuota() (QuotaMessage, error) {
req, err := p.newRequest("GET", "https://api-drive.mypikpak.com/drive/v1/about", nil)
if err != nil {
return QuotaMessage{}, err
}
bs, err := p.sendRequest(req)
if err != nil {
return QuotaMessage{}, err
}
var quotaMessage QuotaMessage
err = jsoniter.Unmarshal(bs, "aMessage)
if err != nil {
return QuotaMessage{}, err
}
return quotaMessage, nil
}
// GetTransferQuota gets monthly transfer quota.
func (p *PikPak) GetTransferQuota() (TransferMessage, error) {
req, err := p.newRequest("GET", "https://api-drive.mypikpak.com/vip/v1/quantity/list?type=transfer&limit=200", nil)
if err != nil {
return TransferMessage{}, err
}
bs, err := p.sendRequest(req)
if err != nil {
return TransferMessage{}, err
}
var transferMessage TransferMessage
err = jsoniter.Unmarshal(bs, &transferMessage)
if err != nil {
return TransferMessage{}, err
}
return transferMessage, nil
}
================================================
FILE: internal/api/quota_test.go
================================================
package api
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestQuotaRemaining(t *testing.T) {
remaining, err := (Quota{Limit: "10", Usage: "3"}).Remaining()
require.NoError(t, err)
assert.Equal(t, int64(7), remaining)
}
func TestQuotaRemainingInvalid(t *testing.T) {
_, err := (Quota{Limit: "bad", Usage: "3"}).Remaining()
require.Error(t, err)
}
func TestTransferQuotaRemaining(t *testing.T) {
remaining := (TransferQuota{TotalAssets: 10, Assets: 3}).Remaining()
assert.Equal(t, int64(7), remaining)
}
================================================
FILE: internal/api/refresh_token.go
================================================
package api
import (
"bytes"
"fmt"
"github.com/52funny/pikpakcli/internal/logx"
jsoniter "github.com/json-iterator/go"
"github.com/tidwall/gjson"
)
func (p *PikPak) RefreshToken() error {
url := "https://user.mypikpak.com/v1/auth/token"
m := map[string]string{
"client_id": clientID,
"client_secret": clientSecret,
"grant_type": "refresh_token",
"refresh_token": p.refreshToken,
}
bs, err := jsoniter.Marshal(&m)
if err != nil {
return err
}
req, err := p.newRequest("POST", url, bytes.NewBuffer(bs))
if err != nil {
return err
}
bs, err = p.sendRequest(req)
if err != nil {
return err
}
error_code := gjson.GetBytes(bs, "error_code").Int()
if error_code != 0 {
// refresh token failed
if error_code == 4126 {
// Retry with the full login flow when the refresh token is no longer valid.
return p.login()
}
return fmt.Errorf("refresh token error message: %d", gjson.GetBytes(bs, "error").Int())
}
// logrus.Debug("refresh: ", string(bs))
p.JwtToken = gjson.GetBytes(bs, "access_token").String()
p.refreshToken = gjson.GetBytes(bs, "refresh_token").String()
p.RefreshSecond = gjson.GetBytes(bs, "expires_in").Int()
logx.Debugln("session", "refresh token succeeded")
return nil
}
================================================
FILE: internal/api/session.go
================================================
package api
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
)
const sessionExpirySkew = 5 * 60
// sessionData is the on-disk representation of cached auth tokens.
type sessionData struct {
JwtToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Sub string `json:"sub"`
// ExpiresAt stores the access token expiration time as a Unix timestamp in seconds.
ExpiresAt int64 `json:"expires_at"`
}
// saveSession persists the current token state to the local session file.
func (p *PikPak) saveSession() error {
path, err := sessionFile(p.Account)
if err != nil {
return err
}
if err := utils.CreateDirIfNotExist(filepath.Dir(path)); err != nil {
return fmt.Errorf("create session dir error: %w", err)
}
data := sessionData{
JwtToken: p.JwtToken,
RefreshToken: p.refreshToken,
Sub: p.Sub,
// Treat the token as expired slightly early to avoid using a near-expiry session.
ExpiresAt: time.Now().Unix() + p.RefreshSecond - sessionExpirySkew,
}
bs, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal session error: %w", err)
}
if err = os.WriteFile(path, bs, 0600); err != nil {
return fmt.Errorf("write session file error: %w", err)
}
logx.Debugln("session", "session saved to", path)
return nil
}
// loadSession restores cached tokens from disk into the current client.
func (p *PikPak) loadSession() error {
path, err := sessionFile(p.Account)
if err != nil {
return err
}
bs, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read session file error: %w", err)
}
var data sessionData
if err = json.Unmarshal(bs, &data); err != nil {
return fmt.Errorf("unmarshal session error: %w", err)
}
p.JwtToken = data.JwtToken
p.refreshToken = data.RefreshToken
p.Sub = data.Sub
p.RefreshSecond = data.ExpiresAt - time.Now().Unix()
logx.Debugln("session", "session loaded from", path)
return nil
}
// isTokenExpired reports whether the cached access token should be treated as expired.
func (p *PikPak) isTokenExpired() bool {
return p.RefreshSecond <= 0
}
func (p *PikPak) saveSessionBestEffort() {
if err := p.saveSession(); err != nil {
logx.Warn("session", "save session failed:", err)
}
}
func sessionFile(account string) (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("get config dir error: %w", err)
}
hash := md5.Sum([]byte(account))
filename := fmt.Sprintf("session_%s.json", hex.EncodeToString(hash[:]))
return filepath.Join(configDir, "pikpakcli", filename), nil
}
================================================
FILE: internal/api/sha.go
================================================
package api
import (
"bytes"
"fmt"
jsoniter "github.com/json-iterator/go"
)
func (p *PikPak) CreateShaFile(parentId, fileName, size, sha string) error {
m := map[string]interface{}{
"body": map[string]string{
"duration": "",
"width": "",
"height": "",
},
"kind": FileKindFile,
"name": fileName,
"size": size,
"hash": sha,
"upload_type": "UPLOAD_TYPE_RESUMABLE",
"objProvider": map[string]string{
"provider": "UPLOAD_TYPE_UNKNOWN",
},
}
if parentId != "" {
m["parent_id"] = parentId
}
bs, err := jsoniter.Marshal(&m)
if err != nil {
return err
}
START:
req, err := p.newRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Product_flavor_name", "cha")
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Client-Version-Code", "10083")
req.Header.Set("X-Peer-Id", p.DeviceId)
req.Header.Set("X-User-Region", "1")
req.Header.Set("X-Alt-Capability", "3")
req.Header.Set("Country", "CN")
bs, err = p.sendRequest(req)
if err != nil {
return err
}
error_code := jsoniter.Get(bs, "error_code").ToInt()
if error_code != 0 {
if error_code == 9 {
err := p.AuthCaptchaToken("POST:/drive/v1/files")
if err != nil {
return err
}
goto START
}
return fmt.Errorf("upload file error: %s", jsoniter.Get(bs, "error").ToString())
}
file := jsoniter.Get(bs, "file")
phase := file.Get("phase").ToString()
if phase == "PHASE_TYPE_COMPLETE" {
return nil
} else {
return fmt.Errorf("create file error: %s", phase)
}
}
================================================
FILE: internal/api/upload.go
================================================
package api
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakhash"
jsoniter "github.com/json-iterator/go"
)
type OssArgs struct {
Bucket string `json:"bucket"`
AccessKeyId string `json:"access_key_id"`
AccessKeySecret string `json:"access_key_secret"`
EndPoint string `json:"endpoint"`
Key string `json:"key"`
SecurityToken string `json:"security_token"`
}
// 256k
var defaultChunkSize int64 = 1 << 18
var Concurrent int64 = 1 << 4
var ErrNotFoundFolder = errors.New("not found pikpak folder")
func (p *PikPak) UploadFile(parentId, path string) error {
fileName := filepath.Base(path)
fileState, err := os.Stat(path)
if err != nil {
return err
}
fileSize := fileState.Size()
ph := pikpakhash.Default()
hash, err := ph.HashFromPath(path)
if err != nil {
return err
}
m := map[string]interface{}{
"body": map[string]string{
"duration": "",
"width": "",
"height": "",
},
"kind": FileKindFile,
"name": fileName,
"size": fmt.Sprintf("%d", fileSize),
"hash": hash,
"upload_type": "UPLOAD_TYPE_RESUMABLE",
"objProvider": map[string]string{
"provider": "UPLOAD_TYPE_UNKNOWN",
},
}
if parentId != "" {
m["parent_id"] = parentId
}
bs, err := jsoniter.Marshal(&m)
if err != nil {
return err
}
START:
req, err := p.newRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Product_flavor_name", "cha")
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Client-Version-Code", "10083")
req.Header.Set("X-Peer-Id", p.DeviceId)
req.Header.Set("X-User-Region", "1")
req.Header.Set("X-Alt-Capability", "3")
req.Header.Set("Country", "CN")
bs, err = p.sendRequest(req)
if err != nil {
return err
}
error_code := jsoniter.Get(bs, "error_code").ToInt()
if error_code != 0 {
if error_code == 9 {
err = p.AuthCaptchaToken("POST:/drive/v1/files")
if err != nil {
return err
}
goto START
}
return fmt.Errorf("upload file error: %s", jsoniter.Get(bs, "error").ToString())
}
// logrus.Debug(string(bs))
file := jsoniter.Get(bs, "file")
phase := file.Get("phase").ToString()
logx.Debug("upload", "path: ", path, " phase: ", phase)
switch phase {
case "PHASE_TYPE_COMPLETE":
logx.Debug("upload", path, " upload file complete")
return nil
case "PHASE_TYPE_PENDING":
// break switch
break
}
params := jsoniter.Get(bs, "resumable").Get("params")
accessKeyId := params.Get("access_key_id").ToString()
accessKeySecret := params.Get("access_key_secret").ToString()
bucket := params.Get("bucket").ToString()
endpoint := params.Get("endpoint").ToString()
key := params.Get("key").ToString()
securityToken := params.Get("security_token").ToString()
ossArgs := OssArgs{
Bucket: bucket,
AccessKeyId: accessKeyId,
AccessKeySecret: accessKeySecret,
EndPoint: endpoint,
Key: key,
SecurityToken: securityToken,
}
uploadId := p.beforeUpload(ossArgs)
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
wait := new(sync.WaitGroup)
in_wait := new(sync.WaitGroup)
ch := make(chan Part, Concurrent)
var chunkSize = int64(math.Ceil(float64(fileSize) / 10000))
if chunkSize < defaultChunkSize {
chunkSize = defaultChunkSize
}
for i := int64(0); i < Concurrent; i++ {
wait.Add(1)
go uploadChunk(p.requestContext(), wait, ch, f, chunkSize, fileSize, i, ossArgs, uploadId)
}
donePartSlice := make([]Part, 0)
in_wait.Add(1)
go func() {
defer in_wait.Done()
for p := range ch {
donePartSlice = append(donePartSlice, p)
}
}()
wait.Wait()
close(ch)
in_wait.Wait()
sort.Slice(donePartSlice, func(i, j int) bool {
iNum, _ := strconv.Atoi(donePartSlice[i].PartNumber)
jNum, _ := strconv.Atoi(donePartSlice[j].PartNumber)
return iNum < jNum
})
args := CompleteMultipartUpload{
Part: donePartSlice,
}
err = p.afterUpload(&args, ossArgs, uploadId)
if err != nil {
return err
}
return nil
}
func uploadChunk(ctx context.Context, wait *sync.WaitGroup, ch chan Part, f *os.File, ChunkSize, fileSize int64, part int64, ossArgs OssArgs, uploadId string) {
defer wait.Done()
if part*ChunkSize >= fileSize {
return
}
buf := make([]byte, ChunkSize)
var offset = part * ChunkSize
for offset < fileSize {
n, _ := f.ReadAt(buf, offset)
// if err != nil {
// // logrus.Error(err)
// }
if n > 0 {
value := url.Values{}
value.Add("uploadId", uploadId)
value.Add("partNumber", fmt.Sprintf("%d", part+1))
req, err := http.NewRequestWithContext(ctx, "PUT", fmt.Sprintf("https://%s/%s?%s",
ossArgs.EndPoint,
ossArgs.Key,
value.Encode()), bytes.NewBuffer(buf[:n]))
if err != nil {
continue
}
now := time.Now().UTC()
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("X-OSS-Security-Token", ossArgs.SecurityToken)
req.Header.Set("Date", now.Format(http.TimeFormat))
req.Header.Set("Authorization", "OSS "+ossArgs.AccessKeyId+":"+hmacAuthorization(req, nil, now, ossArgs))
resp, err := http.DefaultClient.Do(req)
if err != nil {
continue
}
// bs, _ := io.ReadAll(resp.Body)
eTag := strings.Trim(resp.Header.Get("ETag"), "\"")
p := Part{
PartNumber: fmt.Sprintf("%d", part+1),
ETag: eTag,
}
ch <- p
resp.Body.Close()
}
part = part + Concurrent
offset = part * ChunkSize
}
}
type header struct {
key string
val string
}
type CompleteMultipartUpload struct {
Part []Part `xml:"Part"`
}
type Part struct {
PartNumber string `xml:"PartNumber"`
ETag string `xml:"ETag"`
}
func hmacAuthorization(req *http.Request, body []byte, time time.Time, ossArgs OssArgs) string {
date := time.UTC().Format(http.TimeFormat)
stringBuilder := new(strings.Builder)
stringBuilder.WriteString(req.Method + "\n")
if body == nil {
stringBuilder.WriteString("\n")
} else {
// digest := md5.New()
// digest.Write(body)
// sign := base64.StdEncoding.EncodeToString(digest.Sum(nil))
// stringBuilder.WriteString(sign + "\n")
stringBuilder.WriteString("\n")
}
stringBuilder.WriteString(req.Header.Get("Content-Type") + "\n")
stringBuilder.WriteString(date + "\n")
headerSlice := make([]header, 0)
for k, v := range req.Header {
headerK := strings.ToLower(k)
if strings.Contains(headerK, "x-oss-") && len(v) > 0 {
headerSlice = append(headerSlice, header{headerK, v[0]})
}
}
// 从小到大排序
sort.Slice(headerSlice, func(i, j int) bool {
return headerSlice[i].key < headerSlice[j].key
})
for _, hd := range headerSlice {
stringBuilder.WriteString(hd.key + ":" + hd.val + "\n")
}
stringBuilder.WriteString("/" + ossArgs.Bucket + req.URL.Path + "?" + req.URL.RawQuery)
h := hmac.New(sha1.New, []byte(ossArgs.AccessKeySecret))
h.Write([]byte(stringBuilder.String()))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
func (p *PikPak) beforeUpload(ossArgs OssArgs) string {
req, err := p.newRequest("POST", "https://"+ossArgs.EndPoint+"/"+ossArgs.Key+"?uploads", nil)
if err != nil {
return ""
}
time := time.Now().UTC()
req.Header.Set("Date", time.Format(http.TimeFormat))
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("User-Agent", "aliyun-sdk-android/2.9.5(Linux/Android 11/ONEPLUS%20A6000;RKQ1.201217.002)")
req.Header.Set("X-Oss-Security-Token", ossArgs.SecurityToken)
req.Header.Set("Authorization",
fmt.Sprintf("%s %s:%s",
"OSS",
ossArgs.AccessKeyId,
hmacAuthorization(req, nil, time, ossArgs),
))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
bs, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}
type InitiateMultipartUploadResult struct {
Bucket string `xml:"Bucket"`
Key string `xml:"Key"`
UploadId string `xml:"UploadId"`
}
res := new(InitiateMultipartUploadResult)
err = xml.Unmarshal(bs, res)
if err != nil {
return ""
}
return res.UploadId
}
func (p *PikPak) afterUpload(args *CompleteMultipartUpload, ossArgs OssArgs, uploadId string) error {
bs, err := xml.Marshal(args)
if err != nil {
return err
}
req, err := p.newRequest("POST", "https://"+ossArgs.EndPoint+"/"+ossArgs.Key+"?uploadId="+uploadId, bytes.NewBuffer(bs))
if err != nil {
return err
}
time := time.Now().UTC()
req.Header.Set("Date", time.Format(http.TimeFormat))
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("User-Agent", "aliyun-sdk-android/2.9.5(Linux/Android 11/ONEPLUS%20A6000;RKQ1.201217.002)")
req.Header.Set("X-Oss-Security-Token", ossArgs.SecurityToken)
req.Header.Set("Authorization",
fmt.Sprintf("%s %s:%s",
"OSS",
ossArgs.AccessKeyId,
hmacAuthorization(req, nil, time, ossArgs),
))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
if err != nil {
return err
}
return nil
}
================================================
FILE: internal/api/url.go
================================================
package api
import (
"bytes"
"fmt"
"github.com/52funny/pikpakcli/internal/logx"
jsoniter "github.com/json-iterator/go"
)
func (p *PikPak) CreateUrlFile(parentId, url string) error {
m := map[string]interface{}{
"kind": FileKindFile,
"upload_type": "UPLOAD_TYPE_URL",
"url": map[string]string{
"url": url,
},
}
if parentId != "" {
m["parent_id"] = parentId
}
bs, err := jsoniter.Marshal(&m)
if err != nil {
return err
}
START:
req, err := p.newRequest("POST", "https://api-drive.mypikpak.com/drive/v1/files", bytes.NewBuffer(bs))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Product_flavor_name", "cha")
req.Header.Set("X-Captcha-Token", p.CaptchaToken)
req.Header.Set("X-Client-Version-Code", "10083")
req.Header.Set("X-Peer-Id", p.DeviceId)
req.Header.Set("X-User-Region", "1")
req.Header.Set("X-Alt-Capability", "3")
req.Header.Set("Country", "CN")
bs, err = p.sendRequest(req)
if err != nil {
return err
}
error_code := jsoniter.Get(bs, "error_code").ToInt()
if error_code != 0 {
if error_code == 9 {
err := p.AuthCaptchaToken("POST:/drive/v1/files")
if err != nil {
return err
}
goto START
}
return fmt.Errorf("upload file error: %s", jsoniter.Get(bs, "error").ToString())
}
task := jsoniter.Get(bs, "task")
logx.Debug("api", task.ToString())
// phase := task.Get("phase").ToString()
// if phase == "PHASE_TYPE_COMPLETE" {
// return nil
// } else {
// return fmt.Errorf("create file error: %s", phase)
// }
return nil
}
================================================
FILE: internal/logx/logx.go
================================================
package logx
import (
"fmt"
"log/slog"
"os"
"strings"
)
var enabledTopics = map[string]struct{}{}
var debugEnabled bool
var logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelError,
}))
func Init(debug bool, topics []string) {
enabledTopics = parseTopics(topics)
debugEnabled = debug || envEnabled("PIKPAKCLI_DEBUG") || len(enabledTopics) > 0
level := slog.LevelError
if debugEnabled {
level = slog.LevelDebug
}
logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
}))
slog.SetDefault(logger)
}
func Enabled(topic string) bool {
if !debugEnabled {
return false
}
if topic == "" {
return true
}
if _, ok := enabledTopics["all"]; ok {
return true
}
_, ok := enabledTopics[topic]
return ok
}
func Debug(topic string, args ...any) {
if Enabled(topic) {
logger.Debug(fmt.Sprint(args...))
}
}
func Debugln(topic string, args ...any) {
if Enabled(topic) {
logger.Debug(fmt.Sprintln(args...))
}
}
func Warn(topic string, args ...any) {
if Enabled(topic) {
logger.Warn(fmt.Sprint(args...))
}
}
func Warnf(topic, format string, args ...any) {
if Enabled(topic) {
logger.Warn(fmt.Sprintf(format, args...))
}
}
func Error(args ...any) {
logger.Error(fmt.Sprint(args...))
}
func Errorf(format string, args ...any) {
logger.Error(fmt.Sprintf(format, args...))
}
func parseTopics(topics []string) map[string]struct{} {
res := map[string]struct{}{}
for _, topic := range topics {
for _, item := range strings.Split(topic, ",") {
item = strings.TrimSpace(strings.ToLower(item))
if item == "" {
continue
}
res[item] = struct{}{}
}
}
envTopics := strings.Split(os.Getenv("PIKPAKCLI_DEBUG_TOPICS"), ",")
for _, item := range envTopics {
item = strings.TrimSpace(strings.ToLower(item))
if item == "" {
continue
}
res[item] = struct{}{}
}
if envEnabled("PIKPAKCLI_DEBUG") {
res["all"] = struct{}{}
}
return res
}
func envEnabled(key string) bool {
value := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
switch value {
case "1", "true", "yes", "on", "debug", "all":
return true
default:
return false
}
}
================================================
FILE: internal/shell/open.go
================================================
package shell
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/utils"
)
const (
openCategoryDefault = "default"
openCategoryText = "text"
openCategoryImage = "image"
openCategoryVideo = "video"
openCategoryAudio = "audio"
openCategoryPDF = "pdf"
)
type openFileService interface {
GetFileByPath(path string) (api.FileStat, error)
GetFile(fileID string) (api.File, error)
}
func handleOpenCommand(p openFileService, currentPath string, args []string) error {
if len(args) == 0 {
return errors.New("usage: open <remote-file> [remote-file...]")
}
for _, arg := range args {
targetPath := resolveShellPath(currentPath, arg)
stat, err := p.GetFileByPath(targetPath)
if err != nil {
return fmt.Errorf("open: %s: %w", targetPath, err)
}
if stat.Kind == api.FileKindFolder {
return fmt.Errorf("open: %s: folders are not supported", targetPath)
}
file, err := p.GetFile(stat.ID)
if err != nil {
return fmt.Errorf("open: %s: get file failed: %w", targetPath, err)
}
openTarget, err := resolveOpenTarget(&file)
if err != nil {
return fmt.Errorf("open: %s: resolve open target failed: %w", targetPath, err)
}
if err := openWithLocalApp(openTarget, classifyOpenCategory(file.Name)); err != nil {
return fmt.Errorf("open: %s: launch local app failed: %w", targetPath, err)
}
fmt.Printf("Opened %s -> %s\n", targetPath, openTarget)
}
return nil
}
func resolveOpenTarget(file *api.File) (string, error) {
if classifyOpenCategory(file.Name) == openCategoryVideo {
if url := remoteVideoOpenURL(file); url != "" {
return url, nil
}
}
return cacheOpenFile(file)
}
func cacheOpenFile(file *api.File) (string, error) {
cacheRoot, err := openCacheRoot()
if err != nil {
return "", err
}
cacheDir := filepath.Join(cacheRoot, file.ID)
if err := utils.CreateDirIfNotExist(cacheDir); err != nil {
return "", err
}
localPath := filepath.Join(cacheDir, file.Name)
matched, err := localFileMatchesRemoteSize(localPath, file.Size)
if err != nil {
return "", err
}
if matched {
return localPath, nil
}
if err := file.Download(localPath, nil); err != nil {
return "", err
}
return localPath, nil
}
func openCacheRoot() (string, error) {
if strings.TrimSpace(conf.Config.Open.DownloadDir) != "" {
root := utils.ExpandLocalPath(conf.Config.Open.DownloadDir)
if err := utils.CreateDirIfNotExist(root); err != nil {
return "", err
}
return root, nil
}
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(os.TempDir(), "pikpakcli")
}
root := filepath.Join(cacheDir, "pikpakcli", "open")
if err := utils.CreateDirIfNotExist(root); err != nil {
return "", err
}
return root, nil
}
func localFileMatchesRemoteSize(path string, remoteSize string) (bool, error) {
expectedSize, err := strconv.ParseInt(remoteSize, 10, 64)
if err != nil || expectedSize < 0 {
return false, nil
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return info.Size() == expectedSize, nil
}
func classifyOpenCategory(name string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".txt", ".md", ".markdown", ".log", ".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".csv",
".go", ".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".c", ".cc", ".cpp", ".h", ".hpp", ".sh", ".zsh":
return openCategoryText
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".heic", ".tiff":
return openCategoryImage
case ".mp4", ".mkv", ".mov", ".avi", ".wmv", ".flv", ".webm", ".m4v":
return openCategoryVideo
case ".mp3", ".flac", ".wav", ".aac", ".m4a", ".ogg", ".opus":
return openCategoryAudio
case ".pdf":
return openCategoryPDF
default:
return openCategoryDefault
}
}
func remoteVideoOpenURL(file *api.File) string {
for _, media := range file.Medias {
if media.IsDefault && media.IsVisible && strings.TrimSpace(media.Link.URL) != "" {
return media.Link.URL
}
}
for _, media := range file.Medias {
if media.IsVisible && strings.TrimSpace(media.Link.URL) != "" {
return media.Link.URL
}
}
for _, media := range file.Medias {
if strings.TrimSpace(media.Link.URL) != "" {
return media.Link.URL
}
}
if strings.TrimSpace(file.Links.ApplicationOctetStream.URL) != "" {
return file.Links.ApplicationOctetStream.URL
}
if strings.TrimSpace(file.WebContentLink) != "" {
return file.WebContentLink
}
return ""
}
func openWithLocalApp(target string, category string) error {
name, args, err := buildOpenCommand(runtime.GOOS, conf.Config.Open, target, category)
if err != nil {
return err
}
cmd := exec.Command(name, args...)
return cmd.Start()
}
func buildOpenCommand(goos string, cfg conf.OpenConfig, path string, category string) (string, []string, error) {
command := commandForCategory(cfg, category)
if len(command) == 0 {
command = defaultOpenCommand(goos, category)
}
if len(command) == 0 {
return "", nil, fmt.Errorf("unsupported platform: %s", goos)
}
resolved := make([]string, 0, len(command)+1)
hasPlaceholder := false
for _, item := range command {
if item == "{path}" {
resolved = append(resolved, path)
hasPlaceholder = true
continue
}
resolved = append(resolved, item)
}
if !hasPlaceholder {
resolved = append(resolved, path)
}
return resolved[0], resolved[1:], nil
}
func commandForCategory(cfg conf.OpenConfig, category string) []string {
switch category {
case openCategoryText:
if len(cfg.Text) > 0 {
return append([]string{}, cfg.Text...)
}
case openCategoryImage:
if len(cfg.Image) > 0 {
return append([]string{}, cfg.Image...)
}
case openCategoryVideo:
if len(cfg.Video) > 0 {
return append([]string{}, cfg.Video...)
}
case openCategoryAudio:
if len(cfg.Audio) > 0 {
return append([]string{}, cfg.Audio...)
}
case openCategoryPDF:
if len(cfg.PDF) > 0 {
return append([]string{}, cfg.PDF...)
}
}
if len(cfg.Default) > 0 {
return append([]string{}, cfg.Default...)
}
return nil
}
func defaultOpenCommand(goos string, category string) []string {
switch goos {
case "darwin":
switch category {
case openCategoryText:
return []string{"open", "-a", "TextEdit"}
case openCategoryImage, openCategoryPDF:
return []string{"open", "-a", "Preview"}
case openCategoryVideo, openCategoryAudio:
return []string{"open", "-a", "IINA"}
default:
return []string{"open"}
}
case "linux":
return []string{"xdg-open"}
case "windows":
return []string{"cmd", "/c", "start", ""}
default:
return nil
}
}
================================================
FILE: internal/shell/shell.go
================================================
package shell
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"path"
"path/filepath"
"slices"
"strings"
"github.com/52funny/pikpakcli/conf"
"github.com/52funny/pikpakcli/internal/api"
"github.com/52funny/pikpakcli/internal/logx"
"github.com/52funny/pikpakcli/internal/utils"
"github.com/chzyer/readline"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
var builtInCommands = []string{"cd", "clear", "exit", "help", "open", "quit"}
const clearScreenSequence = "\033[H\033[2J"
type fileStatProvider interface {
GetPathFolderId(dirPath string) (string, error)
GetFolderFileStatList(parentId string) ([]api.FileStat, error)
}
type shellAutoCompleter struct {
rootCmd *cobra.Command
fileStatSource fileStatProvider
currentPath func() string
}
// Start starts the interactive shell
func Start(rootCmd *cobra.Command) {
fmt.Println("PikPak CLI Interactive Shell")
fmt.Println("Type 'help' for available commands, 'exit' or Ctrl-D to quit")
fmt.Println()
currentPath := "/"
p := api.NewPikPak(conf.Config.Username, conf.Config.Password)
if err := p.Login(); err != nil {
fmt.Println("Login failed")
logx.Error(err)
return
}
l, err := readline.NewEx(&readline.Config{
Prompt: promptForPath(currentPath),
AutoComplete: &shellAutoCompleter{
rootCmd: rootCmd,
fileStatSource: &p,
currentPath: func() string {
return currentPath
},
},
})
if err != nil {
fmt.Println("Initialize readline failed")
logx.Error(err)
return
}
defer l.Close()
for {
input, err := l.Readline()
if isReadlineInterrupt(err) {
fmt.Println()
l.SetPrompt(promptForPath(currentPath))
continue
}
if shouldExitOnReadlineError(err) {
fmt.Println("\nBye~!")
return
}
if err != nil {
fmt.Println("\nBye~!")
break
}
input = strings.TrimSpace(input)
if input == "" {
continue
}
args := parseShellArgs(input)
if len(args) == 0 {
continue
}
switch args[0] {
case "exit", "quit":
fmt.Println("Bye~!")
return
case "help":
rootCmd.Help()
continue
case "clear":
clearScreen(os.Stdout)
l.SetPrompt(promptForPath(currentPath))
continue
case "cd":
nextPath, err := changeDirectory(&p, currentPath, args[1:])
if err != nil {
fmt.Println("Change directory failed")
logx.Error(err)
continue
}
currentPath = nextPath
l.SetPrompt(promptForPath(currentPath))
continue
case "open":
expandedArgs, err := expandOpenGlobs(currentPath, &p, args[1:])
if err != nil {
fmt.Println(err.Error())
continue
}
cmdCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
if err := handleOpenCommand(p.WithContext(cmdCtx), currentPath, expandedArgs); err != nil {
fmt.Println(err.Error())
}
stop()
if cmdCtx.Err() != nil {
fmt.Println()
}
continue
}
args = adaptShellArgs(rootCmd, currentPath, args)
args, err = expandShellGlobs(rootCmd, currentPath, &p, args)
if err != nil {
fmt.Println(err.Error())
continue
}
cmdCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
setCommandContextTree(rootCmd, cmdCtx)
rootCmd.SetArgs(args)
rootCmd.Execute()
stop()
setCommandContextTree(rootCmd, context.Background())
rootCmd.SetArgs([]string{})
resetFlags(rootCmd)
if cmdCtx.Err() != nil {
fmt.Println()
}
}
}
func shouldExitOnReadlineError(err error) bool {
return err == io.EOF
}
func isReadlineInterrupt(err error) bool {
return err == readline.ErrInterrupt
}
func setCommandContextTree(cmd *cobra.Command, ctx context.Context) {
cmd.SetContext(ctx)
for _, child := range cmd.Commands() {
setCommandContextTree(child, ctx)
}
}
func (c *shellAutoCompleter) Do(line []rune, pos int) ([][]rune, int) {
input := string(line[:pos])
tokens, active, endedWithSpace := splitCompletionLine(input)
if len(tokens) == 0 {
return completeFromPrefix(active, commandCandidates(c.rootCmd), true)
}
if tokens[0] == "cd" {
return c.completeRemotePath(active, true)
}
if tokens[0] == "open" {
return c.completeRemotePath(active, false)
}
cmd, consumed := resolveCommand(c.rootCmd, tokens)
if consumed == 0 && !endedWithSpace {
return completeFromPrefix(active, commandCandidates(c.rootCmd), true)
}
if cmd == nil {
return nil, 0
}
if len(cmd.Commands()) > 0 && (endedWithSpace || active != "") && len(tokens) == consumed {
return completeFromPrefix(active, subcommandCandidates(cmd), true)
}
if strings.HasPrefix(active, "-") {
return completeFromPrefix(active, flagCandidates(cmd), true)
}
commandKey := canonicalCommandKey(c.rootCmd, cmd)
if shouldCompleteLocalPathFlagValue(commandKey, tokens, active, endedWithSpace) {
return completeLocalPath(active, false)
}
if shouldCompleteDirectoryPath(commandKey, tokens, active, endedWithSpace, consumed) {
return c.completeRemotePath(active, true)
}
if shouldCompleteRemoteTargetPath(commandKey, tokens, active, consumed) {
return c.completeRemotePath(active, false)
}
if shouldCompleteLocalTargetPath(commandKey, tokens, active, consumed) {
return completeLocalPath(active, false)
}
return nil, 0
}
func shouldCompleteLocalPathFlagValue(commandKey string, tokens []string, active string, endedWithSpace bool) bool {
if commandKey == "" {
return false
}
switch commandKey {
case "rubbish":
return wantsFlagValue(tokens, active, endedWithSpace, "--rules")
default:
return false
}
}
func shouldCompleteDirectoryPath(commandKey string, tokens []string, active string, endedWithSpace bool, consumed int) bool {
if commandKey == "" {
return false
}
if wantsFlagValue(tokens, active, endedWithSpace, "-p", "--path") {
switch commandKey {
case "ls", "empty", "rubbish", "download", "share", "upload", "delete", "new folder", "new url", "new sha":
return true
}
}
positionalsAfterCommand := positionalTokens(tokens[consumed:], active)
switch commandKey {
case "ls", "empty", "rubbish":
return len(positionalsAfterCommand) <= 1
}
return false
}
func shouldCompleteRemoteTargetPath(commandKey string, tokens []string, active string, consumed int) bool {
if commandKey == "" || active == "" {
return false
}
positionalsAfterCommand := positionalTokens(tokens[consumed:], active)
switch commandKey {
case "download", "share", "delete":
return len(positionalsAfterCommand) >= 1
case "rename":
return len(positionalsAfterCommand) == 1
default:
return false
}
}
func shouldCompleteLocalTargetPath(commandKey string, tokens []string, active string, consumed int) bool {
if commandKey == "" || active == "" {
return false
}
positionalsAfterCommand := positionalTokens(tokens[consumed:], active)
switch commandKey {
case "upload":
return len(positionalsAfterCommand) >= 1
default:
return false
}
}
func wantsFlagValue(tokens []string, active string, endedWithSpace bool, flags ...string) bool {
if len(tokens) == 0 {
return false
}
last := tokens[len(tokens)-1]
if endedWithSpace {
return slices.Contains(flags, last)
}
if active != "" {
return slices.Contains(flags, last)
}
return false
}
func positionalTokens(tokens []string, active string) []string {
positionals := make([]string, 0)
stopFlags := false
for i := 0; i < len(tokens); i++ {
token := tokens[i]
if stopFlags {
positionals = append(positionals, token)
continue
}
switch {
case token == "--":
stopFlags = true
case token == "-p" || token == "--path" ||
token == "-P" || token == "--parent-id" ||
token == "-o" || token == "--output" ||
token == "-i" || token == "--input" ||
token == "-c" || token == "--count" ||
token == "--rules":
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "-"):
default:
positionals = append(positionals, token)
}
}
if active != "" {
positionals = append(positionals, active)
}
return positionals
}
func (c *shellAutoCompleter) completeRemotePath(prefix string, onlyDirs bool) ([][]rune, int) {
currentPath := c.currentPath()
targetPath := resolveShellPath(currentPath, prefix)
basePrefix := prefix
if strings.TrimSpace(prefix) == "" {
targetPath = currentPath
basePrefix = ""
}
parentPath := targetPath
namePrefix := ""
if prefix != "" && !strings.HasSuffix(prefix, "/") {
parentPath = path.Dir(targetPath)
if parentPath == "." {
parentPath = "/"
}
namePrefix = path.Base(targetPath)
}
parentID := ""
var err error
if parentPath != "/" {
parentID, err = c.fileStatSource.GetPathFolderId(parentPath)
if err != nil {
return nil, len([]rune(basePrefix))
}
}
files, err := c.fileStatSource.GetFolderFileStatList(parentID)
if err != nil {
return nil, len([]rune(basePrefix))
}
candidates := make([]string, 0)
for _, file := range files {
if onlyDirs && file.Kind != api.FileKindFolder {
continue
}
if !strings.HasPrefix(file.Name, namePrefix) {
continue
}
remaining := file.Name[len(namePrefix):]
if file.Kind == api.FileKindFolder {
remaining += "/"
}
candidates = append(candidates, escapeShellCompletion(remaining))
}
return toRuneCandidates(candidates), len([]rune(basePrefix))
}
func completeLocalPath(prefix string, onlyDirs bool) ([][]rune, int) {
expandedPrefix := utils.ExpandLocalPath(prefix)
parentPath := "."
basePrefix := prefix
namePrefix := expandedPrefix
hasTrailingSeparator := strings.HasSuffix(prefix, string(filepath.Separator))
if strings.TrimSpace(prefix) == "" {
basePrefix = ""
namePrefix = ""
} else if !hasTrailingSeparator {
parentPath = filepath.Dir(expandedPrefix)
if parentPath == "." && filepath.IsAbs(expandedPrefix) {
parentPath = string(filepath.Separator)
}
namePrefix = filepath.Base(expandedPrefix)
} else {
parentPath = expandedPrefix
namePrefix = ""
}
entries, err := os.ReadDir(parentPath)
if err != nil {
return nil, len([]rune(basePrefix))
}
candidates := make([]string, 0)
for _, entry := range entries {
if onlyDirs && !entry.IsDir() {
continue
}
if !strings.HasPrefix(entry.Name(), namePrefix) {
continue
}
remaining := entry.Name()[len(namePrefix):]
if entry.IsDir() {
remaining += string(filepath.Separator)
}
candidates = append(candidates, escapeShellCompletion(remaining))
}
return toRuneCandidates(candidates), len([]rune(basePrefix))
}
func promptForPath(currentPath string) string {
if currentPath == "/" {
return "pikpak / > "
}
return fmt.Sprintf("pikpak %s/ > ", currentPath)
}
func clearScreen(w io.Writer) {
fmt.Fprint(w, clearScreenSequence)
}
func adaptShellArgs(rootCmd *cobra.Command, currentPath string, args []string) []string {
if len(args) == 0 {
return args
}
cmd, consumed := resolveCommand(rootCmd, args)
if consumed == 0 {
return args
}
commandKey := canonicalCommandKey(rootCmd, cmd)
rest := append([]string{}, args[consumed:]...)
flags := inspectShellArgs(rest)
switch commandKey {
case "ls", "empty", "rubbish":
rest = rewritePositionalPaths(rest, currentPath, 1)
if flags.positionals == 0 && !flags.hasPath && !flags.hasParentID {
rest = append([]string{"-p", currentPath}, rest...)
}
case "download":
rest = rewritePathFlagValues(rest, currentPath)
if flags.positionals > 0 && !flags.hasPath && !flags.hasParentID {
rest = append([]string{"-p", currentPath}, rest...)
}
case "upload":
rest = rewritePathFlagValues(rest, currentPath)
if flags.positionals > 0 && !flags.hasPath && !flags.hasParentID {
rest = append([]string{"-p", currentPath}, rest...)
}
case "share", "new folder", "new url", "new sha":
rest = rewritePathFlagValues(rest, currentPath)
if !flags.hasPath && !flags.hasParentID {
rest = append([]string{"-p", currentPath}, rest...)
}
case "delete":
if !flags.hasPath {
rest = rewritePositionalPaths(rest, currentPath, -1)
}
case "rename":
rest = rewritePositionalPaths(rest, currentPath, 1)
}
return append(append([]string{}, args[:consumed]...), rest...)
}
func canonicalCommandKey(rootCmd *cobra.Command, cmd *cobra.Command) string {
if cmd == nil {
return ""
}
path := cmd.CommandPath()
rootName := rootCmd.Name()
if path == rootName {
return ""
}
return strings.TrimPrefix(path, rootName+" ")
}
type shellArgFlags struct {
hasPath bool
hasParentID bool
positionals int
}
func inspectShellArgs(args []string) shellArgFlags {
var flags shellArgFlags
stopFlags := false
for i := 0; i < len(args); i++ {
token := args[i]
if stopFlags {
flags.positionals++
continue
}
switch {
case token == "--":
stopFlags = true
case token == "--path" || token == "-p":
flags.hasPath = true
if i+1 < len(args) {
i++
}
case strings.HasPrefix(token, "--path=") || strings.HasPrefix(token, "-p="):
flags.hasPath = true
case token == "--parent-id" || token == "-P":
flags.hasParentID = true
if i+1 < len(args) {
i++
}
case token == "--rules":
if i+1 < len(args) {
i++
}
case strings.HasPrefix(token, "--parent-id=") || strings.HasPrefix(token, "-P="):
flags.hasParentID = true
case strings.HasPrefix(token, "-"):
default:
flags.positionals++
}
}
return flags
}
func rewritePathFlagValues(args []string, currentPath string) []string {
rewritten := append([]string{}, args...)
for i := 0; i < len(rewritten); i++ {
switch token := rewritten[i]; {
case token == "--path" || token == "-p":
if i+1 < len(rewritten) {
rewritten[i+1] = resolveShellPath(currentPath, rewritten[i+1])
i++
}
case strings.HasPrefix(token, "--path="):
rewritten[i] = "--path=" + resolveShellPath(currentPath, strings.TrimPrefix(token, "--path="))
case strings.HasPrefix(token, "-p="):
rewritten[i] = "-p=" + resolveShellPath(currentPath, strings.TrimPrefix(token, "-p="))
}
}
return rewritten
}
func rewritePositionalPaths(args []string, currentPath string, limit int) []string {
rewritten := append([]string{}, args...)
stopFlags := false
rewrittenCount := 0
for i := 0; i < len(rewritten); i++ {
token := rewritten[i]
if stopFlags {
if limit < 0 || rewrittenCount < limit {
rewritten[i] = resolveShellPath(currentPath, token)
rewrittenCount++
}
continue
}
switch {
case token == "--":
stopFlags = true
case token == "--path" || token == "-p" || token == "--parent-id" || token == "-P" || token == "--output" || token == "-o" || token == "--input" || token == "-i" || token == "--count" || token == "-c" || token == "--rules":
if i+1 < len(rewritten) {
i++
}
case strings.HasPrefix(token, "-"):
default:
if limit >= 0 && rewrittenCount >= limit {
continue
}
rewritten[i] = resolveShellPath(currentPath, token)
rewrittenCount++
}
}
return rewritten
}
func changeDirectory(p *api.PikPak, currentPath string, args []string) (string, error) {
target := "/"
if len(args) > 0 {
target = args[0]
}
targetPath := resolveShellPath(currentPath, target)
if targetPath == "/" {
return targetPath, nil
}
if _, err := p.GetPathFolderId(targetPath); err != nil {
return "", fmt.Errorf("cd: %s: no such directory", targetPath)
}
return targetPath, nil
}
func resolveShellPath(currentPath string, target string) string {
switch strings.TrimSpace(target) {
case "", "~", "/":
return "/"
}
if strings.HasPrefix(target, "/") {
return path.Clean(target)
}
return path.Clean(path.Join(currentPath, target))
}
func expandOpenGlobs(currentPath string, source fileStatProvider, args []string) ([]string, error) {
expanded := make([]string, 0, len(args))
for _, arg := range args {
matches, err := expandRemotePatternToken(arg, "", currentPath, source, false)
if err != nil {
return nil, err
}
expanded = append(expanded, matches...)
}
return expanded, nil
}
func expandShellGlobs(rootCmd *cobra.Command, currentPath string, source fileStatProvider, args []string) ([]string, error) {
if len(args) == 0 {
return args, nil
}
cmd, consumed := resolveCommand(rootCmd, args)
if consumed == 0 {
return args, nil
}
commandKey := canonicalCommandKey(rootCmd, cmd)
rest := append([]string{}, args[consumed:]...)
var (
expanded []string
err error
)
switch commandKey {
case "download":
expanded, err = expandDownloadGlobs(rest, currentPath, source)
case "delete":
expanded, err = expandDeleteGlobs(rest, currentPath, source)
case "upload":
expanded, err = expandUploadGlobs(rest)
default:
return args, nil
}
if err != nil {
return nil, err
}
return append(append([]string{}, args[:consumed]...), expanded...), nil
}
func expandDownloadGlobs(args []string, currentPath string, source fileStatProvider) ([]string, error) {
return rewriteDownloadLikeArgs(args, currentPath, source)
}
func expandDeleteGlobs(args []string, currentPath string, source fileStatProvider) ([]string, error) {
rewritten := make([]string, 0, len(args))
stopFlags := false
pathValue := ""
for i := 0; i < len(args); i++ {
token := args[i]
if stopFlags {
matches, err := expandDeletePatternToken(token, pathValue, currentPath, source)
if err != nil {
return nil, err
}
rewritten = append(rewritten, matches...)
continue
}
switch {
case token == "--":
stopFlags = true
rewritten = append(rewritten, token)
case token == "--path" || token == "-p":
rewritten = append(rewritten, token)
if i+1 < len(args) {
pathValue = args[i+1]
rewritten = append(rewritten, pathValue)
i++
}
case strings.HasPrefix(token, "--path="):
pathValue = strings.TrimPrefix(token, "--path=")
rewritten = append(rewritten, token)
case strings.HasPrefix(token, "-p="):
pathValue = strings.TrimPrefix(token, "-p=")
rewritten = append(rewritten, token)
default:
if consumesNextValue(token) {
rewritten = append(rewritten, token)
if i+1 < len(args) {
rewritten = append(rewritten, args[i+1])
i++
}
continue
}
if strings.HasPrefix(token, "-") {
rewritten = append(rewritten, token)
continue
}
matches, err := expandDeletePatternToken(token, pathValue, currentPath, source)
if err != nil {
return nil, err
}
rewritten = append(rewritten, matches...)
}
}
return rewritten, nil
}
func expandUploadGlobs(args []string) ([]string, error) {
rewritten := make([]string, 0, len(args))
stopFlags := false
for i := 0; i < len(args); i++ {
token := args[i]
if stopFlags {
matches, err := expandLocalPatternToken(token)
if err != nil {
return nil, err
}
rewritten = append(rewritten, matches...)
continue
}
switch {
case token == "--":
stopFlags = true
rewritten = append(rewritten, token)
case token == "--path" || token == "-p" ||
token == "--parent-id" || token == "-P" ||
token == "--concurrency" || token == "-c" ||
token == "--exn" || token == "-e":
rewritten = append(rewritten, token)
if i+1 < len(args) {
rewritten = append(rewritten, args[i+1])
i++
}
case strings.HasPrefix(token, "--path=") ||
strings.HasPrefix(token, "-p=") ||
strings.HasPrefix(token, "--parent-id=") ||
strings.HasPrefix(token, "-P=") ||
strings.HasPrefix(token, "--concurrency=") ||
strings.HasPrefix(token, "-c=") ||
strings.HasPrefix(token, "--exn=") ||
strings.HasPrefix(token, "-e=") ||
strings.HasPrefix(token, "-"):
rewritten = append(rewritten, token)
default:
matches, err := expandLocalPatternToken(token)
if err != nil {
return nil, err
}
rewritten = append(rewritten, matches...)
}
}
return rewritten, nil
}
func rewriteDownloadLikeArgs(args []string, currentPath string, source fileStatProvider) ([]string, error) {
rewritten := make([]string, 0, len(args))
stopFlags := false
pathValue := ""
hasParentID := false
for i := 0; i < len(args); i++ {
token := args[i]
if stopFlags {
matches, err := expandRemotePatternToken(token, pathValue, currentPath, source, true)
if err != nil {
return nil, err
}
rewritten = append(rewritten, matches...)
continue
}
switch {
case token == "--":
stopFlags = true
rewritten = append(rewritten, token)
case token == "--path" || token == "-p":
rewritten = append(rewritten, token)
if i+1 < len(args) {
pathValue = args[i+1]
rewritten = append(rewritten, pathValue)
i++
}
case strings.HasPrefix(token, "--path="):
pathValue = strings.TrimPrefix(token, "--path=")
rewritten = append(rewritten, token)
case strings.HasPrefix(token, "-p="):
pathValue = strings.TrimPrefix(token, "-p=")
rewritten = append(rewritten, token)
case token == "--parent-id" || token == "-P":
hasParentID = true
rewritten = append(rewritten, token)
if i+1 < len(args) {
rewritten = append(rewritten, args[i+1])
i++
}
case strings.HasPrefix(token, "--parent-id=") || strings.HasPrefix(token, "-P="):
hasParentID = true
rewritten = append(rewritten, token)
default:
if consumesNextValue(token) {
rewritten = append(rewritten, token)
if i+1 < len(args) {
rewritten = append(rewritten, args[i+1])
i++
}
continue
}
if strings.HasPrefix(token, "-") {
rewritten = append(rewritten, token)
continue
}
if hasParentID && pathValue == "" && hasWildcard(token) {
return nil, fmt.Errorf("shell: wildcard expansion with --parent-id requires --path")
}
matches, err := expandRemotePatternToken(token, pathValue, currentPath, source, true)
if err != nil {
return nil, err
}
rewritten = append(rewritten, matches...)
}
}
return rewritten, nil
}
func expandDeletePatternToken(token string, pathValue string, currentPath string, source fileStatProvider) ([]string, error) {
if !hasWildcard(token) {
return []string{token}, nil
}
if pathValue != "" && !path.IsAbs(token) && strings.Contains(token, "/") {
return nil, fmt.Errorf("shell: wildcard expansion with -p does not support nested remote paths: %s", token)
}
return expandRemotePatternToken(token, pathValue, currentPath, source, pathValue != "")
}
func expandRemotePatternToken(token string, pathValue string, currentPath string, source fileStatProvider, preferRelative bool) ([]string, error) {
if !hasWildcard(token) {
return []string{token}, nil
}
basePath := currentPath
if strings.TrimSpace(pathValue) != "" {
basePath = pathValue
}
patternPath := token
if !path.IsAbs(patternPath) {
patternPath = path.Clean(path.Join(basePath, patternPath))
} else {
patternPath = path.Clean(patternPath)
}
parentPath := path.Dir(patternPath)
if parentPath == "." {
parentPath = "/"
}
matches, err := matchRemotePattern(source, parentPath, path.Base(patternPath))
if err != nil {
return nil, err
}
if len(matches) == 0 {
return nil, fmt.Errorf("shell: no matches found for %s", token)
}
if preferRelative && !path.IsAbs(token) && strings.TrimSpace(pathValue) != "" {
rewritten := make([]string, 0, len(matches))
for _, match := range matches {
rewritten = append(rewritten, relativeRemotePath(pathValue, match))
}
return rewritten, nil
}
return matches, nil
}
func matchRemotePattern(source fileStatProvider, parentPath string, pattern string) ([]string, error) {
parentID := ""
if parentPath != "/" {
var err error
parentID, err = source.GetPathFolderId(parentPath)
if err != nil {
return nil, err
}
}
files, err := source.GetFolderFileStatList(parentID)
if err != nil {
return nil, err
}
matches := make([]string, 0)
for _, file := range files {
matched, err := path.Match(pattern, file.Name)
if err != nil {
return nil, fmt.Errorf("shell: invalid wildcard pattern %s: %w", pattern, err)
}
if matched {
matches = append(matches, path.Join(parentPath, file.Name))
}
}
return matches, nil
}
func expandLocalPatternToken(token string) ([]string, error) {
if !hasWildcard(token) {
return []string{token}, nil
}
pattern := utils.ExpandLocalPath(token)
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("shell: invalid wildcard pattern %s: %w", token, err)
}
if len(matches) == 0 {
return nil, fmt.Errorf("shell: no matches found for %s", token)
}
return matches, nil
}
func consumesNextValue(token string) bool {
switch token {
case "--path", "-p",
"--parent-id", "-P",
"--output", "-o",
"--input", "-i",
"--count", "-c",
"--rules":
return true
default:
return false
}
}
func hasWildcard(value string) bool {
return strings.ContainsAny(value, "*?[")
}
func relativeRemotePath(basePath string, fullPath string) string {
base := path.Clean(basePath)
full := path.Clean(fullPath)
if base == "/" {
return strings.TrimPrefix(full, "/")
}
prefix := base + "/"
if strings.HasPrefix(full, prefix) {
return strings.TrimPrefix(full, prefix)
}
return full
}
func splitCompletionLine(input string) ([]string, string, bool) {
args := make([]string, 0)
var current strings.Builder
inDoubleQuote := false
inSingleQuote := false
endedWithSpace := false
escaped := false
for i := 0; i < len(input); i++ {
ch := input[i]
if escaped {
current.WriteByte(ch)
escaped = false
endedWithSpace = false
continue
}
switch ch {
case '\\':
if inSingleQuote {
current.WriteByte(ch)
} else {
escaped = true
}
endedWithSpace = false
case '"':
endedWithSpace = false
if inSingleQuote {
current.WriteByte(ch)
} else {
inDoubleQuote = !inDoubleQuote
}
case '\'':
endedWithSpace = false
if inDoubleQuote {
current.WriteByte(ch)
} else {
inSingleQuote = !inSingleQuote
}
case ' ', '\t':
if inDoubleQuote || inSingleQuote {
current.WriteByte(ch)
endedWithSpace = false
} else {
if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
endedWithSpace = true
}
default:
current.WriteByte(ch)
endedWithSpace = false
}
}
if current.Len() > 0 {
return args, current.String(), false
}
return args, "", endedWithSpace
}
func commandCandidates(rootCmd *cobra.Command) []string {
candidates := append([]string{}, builtInCommands...)
candidates = append(candidates, subcommandCandidates(rootCmd)...)
slices.Sort(candidates)
return slices.Compact(candidates)
}
func subcommandCandidates(cmd *cobra.Command) []string {
candidates := make([]string, 0)
for _, sub := range cmd.Commands() {
if sub.Hidden {
continue
}
candidates = append(candidates, sub.Name())
candidates = append(candidates, sub.Aliases...)
}
slices.Sort(candidates)
return slices.Compact(candidates)
}
func flagCandidates(cmd *cobra.Command) []string {
candidates := make([]string, 0)
cmd.Flags().VisitAll(func(f *pflag.Flag) {
candidates = append(candidates, "--"+f.Name)
if f.Shorthand != "" {
candidates = append(candidates, "-"+f.Shorthand)
}
})
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
candidates = append(candidates, "--"+f.Name)
if f.Shorthand != "" {
candidates = append(candidates, "-"+f.Shorthand)
}
})
slices.Sort(candid
gitextract_u9bxj4ec/
├── .github/
│ └── workflows/
│ ├── dockerhub.yml
│ └── goreleaser.yml
├── .gitignore
├── .goreleaser.yaml
├── Dockerfile
├── LICENSE
├── README.md
├── README_zhCN.md
├── cli/
│ ├── del/
│ │ └── del.go
│ ├── download/
│ │ ├── download.go
│ │ ├── download_test.go
│ │ └── progress_test.go
│ ├── empty/
│ │ ├── empty.go
│ │ └── empty_test.go
│ ├── list/
│ │ └── list.go
│ ├── new/
│ │ ├── folder/
│ │ │ └── folder.go
│ │ ├── new.go
│ │ ├── sha/
│ │ │ └── sha.go
│ │ └── url/
│ │ └── url.go
│ ├── quota/
│ │ ├── quota.go
│ │ └── quota_test.go
│ ├── rename/
│ │ └── rename.go
│ ├── root.go
│ ├── rubbish/
│ │ ├── rubbish.go
│ │ └── rubbish_test.go
│ ├── share/
│ │ └── share.go
│ ├── shell.go
│ └── upload/
│ └── upload.go
├── conf/
│ └── config.go
├── config_example.yml
├── docs/
│ ├── command.md
│ ├── command_docker.md
│ ├── command_zhCN.md
│ ├── config.md
│ └── config_zhCN.md
├── go.mod
├── go.sum
├── internal/
│ ├── api/
│ │ ├── captcha_token.go
│ │ ├── constants.go
│ │ ├── download.go
│ │ ├── download_test.go
│ │ ├── file.go
│ │ ├── file_test.go
│ │ ├── folder.go
│ │ ├── glob.go
│ │ ├── glob_test.go
│ │ ├── pikpak.go
│ │ ├── quota.go
│ │ ├── quota_test.go
│ │ ├── refresh_token.go
│ │ ├── session.go
│ │ ├── sha.go
│ │ ├── upload.go
│ │ └── url.go
│ ├── logx/
│ │ └── logx.go
│ ├── shell/
│ │ ├── open.go
│ │ ├── shell.go
│ │ └── shell_test.go
│ └── utils/
│ ├── format.go
│ ├── format_test.go
│ ├── path.go
│ ├── path_test.go
│ └── sync.go
├── main.go
└── rules/
├── README.md
└── rubbish_rules.txt
SYMBOL INDEX (347 symbols across 46 files)
FILE: cli/del/del.go
function init (line 45) | func init() {
function groupDeleteTargets (line 49) | func groupDeleteTargets(args []string, forceParentPath bool) map[string]...
function deleteEntries (line 73) | func deleteEntries(p *api.PikPak, parentPath string, names []string) err...
FILE: cli/download/download.go
type warpFile (line 68) | type warpFile struct
type warpStat (line 73) | type warpStat struct
constant progressNameMaxRunes (line 78) | progressNameMaxRunes = 36
function init (line 80) | func init() {
type downloadTargetResolver (line 88) | type downloadTargetResolver interface
function handleDownload (line 94) | func handleDownload(cmd *cobra.Command, p *api.PikPak, args []string) {
function requiresExplicitOutputFlag (line 111) | func requiresExplicitOutputFlag(cmd *cobra.Command, args []string) bool {
function downloadTarget (line 124) | func downloadTarget(p *api.PikPak, arg string) {
function downloadFolder (line 146) | func downloadFolder(p *api.PikPak, folderID string, rootOutput string) {
function downloadStats (line 152) | func downloadStats(p *api.PikPak, collectStat []warpStat) {
function recursive (line 210) | func recursive(p *api.PikPak, collectWarpFile *[]warpStat, parentId stri...
function downloadFiles (line 231) | func downloadFiles(p *api.PikPak, files []warpFile) {
function startDownloadWorkers (line 248) | func startDownloadWorkers(sendCh <-chan warpFile, receiveCh chan<- struc...
function resolveDownloadTarget (line 264) | func resolveDownloadTarget(p downloadTargetResolver, arg string) (api.Fi...
function remoteTargetPath (line 295) | func remoteTargetPath(arg string) string {
function localOutputRoot (line 307) | func localOutputRoot(name string) string {
function mustGetFile (line 314) | func mustGetFile(p *api.PikPak, stat api.FileStat) *api.File {
function progressDisplayName (line 324) | func progressDisplayName(warp warpFile) string {
function trimRunes (line 332) | func trimRunes(value string, max int) string {
function download (line 343) | func download(inCh <-chan warpFile, out chan<- struct{}, pb *mpb.Progres...
FILE: cli/download/download_test.go
type fakeTargetResolver (line 13) | type fakeTargetResolver struct
method GetFileByPath (line 19) | func (f fakeTargetResolver) GetFileByPath(path string) (api.FileStat, ...
method GetFileStat (line 23) | func (f fakeTargetResolver) GetFileStat(parentId string, name string) ...
method GetPathFolderId (line 27) | func (f fakeTargetResolver) GetPathFolderId(dirPath string) (string, e...
function TestRemoteTargetPathJoinsBasePath (line 31) | func TestRemoteTargetPathJoinsBasePath(t *testing.T) {
function TestResolveDownloadTargetUsesParentIDForDirectChild (line 42) | func TestResolveDownloadTargetUsesParentIDForDirectChild(t *testing.T) {
function TestResolveDownloadTargetJoinsBasePathForNestedArg (line 72) | func TestResolveDownloadTargetJoinsBasePathForNestedArg(t *testing.T) {
function TestResolveDownloadTargetWithoutArgsUsesBaseFolder (line 101) | func TestResolveDownloadTargetWithoutArgsUsesBaseFolder(t *testing.T) {
function TestRequiresExplicitOutputFlag (line 131) | func TestRequiresExplicitOutputFlag(t *testing.T) {
FILE: cli/download/progress_test.go
function TestTrimRunes (line 11) | func TestTrimRunes(t *testing.T) {
function TestProgressDisplayNameIncludesParentDir (line 16) | func TestProgressDisplayNameIncludesParentDir(t *testing.T) {
FILE: cli/empty/empty.go
type emptyFolderProvider (line 20) | type emptyFolderProvider interface
function init (line 67) | func init() {
function handleEmptyFolders (line 73) | func handleEmptyFolders(ctx context.Context, p emptyFolderProvider, root...
type emptyWalkState (line 98) | type emptyWalkState struct
type emptyFolderResult (line 103) | type emptyFolderResult struct
function walkEmptyFolders (line 108) | func walkEmptyFolders(ctx context.Context, p emptyFolderProvider, folder...
FILE: cli/empty/empty_test.go
type fakeEmptyFolderProvider (line 16) | type fakeEmptyFolderProvider struct
method GetPathFolderId (line 24) | func (f *fakeEmptyFolderProvider) GetPathFolderId(dirPath string) (str...
method GetFolderFileStatList (line 31) | func (f *fakeEmptyFolderProvider) GetFolderFileStatList(parentId strin...
method DeleteFile (line 40) | func (f *fakeEmptyFolderProvider) DeleteFile(fileId string) error {
function TestHandleEmptyFoldersDeletesNestedEmptyFolders (line 57) | func TestHandleEmptyFoldersDeletesNestedEmptyFolders(t *testing.T) {
function TestHandleEmptyFoldersSkipsNonEmptyRootTarget (line 82) | func TestHandleEmptyFoldersSkipsNonEmptyRootTarget(t *testing.T) {
function TestHandleEmptyFoldersDeletesTargetWhenItBecomesEmpty (line 100) | func TestHandleEmptyFoldersDeletesTargetWhenItBecomesEmpty(t *testing.T) {
function TestHandleEmptyFoldersNormalizesInvalidConcurrency (line 119) | func TestHandleEmptyFoldersNormalizesInvalidConcurrency(t *testing.T) {
function TestHandleEmptyFoldersListsWithoutDeleting (line 135) | func TestHandleEmptyFoldersListsWithoutDeleting(t *testing.T) {
type blockingEmptyFolderProvider (line 154) | type blockingEmptyFolderProvider struct
method GetFolderFileStatList (line 159) | func (f *blockingEmptyFolderProvider) GetFolderFileStatList(parentId s...
function TestHandleEmptyFoldersHonorsCanceledContext (line 166) | func TestHandleEmptyFoldersHonorsCanceledContext(t *testing.T) {
function TestHandleEmptyFoldersStopsWaitingAfterCancel (line 184) | func TestHandleEmptyFoldersStopsWaitingAfterCancel(t *testing.T) {
FILE: cli/list/list.go
function init (line 38) | func init() {
function handle (line 45) | func handle(p *api.PikPak, args []string, long, human bool, path, parent...
function display (line 75) | func display(mode int, file *api.FileStat) {
FILE: cli/new/folder/folder.go
function init (line 36) | func init() {
function handleNewFolder (line 42) | func handleNewFolder(p *api.PikPak, folders []string) {
FILE: cli/new/new.go
function init (line 19) | func init() {
FILE: cli/new/sha/sha.go
function init (line 63) | func init() {
function handleNewSha (line 70) | func handleNewSha(p *api.PikPak, shas []string) {
FILE: cli/new/url/url.go
function init (line 69) | func init() {
function handleNewUrl (line 77) | func handleNewUrl(p *api.PikPak, shas []string) {
function handleCli (line 98) | func handleCli(p *api.PikPak) {
FILE: cli/quota/quota.go
function init (line 52) | func init() {
function displayCloudDownload (line 56) | func displayCloudDownload(cloudDownload api.Quota) {
function displayMonthlyTransferQuota (line 67) | func displayMonthlyTransferQuota(base api.TransferQuotaBase) {
function displayTransferRow (line 75) | func displayTransferRow(name string, quota api.TransferQuota) {
function formatTransferValue (line 85) | func formatTransferValue(size int64) string {
function formatQuotaValue (line 89) | func formatQuotaValue(size string) string {
FILE: cli/quota/quota_test.go
function TestFormatTransferValue (line 9) | func TestFormatTransferValue(t *testing.T) {
FILE: cli/root.go
function init (line 47) | func init() {
function Execute (line 65) | func Execute() {
FILE: cli/rubbish/rubbish.go
constant defaultRulesRelativePath (line 33) | defaultRulesRelativePath = "rules/rubbish_rules.txt"
constant defaultRulesDownloadURL (line 34) | defaultRulesDownloadURL = "https://raw.githubusercontent.com/52funny/pi...
type rubbishProvider (line 37) | type rubbishProvider interface
type compiledRules (line 43) | type compiledRules struct
method Match (line 421) | func (r compiledRules) Match(path string) (string, bool) {
type rubbishMatch (line 48) | type rubbishMatch struct
type rubbishWalkState (line 53) | type rubbishWalkState struct
type rubbishFolderResult (line 58) | type rubbishFolderResult struct
function init (line 159) | func init() {
function loadRules (line 169) | func loadRules(path string) (compiledRules, error) {
function resolveRulesPath (line 208) | func resolveRulesPath(raw string) (string, error) {
function defaultRulesPath (line 235) | func defaultRulesPath() (string, error) {
function ensureDefaultRulesFile (line 243) | func ensureDefaultRulesFile(path string) error {
function downloadDefaultRules (line 255) | func downloadDefaultRules(targetPath string, sourceURL string) error {
function isRemoteRulesSource (line 280) | func isRemoteRulesSource(path string) bool {
function openLocalPath (line 284) | func openLocalPath(path string) error {
function buildLocalOpenCommand (line 292) | func buildLocalOpenCommand(goos string, path string) (string, []string, ...
function handleRubbish (line 305) | func handleRubbish(ctx context.Context, p rubbishProvider, rootPath stri...
function walkRubbish (line 330) | func walkRubbish(ctx context.Context, p rubbishProvider, folderID, curre...
function patternMatches (line 441) | func patternMatches(pattern, fullPath, name string) bool {
function hasWildcard (line 463) | func hasWildcard(pattern string) bool {
FILE: cli/rubbish/rubbish_test.go
type fakeRubbishProvider (line 18) | type fakeRubbishProvider struct
method GetPathFolderId (line 25) | func (f *fakeRubbishProvider) GetPathFolderId(dirPath string) (string,...
method GetFolderFileStatList (line 32) | func (f *fakeRubbishProvider) GetFolderFileStatList(parentId string) (...
method DeleteFile (line 41) | func (f *fakeRubbishProvider) DeleteFile(fileId string) error {
function TestLoadRules (line 58) | func TestLoadRules(t *testing.T) {
function TestCompiledRulesMatch (line 70) | func TestCompiledRulesMatch(t *testing.T) {
function TestHandleRubbishListsAndDeletesMatches (line 99) | func TestHandleRubbishListsAndDeletesMatches(t *testing.T) {
function TestHandleRubbishNormalizesConcurrency (line 139) | func TestHandleRubbishNormalizesConcurrency(t *testing.T) {
function TestDefaultRulesPathUsesConfigDir (line 156) | func TestDefaultRulesPathUsesConfigDir(t *testing.T) {
function TestResolveRulesPathForDirectory (line 164) | func TestResolveRulesPathForDirectory(t *testing.T) {
function TestDownloadDefaultRules (line 172) | func TestDownloadDefaultRules(t *testing.T) {
function TestBuildLocalOpenCommand (line 188) | func TestBuildLocalOpenCommand(t *testing.T) {
FILE: cli/share/share.go
function init (line 65) | func init() {
function shareFolder (line 72) | func shareFolder(p *api.PikPak, f *os.File) {
function shareFiles (line 97) | func shareFiles(p *api.PikPak, args []string, f *os.File) {
function resolveShareTarget (line 118) | func resolveShareTarget(p *api.PikPak, resolvedParentID string, target s...
FILE: cli/upload/upload.go
function init (line 83) | func init() {
function disposeExclude (line 100) | func disposeExclude() {
function handleUploadFile (line 106) | func handleUploadFile(p *api.PikPak, path string) {
function handleUploadFolder (line 126) | func handleUploadFolder(p *api.PikPak, path string) {
FILE: conf/config.go
type ConfigType (line 15) | type ConfigType struct
method UseProxy (line 35) | func (c *ConfigType) UseProxy() bool {
type OpenConfig (line 22) | type OpenConfig struct
function InitConfig (line 40) | func InitConfig(path string) error {
function readFromBinary (line 75) | func readFromBinary() error {
function readFromPath (line 130) | func readFromPath(path string) error {
function readFromConfigDir (line 135) | func readFromConfigDir() error {
function readConfig (line 143) | func readConfig(path string) error {
FILE: internal/api/captcha_token.go
constant package_name (line 13) | package_name = `com.pikcloud.pikpak`
constant client_version (line 14) | client_version = `1.21.0`
constant md5_obj (line 15) | md5_obj = `[{"alg":"md5","salt":""},{"alg":"md5","salt":"E32cSkYXC2bciKJ...
type md5Obj (line 17) | type md5Obj struct
function init (line 24) | func init() {
method AuthCaptchaToken (line 31) | func (p *PikPak) AuthCaptchaToken(action string) error {
FILE: internal/api/constants.go
constant FileKindFolder (line 4) | FileKindFolder = "drive#folder"
constant FileKindFile (line 5) | FileKindFile = "drive#file"
FILE: internal/api/download.go
constant maxDownloadRetries (line 17) | maxDownloadRetries = 3
type retryableDownloadError (line 21) | type retryableDownloadError struct
method Error (line 32) | func (e *retryableDownloadError) Error() string {
method Unwrap (line 36) | func (e *retryableDownloadError) Unwrap() error {
method requestContext (line 25) | func (f *File) requestContext() context.Context {
function retryableDownload (line 40) | func retryableDownload(err error) error {
function isRetryableDownloadError (line 47) | func isRetryableDownloadError(err error) bool {
method Download (line 53) | func (f *File) Download(path string, bar *mpb.Bar) error {
method download (line 77) | func (f *File) download(path string, bar *mpb.Bar, expectedSize int64) e...
FILE: internal/api/download_test.go
function TestDownloadResumesAfterInterruptedTransfer (line 17) | func TestDownloadResumesAfterInterruptedTransfer(t *testing.T) {
function TestDownloadRestartsWhenServerIgnoresRangeRequest (line 67) | func TestDownloadRestartsWhenServerIgnoresRangeRequest(t *testing.T) {
function TestDownloadTreatsSatisfiedRangeAsSuccess (line 107) | func TestDownloadTreatsSatisfiedRangeAsSuccess(t *testing.T) {
FILE: internal/api/file.go
type FileStat (line 21) | type FileStat struct
type File (line 38) | type File struct
type fileListResult (line 91) | type fileListResult struct
constant maxListRetries (line 96) | maxListRetries = 3
method GetFolderFileStatList (line 98) | func (p *PikPak) GetFolderFileStatList(parentId string) ([]FileStat, err...
method getFolderFileStatPage (line 134) | func (p *PikPak) getFolderFileStatPage(query url.Values) ([]byte, error) {
function isRetryableListError (line 157) | func isRetryableListError(err error) bool {
method GetFileStat (line 178) | func (p *PikPak) GetFileStat(parentId string, name string) (FileStat, er...
method GetFileByPath (line 191) | func (p *PikPak) GetFileByPath(path string) (FileStat, error) {
method GetFile (line 209) | func (p *PikPak) GetFile(fileId string) (File, error) {
method DeleteFile (line 243) | func (p *PikPak) DeleteFile(fileId string) error {
method Rename (line 269) | func (p *PikPak) Rename(fileId string, newName string) error {
FILE: internal/api/file_test.go
type fakeNetError (line 13) | type fakeNetError struct
method Error (line 15) | func (fakeNetError) Error() string { return "i/o timeout" }
method Timeout (line 16) | func (fakeNetError) Timeout() bool { return true }
method Temporary (line 17) | func (fakeNetError) Temporary() bool { return true }
function TestIsRetryableListError (line 19) | func TestIsRetryableListError(t *testing.T) {
function TestFakeNetErrorImplementsNetError (line 28) | func TestFakeNetErrorImplementsNetError(t *testing.T) {
function TestPikPakWithContext (line 34) | func TestPikPakWithContext(t *testing.T) {
FILE: internal/api/folder.go
method GetDeepFolderId (line 17) | func (p *PikPak) GetDeepFolderId(parentId string, dirPath string) (strin...
method GetPathFolderId (line 35) | func (p *PikPak) GetPathFolderId(dirPath string) (string, error) {
method GetFolderId (line 41) | func (p *PikPak) GetFolderId(parentId string, dir string) (string, error) {
method GetDeepFolderOrCreateId (line 85) | func (p *PikPak) GetDeepFolderOrCreateId(parentId string, dirPath string...
method CreateFolder (line 117) | func (p *PikPak) CreateFolder(parentId, dir string) (string, error) {
FILE: internal/api/glob.go
type remotePatternProvider (line 9) | type remotePatternProvider interface
function ExpandRemotePatterns (line 14) | func ExpandRemotePatterns(p remotePatternProvider, basePath string, patt...
function expandRemotePattern (line 26) | func expandRemotePattern(p remotePatternProvider, basePath string, patte...
function hasRemoteWildcard (line 81) | func hasRemoteWildcard(value string) bool {
function relativeRemotePath (line 85) | func relativeRemotePath(basePath string, fullPath string) string {
FILE: internal/api/glob_test.go
type fakeRemotePatternProvider (line 10) | type fakeRemotePatternProvider struct
method GetPathFolderId (line 15) | func (f fakeRemotePatternProvider) GetPathFolderId(dirPath string) (st...
method GetFolderFileStatList (line 19) | func (f fakeRemotePatternProvider) GetFolderFileStatList(parentId stri...
function TestExpandRemotePatternsReturnsAbsoluteMatches (line 23) | func TestExpandRemotePatternsReturnsAbsoluteMatches(t *testing.T) {
function TestExpandRemotePatternsCanKeepRelativeMatches (line 44) | func TestExpandRemotePatternsCanKeepRelativeMatches(t *testing.T) {
function TestExpandRemotePatternsReturnsNoMatchError (line 64) | func TestExpandRemotePatternsReturnsNoMatchError(t *testing.T) {
function TestExpandRemotePatternsPropagatesLookupErrors (line 78) | func TestExpandRemotePatternsPropagatesLookupErrors(t *testing.T) {
FILE: internal/api/pikpak.go
constant userAgent (line 18) | userAgent = `ANDROID-com.pikcloud.pikpak/1.21.0`
constant clientID (line 19) | clientID = `YNxT9w7GMdWvEOKa`
constant clientSecret (line 20) | clientSecret = `dbw2OtmVEeuUvIptb1Coyg`
type PikPak (line 22) | type PikPak struct
method requestContext (line 71) | func (p *PikPak) requestContext() context.Context {
method WithContext (line 78) | func (p *PikPak) WithContext(ctx context.Context) *PikPak {
method newRequest (line 90) | func (p *PikPak) newRequest(method, url string, body io.Reader) (*http...
method login (line 95) | func (p *PikPak) login() error {
method getCaptchaToken (line 135) | func (p *PikPak) getCaptchaToken() (string, error) {
method sendRequest (line 163) | func (p *PikPak) sendRequest(req *http.Request) ([]byte, error) {
method setHeader (line 177) | func (p *PikPak) setHeader(req *http.Request) {
method Login (line 186) | func (p *PikPak) Login() error {
function NewPikPak (line 35) | func NewPikPak(account, password string) PikPak {
function NewPikPakWithContext (line 39) | func NewPikPakWithContext(ctx context.Context, account, password string)...
FILE: internal/api/quota.go
type QuotaMessage (line 9) | type QuotaMessage struct
type Quota (line 15) | type Quota struct
method Remaining (line 25) | func (q Quota) Remaining() (int64, error) {
type Quotas (line 37) | type Quotas struct
type TransferMessage (line 41) | type TransferMessage struct
type TransferQuotaCollection (line 46) | type TransferQuotaCollection struct
type TransferQuotaBase (line 52) | type TransferQuotaBase struct
type TransferQuota (line 58) | type TransferQuota struct
method Remaining (line 65) | func (q TransferQuota) Remaining() int64 {
method GetQuota (line 70) | func (p *PikPak) GetQuota() (QuotaMessage, error) {
method GetTransferQuota (line 88) | func (p *PikPak) GetTransferQuota() (TransferMessage, error) {
FILE: internal/api/quota_test.go
function TestQuotaRemaining (line 10) | func TestQuotaRemaining(t *testing.T) {
function TestQuotaRemainingInvalid (line 16) | func TestQuotaRemainingInvalid(t *testing.T) {
function TestTransferQuotaRemaining (line 21) | func TestTransferQuotaRemaining(t *testing.T) {
FILE: internal/api/refresh_token.go
method RefreshToken (line 12) | func (p *PikPak) RefreshToken() error {
FILE: internal/api/session.go
constant sessionExpirySkew (line 16) | sessionExpirySkew = 5 * 60
type sessionData (line 19) | type sessionData struct
method saveSession (line 28) | func (p *PikPak) saveSession() error {
method loadSession (line 56) | func (p *PikPak) loadSession() error {
method isTokenExpired (line 79) | func (p *PikPak) isTokenExpired() bool {
method saveSessionBestEffort (line 83) | func (p *PikPak) saveSessionBestEffort() {
function sessionFile (line 89) | func sessionFile(account string) (string, error) {
FILE: internal/api/sha.go
method CreateShaFile (line 10) | func (p *PikPak) CreateShaFile(parentId, fileName, size, sha string) err...
FILE: internal/api/upload.go
type OssArgs (line 29) | type OssArgs struct
method UploadFile (line 44) | func (p *PikPak) UploadFile(parentId, path string) error {
function uploadChunk (line 183) | func uploadChunk(ctx context.Context, wait *sync.WaitGroup, ch chan Part...
type header (line 230) | type header struct
type CompleteMultipartUpload (line 235) | type CompleteMultipartUpload struct
type Part (line 238) | type Part struct
function hmacAuthorization (line 243) | func hmacAuthorization(req *http.Request, body []byte, time time.Time, o...
method beforeUpload (line 282) | func (p *PikPak) beforeUpload(ossArgs OssArgs) string {
method afterUpload (line 322) | func (p *PikPak) afterUpload(args *CompleteMultipartUpload, ossArgs OssA...
FILE: internal/api/url.go
method CreateUrlFile (line 11) | func (p *PikPak) CreateUrlFile(parentId, url string) error {
FILE: internal/logx/logx.go
function Init (line 16) | func Init(debug bool, topics []string) {
function Enabled (line 30) | func Enabled(topic string) bool {
function Debug (line 44) | func Debug(topic string, args ...any) {
function Debugln (line 50) | func Debugln(topic string, args ...any) {
function Warn (line 56) | func Warn(topic string, args ...any) {
function Warnf (line 62) | func Warnf(topic, format string, args ...any) {
function Error (line 68) | func Error(args ...any) {
function Errorf (line 72) | func Errorf(format string, args ...any) {
function parseTopics (line 76) | func parseTopics(topics []string) map[string]struct{} {
function envEnabled (line 101) | func envEnabled(key string) bool {
FILE: internal/shell/open.go
constant openCategoryDefault (line 19) | openCategoryDefault = "default"
constant openCategoryText (line 20) | openCategoryText = "text"
constant openCategoryImage (line 21) | openCategoryImage = "image"
constant openCategoryVideo (line 22) | openCategoryVideo = "video"
constant openCategoryAudio (line 23) | openCategoryAudio = "audio"
constant openCategoryPDF (line 24) | openCategoryPDF = "pdf"
type openFileService (line 27) | type openFileService interface
function handleOpenCommand (line 32) | func handleOpenCommand(p openFileService, currentPath string, args []str...
function resolveOpenTarget (line 67) | func resolveOpenTarget(file *api.File) (string, error) {
function cacheOpenFile (line 77) | func cacheOpenFile(file *api.File) (string, error) {
function openCacheRoot (line 104) | func openCacheRoot() (string, error) {
function localFileMatchesRemoteSize (line 125) | func localFileMatchesRemoteSize(path string, remoteSize string) (bool, e...
function classifyOpenCategory (line 142) | func classifyOpenCategory(name string) string {
function remoteVideoOpenURL (line 160) | func remoteVideoOpenURL(file *api.File) string {
function openWithLocalApp (line 185) | func openWithLocalApp(target string, category string) error {
function buildOpenCommand (line 195) | func buildOpenCommand(goos string, cfg conf.OpenConfig, path string, cat...
function commandForCategory (line 221) | func commandForCategory(cfg conf.OpenConfig, category string) []string {
function defaultOpenCommand (line 251) | func defaultOpenCommand(goos string, category string) []string {
FILE: internal/shell/shell.go
constant clearScreenSequence (line 25) | clearScreenSequence = "\033[H\033[2J"
type fileStatProvider (line 27) | type fileStatProvider interface
type shellAutoCompleter (line 32) | type shellAutoCompleter struct
method Do (line 172) | func (c *shellAutoCompleter) Do(line []rune, pos int) ([][]rune, int) {
method completeRemotePath (line 340) | func (c *shellAutoCompleter) completeRemotePath(prefix string, onlyDir...
function Start (line 39) | func Start(rootCmd *cobra.Command) {
function shouldExitOnReadlineError (line 157) | func shouldExitOnReadlineError(err error) bool {
function isReadlineInterrupt (line 161) | func isReadlineInterrupt(err error) bool {
function setCommandContextTree (line 165) | func setCommandContextTree(cmd *cobra.Command, ctx context.Context) {
function shouldCompleteLocalPathFlagValue (line 222) | func shouldCompleteLocalPathFlagValue(commandKey string, tokens []string...
function shouldCompleteDirectoryPath (line 235) | func shouldCompleteDirectoryPath(commandKey string, tokens []string, act...
function shouldCompleteRemoteTargetPath (line 257) | func shouldCompleteRemoteTargetPath(commandKey string, tokens []string, ...
function shouldCompleteLocalTargetPath (line 273) | func shouldCompleteLocalTargetPath(commandKey string, tokens []string, a...
function wantsFlagValue (line 287) | func wantsFlagValue(tokens []string, active string, endedWithSpace bool,...
function positionalTokens (line 304) | func positionalTokens(tokens []string, active string) []string {
function completeLocalPath (line 392) | func completeLocalPath(prefix string, onlyDirs bool) ([][]rune, int) {
function promptForPath (line 437) | func promptForPath(currentPath string) string {
function clearScreen (line 444) | func clearScreen(w io.Writer) {
function adaptShellArgs (line 448) | func adaptShellArgs(rootCmd *cobra.Command, currentPath string, args []s...
function canonicalCommandKey (line 494) | func canonicalCommandKey(rootCmd *cobra.Command, cmd *cobra.Command) str...
type shellArgFlags (line 506) | type shellArgFlags struct
function inspectShellArgs (line 512) | func inspectShellArgs(args []string) shellArgFlags {
function rewritePathFlagValues (line 550) | func rewritePathFlagValues(args []string, currentPath string) []string {
function rewritePositionalPaths (line 568) | func rewritePositionalPaths(args []string, currentPath string, limit int...
function changeDirectory (line 603) | func changeDirectory(p *api.PikPak, currentPath string, args []string) (...
function resolveShellPath (line 621) | func resolveShellPath(currentPath string, target string) string {
function expandOpenGlobs (line 634) | func expandOpenGlobs(currentPath string, source fileStatProvider, args [...
function expandShellGlobs (line 646) | func expandShellGlobs(rootCmd *cobra.Command, currentPath string, source...
function expandDownloadGlobs (line 681) | func expandDownloadGlobs(args []string, currentPath string, source fileS...
function expandDeleteGlobs (line 685) | func expandDeleteGlobs(args []string, currentPath string, source fileSta...
function expandUploadGlobs (line 742) | func expandUploadGlobs(args []string) ([]string, error) {
function rewriteDownloadLikeArgs (line 792) | func rewriteDownloadLikeArgs(args []string, currentPath string, source f...
function expandDeletePatternToken (line 863) | func expandDeletePatternToken(token string, pathValue string, currentPat...
function expandRemotePatternToken (line 873) | func expandRemotePatternToken(token string, pathValue string, currentPat...
function matchRemotePattern (line 914) | func matchRemotePattern(source fileStatProvider, parentPath string, patt...
function expandLocalPatternToken (line 942) | func expandLocalPatternToken(token string) ([]string, error) {
function consumesNextValue (line 958) | func consumesNextValue(token string) bool {
function hasWildcard (line 972) | func hasWildcard(value string) bool {
function relativeRemotePath (line 976) | func relativeRemotePath(basePath string, fullPath string) string {
function splitCompletionLine (line 989) | func splitCompletionLine(input string) ([]string, string, bool) {
function commandCandidates (line 1053) | func commandCandidates(rootCmd *cobra.Command) []string {
function subcommandCandidates (line 1060) | func subcommandCandidates(cmd *cobra.Command) []string {
function flagCandidates (line 1073) | func flagCandidates(cmd *cobra.Command) []string {
function resolveCommand (line 1091) | func resolveCommand(rootCmd *cobra.Command, tokens []string) (*cobra.Com...
function completeFromPrefix (line 1116) | func completeFromPrefix(prefix string, candidates []string, appendSpace ...
function toRuneCandidates (line 1131) | func toRuneCandidates(candidates []string) [][]rune {
function parseShellArgs (line 1140) | func parseShellArgs(input string) []string {
function escapeShellCompletion (line 1193) | func escapeShellCompletion(value string) string {
function resetFlags (line 1207) | func resetFlags(cmd *cobra.Command) {
FILE: internal/shell/shell_test.go
function TestParseShellArgs (line 20) | func TestParseShellArgs(t *testing.T) {
function TestResolveShellPath (line 55) | func TestResolveShellPath(t *testing.T) {
function TestSplitCompletionLine (line 107) | func TestSplitCompletionLine(t *testing.T) {
function TestShouldExitOnReadlineError (line 152) | func TestShouldExitOnReadlineError(t *testing.T) {
function TestIsReadlineInterrupt (line 159) | func TestIsReadlineInterrupt(t *testing.T) {
function TestSetCommandContextTree (line 165) | func TestSetCommandContextTree(t *testing.T) {
function TestCompleterCommandsAndFlags (line 184) | func TestCompleterCommandsAndFlags(t *testing.T) {
function TestCompleterUploadLocalPath (line 268) | func TestCompleterUploadLocalPath(t *testing.T) {
function TestCompleterUploadHomePath (line 302) | func TestCompleterUploadHomePath(t *testing.T) {
function TestCompleterRubbishRulesLocalThenRemotePath (line 345) | func TestCompleterRubbishRulesLocalThenRemotePath(t *testing.T) {
function TestClearScreen (line 386) | func TestClearScreen(t *testing.T) {
function TestAdaptShellArgs (line 392) | func TestAdaptShellArgs(t *testing.T) {
function TestExpandShellGlobsDownload (line 474) | func TestExpandShellGlobsDownload(t *testing.T) {
function TestExpandShellGlobsDelete (line 498) | func TestExpandShellGlobsDelete(t *testing.T) {
function TestExpandShellGlobsUpload (line 521) | func TestExpandShellGlobsUpload(t *testing.T) {
function TestExpandOpenGlobs (line 545) | func TestExpandOpenGlobs(t *testing.T) {
function TestCompleterCDPath (line 562) | func TestCompleterCDPath(t *testing.T) {
function TestCompleterCDPathFromCurrentDirectory (line 589) | func TestCompleterCDPathFromCurrentDirectory(t *testing.T) {
function TestCompleterEscapesSpacesInPath (line 613) | func TestCompleterEscapesSpacesInPath(t *testing.T) {
type fakeFileStatProvider (line 633) | type fakeFileStatProvider struct
method GetPathFolderId (line 638) | func (f fakeFileStatProvider) GetPathFolderId(dirPath string) (string,...
method GetFolderFileStatList (line 645) | func (f fakeFileStatProvider) GetFolderFileStatList(parentId string) (...
function TestClassifyOpenCategory (line 649) | func TestClassifyOpenCategory(t *testing.T) {
function TestBuildOpenCommand (line 658) | func TestBuildOpenCommand(t *testing.T) {
function TestRemoteVideoOpenURL (line 684) | func TestRemoteVideoOpenURL(t *testing.T) {
function TestResolveOpenTargetForVideoPrefersRemoteURL (line 728) | func TestResolveOpenTargetForVideoPrefersRemoteURL(t *testing.T) {
FILE: internal/utils/format.go
function FormatStorage (line 7) | func FormatStorage(sizeText string, human bool) string {
FILE: internal/utils/format_test.go
function TestFormatStorage (line 9) | func TestFormatStorage(t *testing.T) {
FILE: internal/utils/path.go
function ExpandLocalPath (line 11) | func ExpandLocalPath(path string) string {
function SplitSeparator (line 37) | func SplitSeparator(path string) []string {
function Slash (line 44) | func Slash(path string) string {
function SplitRemotePath (line 56) | func SplitRemotePath(path string) (dir string, name string) {
function GetUploadFilePath (line 79) | func GetUploadFilePath(basePath string, defaultRegexp []*regexp.Regexp) ...
function Exists (line 121) | func Exists(path string) (bool, error) {
function CreateDirIfNotExist (line 133) | func CreateDirIfNotExist(path string) error {
function TouchFile (line 148) | func TouchFile(path string) error {
FILE: internal/utils/path_test.go
function TestSplitRemotePath (line 11) | func TestSplitRemotePath(t *testing.T) {
function TestExpandLocalPath (line 55) | func TestExpandLocalPath(t *testing.T) {
FILE: internal/utils/sync.go
type SyncTxt (line 16) | type SyncTxt struct
method Write (line 51) | func (s *SyncTxt) Write(b []byte) (n int, err error) {
method Close (line 64) | func (s *SyncTxt) Close() error {
method WriteString (line 72) | func (s *SyncTxt) WriteString(str string) (n int, err error) {
method UnSync (line 84) | func (s *SyncTxt) UnSync(files []string) []string {
function NewSyncTxt (line 23) | func NewSyncTxt(fileName string, enable bool) (sync *SyncTxt, err error) {
FILE: main.go
function main (line 5) | func main() {
Condensed preview — 66 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (259K chars).
[
{
"path": ".github/workflows/dockerhub.yml",
"chars": 909,
"preview": "name: Publish Docker image\non:\n push:\n tags:\n - \"v*\"\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n "
},
{
"path": ".github/workflows/goreleaser.yml",
"chars": 512,
"preview": "name: goreleaser\n\non:\n push:\n tags:\n - \"v*\"\n\npermissions:\n contents: write\n\njobs:\n goreleaser:\n runs-on: u"
},
{
"path": ".gitignore",
"chars": 57,
"preview": "\n.vscode\n.pikpaksync.txt\nconfig.yml\npikpakcli\ndist\ndist/\n"
},
{
"path": ".goreleaser.yaml",
"chars": 1217,
"preview": "# This is an example .goreleaser.yml file with some sensible defaults.\n# Make sure to check the documentation at https:/"
},
{
"path": "Dockerfile",
"chars": 421,
"preview": "FROM golang:1.21-alpine AS builder\n\nRUN apk add --no-cache git\nWORKDIR /src\n\nCOPY go.mod go.sum ./\nRUN go mod download\n\n"
},
{
"path": "LICENSE",
"chars": 1064,
"preview": "MIT License\n\nCopyright (c) 2024 52funny\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof"
},
{
"path": "README.md",
"chars": 2624,
"preview": "# PikPak CLI\n\n\n\n\n\nfunc TestFormatTransferValue(t *testing.T) "
},
{
"path": "cli/rename/rename.go",
"chars": 1729,
"preview": "package rename\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/p"
},
{
"path": "cli/root.go",
"chars": 1939,
"preview": "package cli\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\tdel \"github.com/52funny/pikpakcli/cli/del\"\n\t\"github.com/52funny/pikpakcli/cli/downl"
},
{
"path": "cli/rubbish/rubbish.go",
"chars": 12130,
"preview": "package rubbish\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"run"
},
{
"path": "cli/rubbish/rubbish_test.go",
"chars": 5531,
"preview": "package rubbish\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sync\"\n\t\"testing"
},
{
"path": "cli/share/share.go",
"chars": 2927,
"preview": "package share\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/52funny/pikpakcli/conf\"\n\t\"github.com/52funny/pikpakcli/int"
},
{
"path": "cli/shell.go",
"chars": 276,
"preview": "package cli\n\nimport (\n\tishell \"github.com/52funny/pikpakcli/internal/shell\"\n\t\"github.com/spf13/cobra\"\n)\n\nvar shellCmd = "
},
{
"path": "cli/upload/upload.go",
"chars": 4870,
"preview": "package upload\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/conf"
},
{
"path": "conf/config.go",
"chars": 3266,
"preview": "package conf\n\nimport (\n\t\"bytes\"\n\t\"encoding/binary\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"gopkg.in/yaml.v2\"\n)"
},
{
"path": "config_example.yml",
"chars": 669,
"preview": "# Proxy URL, for example: http://127.0.0.1:7890\nproxy:\n# PikPak account username or phone number with country code\nusern"
},
{
"path": "docs/command.md",
"chars": 4605,
"preview": "# Command Usage\n\n> For docker users, please refer to the [Docker Command Usage](docs/command_docker.md).\n\n## Upload\n\n- U"
},
{
"path": "docs/command_docker.md",
"chars": 2700,
"preview": "# Docker Command Usage\n\nFor docker users, the most different part is linking the configuration file (i.e., `config.yml`)"
},
{
"path": "docs/command_zhCN.md",
"chars": 3165,
"preview": "# 命令使用方法\n\n## 上传\n\n- 将本地目录下的所有文件上传至 Movies 文件夹内\n\n ```bash\n pikpakcli upload -p Movies .\n ```\n\n- 将本地目录下除了后缀名为`mp3`, `jpg"
},
{
"path": "docs/config.md",
"chars": 2074,
"preview": "## Configuration\n\nThe CLI reads the following fields from `config.yml`:\n\n```yml\nproxy:\nusername: xxx\npassword: xxx\nopen:"
},
{
"path": "docs/config_zhCN.md",
"chars": 1305,
"preview": "## 配置说明\n\nCLI 会从 `config.yml` 中读取以下字段:\n\n```yml\nproxy:\nusername: xxx\npassword: xxx\nopen:\n download_dir:\n default: []\n t"
},
{
"path": "go.mod",
"chars": 1206,
"preview": "module github.com/52funny/pikpakcli\n\ngo 1.21.3\n\nrequire (\n\tgithub.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56ef"
},
{
"path": "go.sum",
"chars": 5902,
"preview": "github.com/52funny/pikpakhash v0.0.0-20231104025731-ef91a56eff9c h1:ecJG8tmvgH6exVE4+I3rFPPA1Mk3/lNb8VZ6A7dtcyI=\ngithub."
},
{
"path": "internal/api/captcha_token.go",
"chars": 2317,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"crypto/md5\"\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"gi"
},
{
"path": "internal/api/constants.go",
"chars": 87,
"preview": "package api\n\nconst (\n\tFileKindFolder = \"drive#folder\"\n\tFileKindFile = \"drive#file\"\n)\n"
},
{
"path": "internal/api/download.go",
"chars": 4713,
"preview": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\n\t\"github.com/52funny/pikpak"
},
{
"path": "internal/api/download_test.go",
"chars": 3634,
"preview": "package api\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"sync/atomic\"\n"
},
{
"path": "internal/api/file.go",
"chars": 8477,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n\t"
},
{
"path": "internal/api/file_test.go",
"chars": 1242,
"preview": "package api\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\ntype fakeNe"
},
{
"path": "internal/api/folder.go",
"chars": 3981,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\t\"github.com/52funny/pik"
},
{
"path": "internal/api/glob.go",
"chars": 2301,
"preview": "package api\n\nimport (\n\t\"fmt\"\n\t\"path\"\n\t\"strings\"\n)\n\ntype remotePatternProvider interface {\n\tGetPathFolderId(dirPath strin"
},
{
"path": "internal/api/glob_test.go",
"chars": 2701,
"preview": "package api\n\nimport (\n\t\"errors\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\ntype fakeRemotePatternProvider str"
},
{
"path": "internal/api/pikpak.go",
"chars": 5167,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.co"
},
{
"path": "internal/api/quota.go",
"chars": 2576,
"preview": "package api\n\nimport (\n\t\"strconv\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\ntype QuotaMessage struct {\n\tKind strin"
},
{
"path": "internal/api/quota_test.go",
"chars": 572,
"preview": "package api\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc T"
},
{
"path": "internal/api/refresh_token.go",
"chars": 1241,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"github.com/json-iterator"
},
{
"path": "internal/api/session.go",
"chars": 2706,
"preview": "package api\n\nimport (\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com"
},
{
"path": "internal/api/sha.go",
"chars": 1684,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\n\tjsoniter \"github.com/json-iterator/go\"\n)\n\nfunc (p *PikPak) CreateShaFile(parentI"
},
{
"path": "internal/api/upload.go",
"chars": 9268,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/hmac\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/xml\"\n\t\"errors\"\n\t\"f"
},
{
"path": "internal/api/url.go",
"chars": 1581,
"preview": "package api\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\n\t\"github.com/52funny/pikpakcli/internal/logx\"\n\tjsoniter \"github.com/json-iterator"
},
{
"path": "internal/logx/logx.go",
"chars": 2168,
"preview": "package logx\n\nimport (\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"strings\"\n)\n\nvar enabledTopics = map[string]struct{}{}\nvar debugEnabled"
},
{
"path": "internal/shell/open.go",
"chars": 6760,
"preview": "package shell\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.co"
},
{
"path": "internal/shell/shell.go",
"chars": 29710,
"preview": "package shell\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"gith"
},
{
"path": "internal/shell/shell_test.go",
"chars": 24289,
"preview": "package shell\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/52"
},
{
"path": "internal/utils/format.go",
"chars": 542,
"preview": "package utils\n\nimport \"strconv\"\n\nvar storageUnits = [...]string{\"B\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\"}\n\nfunc FormatStorage(s"
},
{
"path": "internal/utils/format_test.go",
"chars": 387,
"preview": "package utils\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestFormatStorage(t *testing.T) {\n\tass"
},
{
"path": "internal/utils/path.go",
"chars": 2711,
"preview": "package utils\n\nimport (\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n)\n\nfunc ExpandLocalPath(path string) string"
},
{
"path": "internal/utils/path_test.go",
"chars": 1417,
"preview": "package utils\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestSplitRemot"
},
{
"path": "internal/utils/sync.go",
"chars": 1920,
"preview": "package utils\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"unsafe\"\n\n\t\"github.com/52funny/pikpakcli/internal/lo"
},
{
"path": "main.go",
"chars": 88,
"preview": "package main\n\nimport \"github.com/52funny/pikpakcli/cli\"\n\nfunc main() {\n\tcli.Execute()\n}\n"
},
{
"path": "rules/README.md",
"chars": 638,
"preview": "# Rubbish Rules\n\nThis directory stores text rule files used by the `rubbish` command.\n\n## Files\n\n- `rubbish_rules.txt`: "
},
{
"path": "rules/rubbish_rules.txt",
"chars": 721,
"preview": "# Rubbish match rules for the future `rubbish` command.\n# Format:\n# - one rule per line\n# - empty lines are ignored\n# - "
}
]
About this extraction
This page contains the full source code of the 52funny/pikpakcli GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 66 files (222.8 KB), approximately 68.5k tokens, and a symbol index with 347 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.