Files
2026-10-15 14:15:10 +02:00

261 lines
6.9 KiB
Go

// 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)
}
}