// x360-bt: non-interactive BitTorrent helper based on the anacrolix/torrent // library, also used by the user's minerva-pick tool. It emits machine-readable // status and never starts downloading payload data while inspecting a magnet. package main import ( "context" "encoding/json" "errors" "flag" "fmt" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/anacrolix/torrent" ) type state struct { Status string `json:"status"` Error string `json:"error,omitempty"` Connections int `json:"connections"` KnownPeers int `json:"known_peers"` Downloaded int64 `json:"downloaded,omitempty"` Total int64 `json:"total,omitempty"` Speed int64 `json:"speed,omitempty"` Elapsed int64 `json:"elapsed,omitempty"` } func writeState(path string, s state) { if path == "" { return } b, err := json.Marshal(s) if err != nil { return } if err = os.MkdirAll(filepath.Dir(path), 0755); err != nil { return } f, err := os.CreateTemp(filepath.Dir(path), ".bt-state-*") if err != nil { return } defer os.Remove(f.Name()) if _, err = f.Write(b); err != nil { f.Close() return } if err = f.Close(); err != nil { return } _ = os.Rename(f.Name(), path) } func newClient(dataDir string, port int) (*torrent.Client, error) { cfg := torrent.NewDefaultClientConfig() cfg.DisableIPv6 = true cfg.DataDir = dataDir cfg.Seed = false cfg.NoDefaultPortForwarding = true cfg.ListenPort = port return torrent.NewClient(cfg) } func inspect(ctx context.Context, magnet, output, statusPath string, port int, timeout time.Duration) error { if !strings.HasPrefix(strings.ToLower(magnet), "magnet:?") { return errors.New("nieprawidlowy magnet") } if err := os.MkdirAll(filepath.Dir(output), 0700); err != nil { return err } client, err := newClient(filepath.Dir(output), port) if err != nil { return fmt.Errorf("klient BT: %w", err) } defer client.Close() t, err := client.AddMagnet(magnet) if err != nil { return fmt.Errorf("magnet: %w", err) } started := time.Now() timer := time.NewTimer(timeout) defer timer.Stop() ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() for { snapshot := state{Status: "loading", Connections: len(t.PeerConns()), KnownPeers: len(t.KnownSwarm()), Elapsed: int64(time.Since(started).Seconds())} writeState(statusPath, snapshot) select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return fmt.Errorf("brak metadanych po %s (polaczenia=%d, znani peerzy=%d)", timeout, snapshot.Connections, snapshot.KnownPeers) case <-t.GotInfo(): mi := t.Metainfo() f, err := os.CreateTemp(filepath.Dir(output), ".bt-meta-*") if err != nil { return err } temp := f.Name() defer os.Remove(temp) if err = mi.Write(f); err != nil { f.Close() return err } if err = f.Close(); err != nil { return err } if info, err := os.Stat(temp); err != nil || info.Size() > 32*1024*1024 { return errors.New("metadane przekraczaja 32 MiB") } if err = os.Rename(temp, output); err != nil { return err } writeState(statusPath, state{Status: "ready", Connections: snapshot.Connections, KnownPeers: snapshot.KnownPeers, Elapsed: snapshot.Elapsed}) return nil case <-ticker.C: } } } func parseSelected(selection string, files []*torrent.File) ([]*torrent.File, error) { if selection == "" { return nil, errors.New("brak wybranych plikow") } used := make(map[int]bool) out := make([]*torrent.File, 0) for _, s := range strings.Split(selection, ",") { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || n < 1 || n > len(files) || used[n] { return nil, fmt.Errorf("niepoprawny indeks pliku %q", s) } used[n] = true out = append(out, files[n-1]) } return out, nil } func progress(files []*torrent.File) (done, total int64) { for _, f := range files { total += f.Length() n := f.BytesCompleted() if n > f.Length() { n = f.Length() } done += n } return } func fetch(ctx context.Context, input, selection, outDir, statusPath string, port int) error { if err := os.MkdirAll(outDir, 0755); err != nil { return err } client, err := newClient(outDir, port) if err != nil { return fmt.Errorf("klient BT: %w", err) } defer client.Close() t, err := client.AddTorrentFromFile(input) if err != nil { return fmt.Errorf("plik torrent: %w", err) } select { case <-ctx.Done(): return ctx.Err() case <-t.GotInfo(): } selected, err := parseSelected(selection, t.Files()) if err != nil { return err } for _, f := range selected { f.Download() } started := time.Now() ticker := time.NewTicker(time.Second) defer ticker.Stop() lastDone := int64(0) lastAt := time.Now() for { done, total := progress(selected) now := time.Now() seconds := now.Sub(lastAt).Seconds() var speed int64 if seconds > 0 { speed = int64(float64(done-lastDone) / seconds) if speed < 0 { speed = 0 } } lastDone = done lastAt = now if total == 0 || done >= total { writeState(statusPath, state{Status: "done", Total: total, Downloaded: done, Connections: len(t.PeerConns()), KnownPeers: len(t.KnownSwarm())}) return nil } writeState(statusPath, state{Status: "downloading", Total: total, Downloaded: done, Speed: speed, Connections: len(t.PeerConns()), KnownPeers: len(t.KnownSwarm()), Elapsed: int64(time.Since(started).Seconds())}) select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } } } func run(ctx context.Context) (string, error) { if len(os.Args) < 2 { return "", errors.New("tryb inspect albo fetch") } command := os.Args[1] flags := flag.NewFlagSet(command, flag.ContinueOnError) magnet := flags.String("magnet", "", "magnet link") input := flags.String("torrent", "", "plik .torrent") selection := flags.String("select", "", "indeksy 1-based oddzielone przecinkiem") out := flags.String("out", "", "sciezka wyjsciowa") statusPath := flags.String("status", "", "sciezka JSON statusu") port := flags.Int("port", 0, "port TCP/UDP (0 = losowy)") timeout := flags.Duration("timeout", 5*time.Minute, "timeout metadanych") if err := flags.Parse(os.Args[2:]); err != nil { return *statusPath, err } if *port < 0 || *port > 65535 { return *statusPath, errors.New("niepoprawny port") } var err error switch command { case "inspect": if *out == "" || *magnet == "" { err = errors.New("podaj -magnet i -out") } else { err = inspect(ctx, *magnet, *out, *statusPath, *port, *timeout) } case "fetch": if *input == "" || *out == "" { err = errors.New("podaj -torrent i -out") } else { err = fetch(ctx, *input, *selection, *out, *statusPath, *port) } default: err = errors.New("nieznany tryb: " + command) } return *statusPath, err } func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() path, err := run(ctx) if err != nil { fmt.Fprintln(os.Stderr, "x360-bt:", err) writeState(path, state{Status: "error", Error: err.Error()}) os.Exit(1) } }