261 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|