first commit
This commit is contained in:
commit
8b29d0f9f7
6 files changed
+1272
No files matched your search
@@ -0,0 +1,575 @@
|
||||
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.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> ",
|
||||
"--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])
|
||||
}
|
||||
Reference in new issue
Block a user