Files
minerva-pick/main.go
T

578 lines
13 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/anacrolix/torrent"
)
const usageText = `minerva-pick - pobieranie z magneta
Uzycie:
minerva-pick [opcje] ['magnet:?xt=urn:btih:...']
Przyklady:
minerva-pick 'magnet:?xt=urn:btih:...'
minerva-pick
minerva-pick -out ./downloads
minerva-pick -list
minerva-pick -forget-magnet
Podany magnet jest zapisywany jako ostatnio uzyty.
Gdy uruchomisz program bez magneta, uzyje zapamietanego.
Jesli fzf jest zainstalowany, zostanie uzyty automatycznie.
Bez fzf wybierz pliki wpisujac np.: 1,3,7-10 albo all.
Elo.
`
type options struct {
outDir string
listOnly bool
noFZF bool
forgetMagnet bool
metadataTimeout time.Duration
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
if errors.Is(err, context.Canceled) {
fmt.Fprintln(os.Stderr, "\nPrzerwano.")
os.Exit(130)
}
fmt.Fprintln(os.Stderr, "blad:", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
var opt options
flag.StringVar(&opt.outDir, "out", "./downloads", "katalog docelowy")
flag.BoolVar(&opt.listOnly, "list", false, "tylko pokaz liste plikow")
flag.BoolVar(&opt.noFZF, "no-fzf", false, "nie uzywaj fzf; wybor tekstowy")
flag.BoolVar(&opt.forgetMagnet, "forget-magnet", false, "usun zapamietany magnet i zakoncz")
flag.DurationVar(&opt.metadataTimeout, "metadata-timeout", 5*time.Minute, "timeout pobierania metadanych")
flag.Usage = func() {
fmt.Fprint(flag.CommandLine.Output(), usageText)
flag.PrintDefaults()
}
flag.Parse()
if flag.NArg() > 1 {
flag.Usage()
return fmt.Errorf("podaj najwyzej jeden magnet link")
}
magnetPath, err := lastMagnetPath()
if err != nil {
return err
}
if opt.forgetMagnet {
if flag.NArg() != 0 {
return fmt.Errorf("-forget-magnet nie przyjmuje magnet linku")
}
if err := forgetLastMagnet(magnetPath); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "Zapamietany magnet usuniety.")
return nil
}
var magnet string
if flag.NArg() == 1 {
magnet = flag.Arg(0)
} else {
magnet, err = loadLastMagnet(magnetPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
flag.Usage()
return fmt.Errorf("brak zapamietanego magneta; podaj magnet link przy pierwszym uruchomieniu")
}
return err
}
fmt.Fprintln(os.Stderr, "Uzywam zapamietanego magneta.")
}
if !strings.HasPrefix(strings.ToLower(magnet), "magnet:?") {
return fmt.Errorf("argument nie wyglada jak magnet link")
}
outDir, err := filepath.Abs(opt.outDir)
if err != nil {
return fmt.Errorf("ustalenie katalogu docelowego %q: %w", opt.outDir, err)
}
if err := os.MkdirAll(filepath.Dir(outDir), 0o755); err != nil {
return fmt.Errorf("utworzenie katalogu nadrzednego dla %q: %w", outDir, err)
}
stageDir, err := os.MkdirTemp(filepath.Dir(outDir), ".minerva-pick-")
if err != nil {
return fmt.Errorf("utworzenie katalogu roboczego: %w", err)
}
defer os.RemoveAll(stageDir)
cfg := torrent.NewDefaultClientConfig()
cfg.DisableIPv6 = true
cfg.DataDir = stageDir
cfg.Seed = false
client, err := torrent.NewClient(cfg)
if err != nil {
return fmt.Errorf("uruchomienie klienta torrent: %w", err)
}
clientClosed := false
defer func() {
if !clientClosed {
client.Close()
}
}()
t, err := client.AddMagnet(magnet)
if err != nil {
return fmt.Errorf("dodanie magneta: %w", err)
}
fmt.Fprintln(os.Stderr, "Pobieram metadane torrenta...")
if err := waitForMetadata(ctx, t, opt.metadataTimeout); err != nil {
return err
}
files := t.Files()
if len(files) == 0 {
return fmt.Errorf("torrent nie zawiera plikow")
}
if flag.NArg() == 1 {
if err := saveLastMagnet(magnetPath, magnet); err != nil {
fmt.Fprintln(os.Stderr, "ostrzezenie: nie udalo sie zapamietac magneta:", err)
}
}
info := t.Info()
if info != nil && info.Name != "" {
fmt.Fprintf(os.Stderr, "Torrent: %s (%d plikow)\n", info.Name, len(files))
} else {
fmt.Fprintf(os.Stderr, "Metadane gotowe: %d plikow\n", len(files))
}
if opt.listOnly {
printFiles(files)
return nil
}
selected, err := chooseFiles(ctx, files, opt.noFZF)
if err != nil {
return err
}
if len(selected) == 0 {
fmt.Fprintln(os.Stderr, "Nic nie wybrano.")
return nil
}
var total int64
fmt.Fprintln(os.Stderr, "Wybrane pliki:")
for _, idx := range selected {
f := files[idx]
total += f.Length()
fmt.Fprintf(os.Stderr, " [%d] %8s %s\n", idx+1, humanBytes(f.Length()), f.Path())
f.Download()
}
fmt.Fprintf(os.Stderr, "Razem: %s\n", humanBytes(total))
if err := waitForSelected(ctx, files, selected, total); err != nil {
return err
}
if errs := client.Close(); len(errs) > 0 {
fmt.Fprintf(os.Stderr, "ostrzezenie: zamykanie klienta torrent zwrocilo %d bledow\n", len(errs))
}
clientClosed = true
if err := publishSelected(stageDir, outDir, files, selected); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Gotowe. Zapisano %d wybranych plikow w %s\n", len(selected), outDir)
return nil
}
func lastMagnetPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("ustalenie katalogu konfiguracji: %w", err)
}
return filepath.Join(configDir, "minerva-pick", "last-magnet"), nil
}
func saveLastMagnet(path, magnet string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("utworzenie katalogu konfiguracji: %w", err)
}
if err := os.WriteFile(path, []byte(strings.TrimSpace(magnet)+"\n"), 0o600); err != nil {
return fmt.Errorf("zapis magneta: %w", err)
}
return nil
}
func loadLastMagnet(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
magnet := strings.TrimSpace(string(b))
if magnet == "" {
return "", fmt.Errorf("zapamietany magnet jest pusty")
}
return magnet, nil
}
func forgetLastMagnet(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("usuniecie zapamietanego magneta: %w", err)
}
return nil
}
func waitForMetadata(ctx context.Context, t *torrent.Torrent, timeout time.Duration) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-t.GotInfo():
return nil
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return fmt.Errorf("timeout po %s podczas pobierania metadanych", timeout)
}
}
func chooseFiles(ctx context.Context, files []*torrent.File, noFZF bool) ([]int, error) {
if !noFZF {
if path, err := exec.LookPath("fzf"); err == nil {
selected, err := chooseWithFZF(ctx, path, files)
if err == nil {
return selected, nil
}
if errors.Is(err, context.Canceled) {
return nil, err
}
fmt.Fprintln(os.Stderr, "fzf nie zadzialal, przechodze do wyboru tekstowego:", err)
}
}
return chooseFromPrompt(files)
}
func chooseWithFZF(ctx context.Context, fzfPath string, files []*torrent.File) ([]int, error) {
var input strings.Builder
for i, f := range files {
fmt.Fprintf(&input, "%d\t%8s\t%s\n", i+1, humanBytes(f.Length()), f.Path())
}
cmd := exec.CommandContext(ctx, fzfPath,
"--multi",
"--height=90%",
"--border",
"--delimiter=\t",
"--with-nth=2..",
"--prompt=pliki> ",
"--bind=ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all",
"--header=TAB: zaznacz/odznacz, ENTER: pobierz, ESC: anuluj",
)
cmd.Stdin = strings.NewReader(input.String())
cmd.Stderr = os.Stderr
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && (exitErr.ExitCode() == 1 || exitErr.ExitCode() == 130) {
return nil, nil
}
return nil, err
}
var selected []int
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := scanner.Text()
first, _, ok := strings.Cut(line, "\t")
if !ok {
continue
}
n, err := strconv.Atoi(strings.TrimSpace(first))
if err == nil && n >= 1 && n <= len(files) {
selected = append(selected, n-1)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return uniqueSorted(selected), nil
}
func chooseFromPrompt(files []*torrent.File) ([]int, error) {
printFiles(files)
fmt.Fprint(os.Stderr, "Wybierz pliki (np. 1,3,7-10 albo all): ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && len(line) == 0 {
return nil, err
}
return parseSelection(strings.TrimSpace(line), len(files))
}
func parseSelection(s string, max int) ([]int, error) {
if strings.EqualFold(strings.TrimSpace(s), "all") {
ret := make([]int, max)
for i := range ret {
ret[i] = i
}
return ret, nil
}
if strings.TrimSpace(s) == "" {
return nil, nil
}
var result []int
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "-") {
loS, hiS, ok := strings.Cut(part, "-")
if !ok {
return nil, fmt.Errorf("niepoprawny zakres %q", part)
}
lo, err1 := strconv.Atoi(strings.TrimSpace(loS))
hi, err2 := strconv.Atoi(strings.TrimSpace(hiS))
if err1 != nil || err2 != nil || lo < 1 || hi < lo || hi > max {
return nil, fmt.Errorf("niepoprawny zakres %q (dozwolone 1-%d)", part, max)
}
for n := lo; n <= hi; n++ {
result = append(result, n-1)
}
continue
}
n, err := strconv.Atoi(part)
if err != nil || n < 1 || n > max {
return nil, fmt.Errorf("niepoprawny numer %q (dozwolone 1-%d)", part, max)
}
result = append(result, n-1)
}
return uniqueSorted(result), nil
}
func uniqueSorted(in []int) []int {
if len(in) == 0 {
return nil
}
sort.Ints(in)
out := in[:0]
last := -1
for _, n := range in {
if n != last {
out = append(out, n)
last = n
}
}
return out
}
func printFiles(files []*torrent.File) {
for i, f := range files {
fmt.Printf("%6d %8s %s\n", i+1, humanBytes(f.Length()), f.Path())
}
}
func waitForSelected(ctx context.Context, files []*torrent.File, selected []int, total int64) error {
if total == 0 {
return nil
}
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
lastDone := selectedBytesCompleted(files, selected)
lastAt := time.Now()
printProgress(lastDone, total, 0)
for {
select {
case <-ctx.Done():
fmt.Fprintln(os.Stderr)
return ctx.Err()
case now := <-ticker.C:
done := selectedBytesCompleted(files, selected)
elapsed := now.Sub(lastAt).Seconds()
rate := float64(done-lastDone) / elapsed
if rate < 0 {
rate = 0
}
printProgress(done, total, rate)
if done >= total {
fmt.Fprintln(os.Stderr)
return nil
}
lastDone = done
lastAt = now
}
}
}
func publishSelected(stageDir, outDir string, files []*torrent.File, selected []int) error {
for _, idx := range selected {
f := files[idx]
rel, err := safeTorrentPath(f.Path())
if err != nil {
return fmt.Errorf("niebezpieczna sciezka w torrencie %q: %w", f.Path(), err)
}
src := filepath.Join(stageDir, rel)
dst := filepath.Join(outDir, rel)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("utworzenie katalogu dla %q: %w", dst, err)
}
if f.Length() == 0 {
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
if err := os.WriteFile(dst, nil, 0o644); err != nil {
return fmt.Errorf("utworzenie pustego pliku %q: %w", dst, err)
}
continue
}
}
if err := moveFile(src, dst); err != nil {
return fmt.Errorf("zapis wybranego pliku %q: %w", f.Path(), err)
}
}
return nil
}
func safeTorrentPath(path string) (string, error) {
rel := filepath.Clean(filepath.FromSlash(path))
if rel == "." || rel == "" || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("sciezka wychodzi poza katalog docelowy")
}
return rel, nil
}
func moveFile(src, dst string) error {
if err := os.Rename(src, dst); err == nil {
return nil
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".minerva-pick-copy-")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
os.Remove(tmpName)
}
}()
if err := tmp.Chmod(info.Mode().Perm()); err != nil {
tmp.Close()
return err
}
if _, err := io.Copy(tmp, in); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpName, dst); err != nil {
return err
}
cleanup = false
if err := os.Remove(src); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func selectedBytesCompleted(files []*torrent.File, selected []int) int64 {
var done int64
for _, idx := range selected {
f := files[idx]
n := f.BytesCompleted()
if n > f.Length() {
n = f.Length()
}
done += n
}
return done
}
func printProgress(done, total int64, bytesPerSecond float64) {
pct := float64(done) / float64(total) * 100
fmt.Fprintf(os.Stderr, "\r%6.2f%% %s / %s %s/s", pct, humanBytes(done), humanBytes(total), humanBytes(int64(bytesPerSecond)))
}
func humanBytes(n int64) string {
const unit = int64(1024)
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := unit, 0
for value := n / unit; value >= unit && exp < 5; value /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}