Initial release of bdrclone

This commit is contained in:
2026-08-13 23:02:25 +08:00
commit d6d1956050
23 changed files with 3042 additions and 0 deletions

390
cmd/bdrclone/main.go Normal file
View File

@@ -0,0 +1,390 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"gitea.dddbg.com/youbin/bdrclone/internal/auth"
"gitea.dddbg.com/youbin/bdrclone/internal/baidu"
"gitea.dddbg.com/youbin/bdrclone/internal/config"
"gitea.dddbg.com/youbin/bdrclone/internal/mount"
"gitea.dddbg.com/youbin/bdrclone/internal/serve"
"github.com/spf13/cobra"
)
var version = "dev"
type application struct {
configPath string
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
app := &application{}
root := app.command()
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
}
func (a *application) command() *cobra.Command {
defaultConfig, _ := config.DefaultPath()
cmd := &cobra.Command{
Use: "bdrclone",
Short: "百度网盘命令行和 FUSE 挂载客户端",
SilenceUsage: true,
SilenceErrors: true,
Version: version,
}
cmd.PersistentFlags().StringVar(&a.configPath, "config", defaultConfig, "配置文件路径")
cmd.AddCommand(
a.configCommand(), a.authCommand(), a.lsCommand(), a.statCommand(), a.catCommand(),
a.mkdirCommand(), a.rmCommand(), a.mvCommand(), a.cpCommand(), a.uploadCommand(),
a.downloadCommand(), a.quotaCommand(), a.mountCommand(), a.serveCommand(),
)
return cmd
}
func (a *application) client() (*baidu.Client, *config.Config, error) {
cfg, err := config.Load(a.configPath)
if err != nil {
return nil, nil, err
}
if err := cfg.Validate(true); err != nil {
return nil, nil, err
}
client := baidu.New(cfg, baidu.WithTokenSaver(func(token baidu.Token, expiresAt time.Time) error {
cfg.AccessToken = token.AccessToken
cfg.RefreshToken = token.RefreshToken
cfg.ExpiresAt = expiresAt
return config.Save(a.configPath, cfg)
}))
return client, cfg, nil
}
func (a *application) configCommand() *cobra.Command {
var clientID, secret, redirect, root, userAgent string
var uploadParts int
var partSize int64
cmd := &cobra.Command{
Use: "config",
Short: "创建或更新百度开放平台配置",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg := &config.Config{}
if old, err := config.Load(a.configPath); err == nil {
cfg = old
}
if clientID != "" {
cfg.ClientID = clientID
}
if secret != "" {
cfg.ClientSecret = secret
}
if redirect != "" {
cfg.RedirectURI = redirect
}
if root != "" {
cfg.Root = root
}
if userAgent != "" {
cfg.UserAgent = userAgent
}
if uploadParts != 0 {
cfg.UploadParts = uploadParts
}
if cmd.Flags().Changed("part-size") {
cfg.PartSize = partSize
}
if err := config.Save(a.configPath, cfg); err != nil {
return err
}
fmt.Println("配置已保存到", a.configPath)
return nil
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "百度开放平台 AppKey")
cmd.Flags().StringVar(&secret, "client-secret", "", "百度开放平台 SecretKey")
cmd.Flags().StringVar(&redirect, "redirect-uri", "", "OAuth 回调地址")
cmd.Flags().StringVar(&root, "root", "", "挂载的网盘根路径")
cmd.Flags().StringVar(&userAgent, "user-agent", "", "下载 User-Agent")
cmd.Flags().IntVar(&uploadParts, "upload-parts", 0, "并发上传分片数 (1-32)")
cmd.Flags().Int64Var(&partSize, "part-size", 0, "上传分片字节数,0 表示按会员等级自动")
return cmd
}
func (a *application) authCommand() *cobra.Command {
var noOpen, oob bool
cmd := &cobra.Command{
Use: "auth",
Short: "通过 OAuth 授权百度网盘",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(a.configPath)
if err != nil {
return err
}
client := baidu.New(cfg, baidu.WithTokenSaver(func(token baidu.Token, expiresAt time.Time) error {
cfg.AccessToken, cfg.RefreshToken, cfg.ExpiresAt = token.AccessToken, token.RefreshToken, expiresAt
return config.Save(a.configPath, cfg)
}))
if oob {
err = auth.AuthorizeOOB(cmd.Context(), client, !noOpen, cmd.InOrStdin(), cmd.OutOrStdout())
} else {
err = auth.Authorize(cmd.Context(), client, cfg.RedirectURI, !noOpen)
}
if err != nil {
return err
}
fmt.Println("授权成功")
return nil
},
}
cmd.Flags().BoolVar(&noOpen, "no-open", false, "不自动打开浏览器")
cmd.Flags().BoolVar(&oob, "oob", false, "使用百度页面显示授权码,不启动本机回调服务")
return cmd
}
func (a *application) lsCommand() *cobra.Command {
var asJSON bool
cmd := &cobra.Command{Use: "ls [远端目录]", Args: cobra.MaximumNArgs(1), Short: "列出远端目录", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
name := "/"
if len(args) > 0 {
name = args[0]
}
entries, err := client.List(cmd.Context(), name)
if err != nil {
return err
}
if asJSON {
return printJSON(entries)
}
for _, entry := range entries {
kind := "-"
if entry.IsDirectory() {
kind = "d"
}
fmt.Printf("%s %12d %s %s\n", kind, entry.Size, entry.ModTime().Format("2006-01-02 15:04:05"), entry.Name())
}
return nil
}}
cmd.Flags().BoolVar(&asJSON, "json", false, "输出 JSON")
return cmd
}
func (a *application) statCommand() *cobra.Command {
return &cobra.Command{Use: "stat <远端路径>", Args: cobra.ExactArgs(1), Short: "查看远端文件元数据", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
entry, err := client.Stat(cmd.Context(), args[0])
if err != nil {
return err
}
return printJSON(entry)
}}
}
func (a *application) catCommand() *cobra.Command {
return &cobra.Command{Use: "cat <远端文件>", Args: cobra.ExactArgs(1), Short: "输出远端文件", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
entry, err := client.Stat(cmd.Context(), args[0])
if err != nil {
return err
}
body, err := client.Open(cmd.Context(), entry, 0, 0)
if err != nil {
return err
}
defer body.Close()
_, err = io.Copy(os.Stdout, body)
return err
}}
}
func (a *application) mkdirCommand() *cobra.Command {
return &cobra.Command{Use: "mkdir <远端目录>", Args: cobra.ExactArgs(1), Short: "创建远端目录", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
_, err = client.Mkdir(cmd.Context(), args[0])
return err
}}
}
func (a *application) rmCommand() *cobra.Command {
var recursive bool
cmd := &cobra.Command{Use: "rm <远端路径>", Args: cobra.ExactArgs(1), Short: "删除远端文件或目录", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
entry, err := client.Stat(cmd.Context(), args[0])
if err != nil {
return err
}
if entry.IsDirectory() && !recursive {
return errors.New("target is a directory; pass --recursive to delete it")
}
return client.Delete(cmd.Context(), args[0])
}}
cmd.Flags().BoolVarP(&recursive, "recursive", "r", false, "递归删除目录")
return cmd
}
func (a *application) mvCommand() *cobra.Command {
return a.manageCommand("mv", "移动或重命名远端路径", func(ctx context.Context, c *baidu.Client, from, to string) error { return c.Move(ctx, from, to) })
}
func (a *application) cpCommand() *cobra.Command {
return a.manageCommand("cp", "复制远端路径", func(ctx context.Context, c *baidu.Client, from, to string) error { return c.Copy(ctx, from, to) })
}
func (a *application) manageCommand(use, short string, fn func(context.Context, *baidu.Client, string, string) error) *cobra.Command {
return &cobra.Command{Use: use + " <源路径> <目标路径>", Args: cobra.ExactArgs(2), Short: short, RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
return fn(cmd.Context(), client, args[0], args[1])
}}
}
func (a *application) uploadCommand() *cobra.Command {
return &cobra.Command{Use: "upload <本地文件> <远端文件>", Args: cobra.ExactArgs(2), Short: "分片上传本地文件", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
_, err = client.UploadFile(cmd.Context(), args[0], args[1], func(done, total int64) {
fmt.Fprintf(os.Stderr, "\r上传 %d/%d bytes (%d%%)", done, total, done*100/total)
})
if err == nil {
fmt.Fprintln(os.Stderr)
}
return err
}}
}
func (a *application) downloadCommand() *cobra.Command {
return &cobra.Command{Use: "download <远端文件> <本地文件>", Args: cobra.ExactArgs(2), Short: "下载远端文件", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
entry, err := client.Stat(cmd.Context(), args[0])
if err != nil {
return err
}
body, err := client.Open(cmd.Context(), entry, 0, 0)
if err != nil {
return err
}
defer body.Close()
if err := os.MkdirAll(filepath.Dir(args[1]), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(args[1]), ".bdrclone-download-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
_, copyErr := io.Copy(tmp, body)
closeErr := tmp.Close()
if err := errors.Join(copyErr, closeErr); err != nil {
return err
}
return os.Rename(tmpName, args[1])
}}
}
func (a *application) quotaCommand() *cobra.Command {
return &cobra.Command{Use: "quota", Short: "查看网盘容量", RunE: func(cmd *cobra.Command, _ []string) error {
client, _, err := a.client()
if err != nil {
return err
}
quota, err := client.Quota(cmd.Context())
if err != nil {
return err
}
percent := int64(0)
if quota.Total > 0 {
percent = quota.Used * 100 / quota.Total
}
fmt.Printf("已用 %s / 总计 %s (%d%%)\n", formatBytes(quota.Used), formatBytes(quota.Total), percent)
return nil
}}
}
func (a *application) mountCommand() *cobra.Command {
var readOnly bool
cmd := &cobra.Command{Use: "mount <挂载点>", Args: cobra.ExactArgs(1), Short: "通过 FUSE 挂载百度网盘", RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := a.client()
if err != nil {
return err
}
if err := os.MkdirAll(args[0], 0o755); err != nil {
return err
}
fmt.Println("正在挂载", args[0], ",按 Ctrl-C 卸载")
return mount.Mount(cmd.Context(), client, args[0], mount.Options{ReadOnly: readOnly})
}}
cmd.Flags().BoolVar(&readOnly, "read-only", false, "只读挂载")
return cmd
}
func (a *application) serveCommand() *cobra.Command {
var address string
cmd := &cobra.Command{Use: "serve", Short: "启动只读 HTTP 文件服务", RunE: func(cmd *cobra.Command, _ []string) error {
client, _, err := a.client()
if err != nil {
return err
}
fmt.Println("HTTP 服务监听 http://" + address)
err = serve.New(client, address).ListenAndServe(cmd.Context())
if errors.Is(err, context.Canceled) {
return nil
}
return err
}}
cmd.Flags().StringVar(&address, "addr", "127.0.0.1:8080", "监听地址")
return cmd
}
func printJSON(value any) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func formatBytes(value int64) string {
const unit = int64(1024)
if value < unit {
return strconv.FormatInt(value, 10) + " B"
}
div, exp := unit, 0
for n := value / unit; n >= unit && exp < 5; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp])
}