Files
zpan/cmd/main.go
T

270 lines
7.6 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
"github.com/saltbo/zpan/internal/client"
"github.com/saltbo/zpan/internal/config"
"github.com/saltbo/zpan/internal/downloader"
_ "github.com/saltbo/zpan/pkg/downloaders/aria2"
_ "github.com/saltbo/zpan/pkg/downloaders/httpdl"
_ "github.com/saltbo/zpan/pkg/downloaders/qbittorrent"
"github.com/saltbo/zpan/pkg/system"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
if err := rootCommand().Execute(); err != nil {
slog.Error("command failed", "error", err)
os.Exit(1)
}
}
func rootCommand() *cobra.Command {
v := viper.New()
var cfgFile string
var logLevel string
root := &cobra.Command{
Use: "zpan",
Short: "Command line tools for ZPan",
}
root.PersistentFlags().StringVar(&cfgFile, "config", config.DefaultConfigPath(), "config file")
root.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
root.PersistentPreRun = func(cmd *cobra.Command, args []string) {
setLogLevel(logLevel)
v.SetConfigFile(cfgFile)
}
root.AddCommand(configCommand(v, &cfgFile))
root.AddCommand(downloaderCommand(v, &cfgFile))
return root
}
func downloaderCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
cmd := &cobra.Command{
Use: "downloader",
Short: "Manage the ZPan remote downloader",
}
cmd.AddCommand(upCommand(v, cfgFile))
return cmd
}
func setLogLevel(level string) {
var parsed slog.Level
switch strings.ToLower(level) {
case "debug":
parsed = slog.LevelDebug
case "info", "":
parsed = slog.LevelInfo
case "warn", "warning":
parsed = slog.LevelWarn
case "error":
parsed = slog.LevelError
default:
slog.Warn("unknown log level, using info", "log_level", level)
parsed = slog.LevelInfo
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: parsed})))
}
func upCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
return &cobra.Command{
Use: "up",
Short: "Start the downloader worker",
RunE: func(cmd *cobra.Command, args []string) error {
slog.Info("loading downloader config")
cfg, err := config.Load(v)
if err != nil {
return err
}
if err := validateConfiguredEngine(cfg.Engine); err != nil {
return err
}
slog.Info("downloader config loaded",
"server_url", cfg.ServerURL,
"engine", cfg.Engine,
"download_dir", cfg.DownloadDir,
"poll_interval", cfg.PollInterval.String(),
"max_concurrent_tasks", cfg.MaxConcurrentTasks,
"seed_enabled", cfg.SeedEnabled,
"seed_duration", cfg.SeedDuration.String(),
"seed_cache_limit", cfg.SeedCacheLimit,
)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if cfg.Token == "" {
registered, err := registerDownloaderWithDeviceLogin(ctx, cmd, cfg, *cfgFile)
if err != nil {
return err
}
cfg.Token = registered.Token
}
runner, err := downloader.NewTaskRunner(cfg)
if err != nil {
return err
}
return runner.Run(ctx)
},
}
}
func configCommand(v *viper.Viper, cfgFile *string) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage CLI configuration",
}
var serverURL string
initCmd := &cobra.Command{
Use: "init",
Short: "Create a default config file",
RunE: func(cmd *cobra.Command, args []string) error {
return config.WriteDefaultConfig(*cfgFile, serverURL)
},
}
initCmd.Flags().StringVar(&serverURL, "server-url", config.DefaultServerURL, "ZPan server URL")
cmd.AddCommand(initCmd)
return cmd
}
func registerDownloaderWithDeviceLogin(
ctx context.Context,
cmd *cobra.Command,
cfg config.Config,
cfgFile string,
) (client.CreateDownloaderResponse, error) {
slog.Info("starting downloader device login")
api, err := client.New(cfg.ServerURL, "")
if err != nil {
return client.CreateDownloaderResponse{}, err
}
slog.Info("requesting device login code", "server_url", cfg.ServerURL)
code, err := api.RequestDeviceCode(ctx)
if err != nil {
return client.CreateDownloaderResponse{}, err
}
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to authorize the downloader:\n%s\n\n", code.VerificationURIComplete)
fmt.Fprintf(cmd.OutOrStdout(), "User code: %s\n", code.UserCode)
slog.Info("waiting for device authorization", "user_code", code.UserCode)
token, err := pollDeviceToken(ctx, api, code)
if err != nil {
return client.CreateDownloaderResponse{}, err
}
slog.Info("device authorization completed")
registered, err := api.CreateDownloader(ctx, token.AccessToken, client.CreateDownloaderRequest{
Name: downloaderName(),
Heartbeat: registrationHeartbeat(cfg),
})
if err != nil {
return client.CreateDownloaderResponse{}, err
}
slog.Info("downloader registered", "downloader_id", registered.Downloader.ID)
if err := saveRegisteredDownloaderConfig(cfg, cfgFile, registered.Token); err != nil {
return client.CreateDownloaderResponse{}, err
}
fmt.Fprintf(cmd.OutOrStdout(), "Downloader registered: %s\n", registered.Downloader.ID)
fmt.Fprintf(cmd.OutOrStdout(), "Config saved: %s\n", cfgFile)
return registered, nil
}
func saveRegisteredDownloaderConfig(cfg config.Config, cfgFile string, token string) error {
return config.WriteConfig(cfgFile, cfg, token)
}
func downloaderName() string {
if hostname := system.DownloaderHostname(); hostname != "" {
return hostname
}
return "zpan"
}
func pollDeviceToken(ctx context.Context, api *client.Client, code client.DeviceCode) (client.DeviceToken, error) {
interval := time.Duration(code.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
deadline := time.Now().Add(time.Duration(code.ExpiresIn) * time.Second)
for {
if time.Now().After(deadline) {
return client.DeviceToken{}, fmt.Errorf("device login expired")
}
select {
case <-ctx.Done():
return client.DeviceToken{}, ctx.Err()
case <-time.After(interval):
}
token, err := api.PollDeviceToken(ctx, code.DeviceCode)
if err == nil {
return token, nil
}
if !isPendingDeviceAuthError(err) {
return client.DeviceToken{}, err
}
}
}
func isPendingDeviceAuthError(err error) bool {
message := err.Error()
return strings.Contains(message, "authorization_pending") || strings.Contains(message, "slow_down")
}
func registrationHeartbeat(cfg config.Config) client.Heartbeat {
engine := normalizeRegistrationEngine(cfg)
return client.Heartbeat{
Version: downloader.Version,
Hostname: system.DownloaderHostname(),
Platform: runtime.GOOS,
Arch: runtime.GOARCH,
Engine: engine,
Capabilities: runtimeCapabilities(engine),
MaxConcurrentTasks: cfg.MaxConcurrentTasks,
CurrentTasks: 0,
DownloadBps: 0,
UploadBps: 0,
FreeDiskBytes: 0,
}
}
func normalizeRegistrationEngine(cfg config.Config) string {
switch strings.ToLower(strings.TrimSpace(cfg.Engine)) {
case "aria2", "qbittorrent", "http":
return strings.ToLower(strings.TrimSpace(cfg.Engine))
}
if cfg.Aria2Configured {
return "aria2"
}
if cfg.QBittorrentConfigured {
return "qbittorrent"
}
return "aria2"
}
func validateConfiguredEngine(engine string) error {
switch strings.ToLower(strings.TrimSpace(engine)) {
case "", "auto", "http", "aria2", "qbittorrent":
return nil
default:
return fmt.Errorf("unsupported downloader engine %q; expected auto, http, aria2, or qbittorrent", engine)
}
}
func runtimeCapabilities(name string) []string {
switch name {
case "aria2", "qbittorrent":
return []string{"http", "magnet", "torrent", "torrent_url"}
default:
return []string{"http"}
}
}