diff --git a/tor-dl.go b/tor-dl.go
index 44d16d5..4a5e42d 100644
--- a/tor-dl.go
+++ b/tor-dl.go
@@ -2,7 +2,9 @@ package main
/*
tor-dl - fast large file downloader over locally installed Tor
- Copyright © 2025 Bryan Cuneo
+ Copyright © 2026 Bryan Cuneo
+
+ -resume added by DrJapan
Based on torget by Michał Trojnara
@@ -22,6 +24,7 @@ package main
import (
"bufio"
"context"
+ "encoding/json"
"errors"
"flag"
"fmt"
@@ -46,6 +49,20 @@ type chunk struct {
cancel context.CancelFunc
}
+type resumeChunk struct {
+ Start int64 `json:"start"`
+ Length int64 `json:"length"`
+}
+
+type resumeState struct {
+ Version int `json:"version"`
+ Src string `json:"src"`
+ Output string `json:"output"`
+ BytesTotal int64 `json:"bytes_total"`
+ Circuits int `json:"circuits"`
+ Chunks []resumeChunk `json:"chunks"`
+}
+
type State struct {
ctx context.Context
src string
@@ -63,6 +80,7 @@ type State struct {
}
const torBlock = 8000 // The longest plain text block in Tor
+const resumeSuffix = ".tordl.resume"
// Basic function to determine human-readable file sizes
func humanReadableSize(sizeInBytes float32) string {
@@ -93,6 +111,78 @@ func httpClient(user string) *http.Client {
}
}
+func resumeFilepath(output string) string {
+ return output + resumeSuffix
+}
+
+func loadResume(path string) (resumeState, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return resumeState{}, err
+ }
+
+ var state resumeState
+ if err := json.Unmarshal(data, &state); err != nil {
+ return resumeState{}, err
+ }
+
+ return state, nil
+}
+
+func (s *State) snapshotResume() resumeState {
+ state := resumeState{
+ Version: 1,
+ Src: s.src,
+ Output: s.output,
+ BytesTotal: s.bytesTotal,
+ Circuits: s.circuits,
+ Chunks: make([]resumeChunk, s.circuits),
+ }
+
+ s.rwmutex.RLock()
+ for i := range s.chunks {
+ state.Chunks[i] = resumeChunk{Start: s.chunks[i].start, Length: s.chunks[i].length}
+ }
+ s.rwmutex.RUnlock()
+
+ return state
+}
+
+func (s *State) saveResume(path string) error {
+ state := s.snapshotResume()
+ data, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return err
+ }
+
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0644); err != nil {
+ return err
+ }
+
+ return os.Rename(tmp, path)
+}
+
+func (s *State) startResumeWriter(path string) chan struct{} {
+ := make(chan struct{})
+ go func() {
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.()
+ for {
+ select {
+ case <-:
+ return
+ case <-ticker.C:
+ if err := s.saveResume(path); err != nil {
+ s.log <- fmt.Sprintf("resume save: %s", err.Error())
+ }
+ }
+ }
+ }()
+
+ return
+}
+
func NewState(ctx context.Context) *State {
var s State
s.circuits = circuits
@@ -233,7 +323,7 @@ func (s *State) printLogs() {
for i := 0; i < n; i++ {
logs[i] = <-s.log
}
- logs[n] = "stop" // Not an expected log line
+ logs[n] = "" // Not an expected log line
sort.Strings(logs)
prevLog := "start" // Not an expected log line
cnt := 0
@@ -300,8 +390,8 @@ func (s *State) darwin() {
var slowest float64
now := time.Now()
- s.rwmutex.RLock()
- for id := range s.circuits {
+ s.rwmutex.Lock()
+ for id := range s.chunks {
if s.chunks[id].cancel == nil {
continue
}
@@ -320,7 +410,7 @@ func (s *State) darwin() {
s.chunks[victim].cancel()
s.chunks[victim].cancel = nil
}
- s.rwmutex.RUnlock()
+ s.rwmutex.Unlock()
}
func (s *State) getOutputFilepath() {
@@ -366,16 +456,32 @@ func (s *State) getOutputFilepath() {
}
func (s *State) Fetch(src string) int {
- var stop_status chan bool
+ var Status chan bool
+ var resume chan struct{}
+ var resumeData *resumeState
+
s.src = src
startTime := time.Now()
s.getOutputFilepath()
- // If the file already exists and the -force argument was not used, exit
- if _, err := os.Stat(s.output); err == nil && !force {
- fmt.Fprintf(errorWriter, "ERROR: \"%s\" already exists. Skipping.\n", s.output)
- return 1
+ resumePath := resumeFilepath(s.output)
+
+ // If the file already exists, the resume data exists and is available, and the -force argument was not used, exit
+ if resume {
+ if data, err := loadResume(resumePath); err == nil {
+ resumeData = &data
+ } else if !errors.Is(err, fs.ErrNotExist) {
+ fmt.Fprintf(messageWriter, "WARNING: Unable to read resume data: %v\n", err)
+ }
}
+
+ if resume && resumeData == nil {
+ if _, err := os.Stat(s.output); err == nil && !force {
+ fmt.Fprintf(errorWriter, "ERROR: \"%s\" already exists and no resume data found. Use -force to overwrite or delete the file.\n", s.output)
+ return 1
+ }
+ }
+
fmt.Fprintf(messageWriter, "Output file:\t\t%s\n", s.output)
// Get the target length
@@ -392,6 +498,46 @@ func (s *State) Fetch(src string) int {
s.bytesTotal = resp.ContentLength
fmt.Fprintf(messageWriter, "Download filesize:\t%s\n", humanReadableSize(float32(s.bytesTotal)))
+ //Resume data errors (URLs don't match, resume size doesn't match, resume data is invalid)
+ seq := 0
+ if resumeData != nil {
+ if _, err := os.Stat(s.output); err != nil {
+ fmt.Fprintf(errorWriter, "ERROR: Output file missing for resume: %s\n", s.output)
+ return 1
+ }
+
+ if resumeData.Circuits != s.circuits {
+ fmt.Fprintf(messageWriter, "WARNING: Resume data uses %d circuits; overriding -circuits.\n", resumeData.Circuits)
+ }
+ s.circuits = resumeData.Circuits
+ s.chunks = make([]chunk, s.circuits)
+ for i := range s.chunks {
+ s.chunks[i].start = resumeData.Chunks[i].Start
+ s.chunks[i].length = resumeData.Chunks[i].Length
+ s.chunks[i].circuit = i
+ }
+
+ remaining := int64(0)
+ for i := range s.chunks {
+ remaining += s.chunks[i].length
+ }
+ s.bytesPrev = s.bytesTotal - remaining
+
+ if remaining == 0 {
+ s.printPermanent("Download already complete.")
+ if err := os.Remove(resumePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ fmt.Fprintf(messageWriter, "WARNING: Unable to remove resume file: %v\n", err)
+ }
+ return 0
+ }
+ seq = s.circuits
+ } else {
+ // If the file already exists and the -force argument was not used, exit
+ if _, err := os.Stat(s.output); err == nil && !force {
+ fmt.Fprintf(errorWriter, "ERROR: \"%s\" already exists. Skipping.\n", s.output)
+ return 1
+ }
+
// Create the output file. This will overwrite an existing file
file, err := os.Create(s.output)
if file != nil {
@@ -401,28 +547,40 @@ func (s *State) Fetch(src string) int {
fmt.Fprintln(messageWriter, err.Error())
return 1
}
-
+ if err := os.Remove(resumePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ fmt.Fprintf(messageWriter, "WARNING: Unable to remove resume file: %v\n", err)
+ }
+
// Initialize chunks
chunkLen := s.bytesTotal / int64(s.circuits)
- seq := 0
- for id := range s.circuits {
- s.chunks[id].start = int64(id) * chunkLen
- s.chunks[id].length = chunkLen
- s.chunks[id].circuit = seq
- seq++
+ for id := range s.chunks {
+ s.chunks[id].start = int64(id) * chunkLen
+ s.chunks[id].length = chunkLen
+ s.chunks[id].circuit = seq
+ seq++
+ }
+ s.chunks[s.circuits-1].length += s.bytesTotal % int64(s.circuits)
+ }
+
+ // Start resume writer if enabled
+ if resume {
+ if err := s.saveResume(resumePath); err != nil {
+ fmt.Fprintf(messageWriter, "WARNING: Unable to write resume data: %v\n", err)
+ } else {
+ resume = s.startResumeWriter(resumePath)
+ }
}
- s.chunks[s.circuits-1].length += s.bytesTotal % int64(s.circuits)
// If not -quiet or -silent, update status message every 1 second
if !quiet && !silent {
- stop_status = make(chan bool)
+ stopStatus = make(chan bool)
go func() {
for {
time.Sleep(time.Second)
select {
- case <-stop_status:
- close(stop_status)
+ case <-stopStatus:
+ close(stopStatus)
return
default:
s.progress()
@@ -433,13 +591,16 @@ func (s *State) Fetch(src string) int {
// Spawn initial fetchers
go func() {
- for id := range s.circuits {
+ for id := range s.chunks {
+ if s.chunks[id].length == 0 {
+ continue
+ }
client, req := s.chunkInit(id)
go s.chunkFetch(id, client, req)
time.Sleep(499 * time.Millisecond) // Be gentle to the local tor daemon
}
}()
-
+
// Spawn additional fetchers as needed
for {
select {
@@ -471,7 +632,18 @@ func (s *State) Fetch(src string) int {
howLong.Round(time.Second),
averageSpeed))
- stop_status <- true
+ if resumeStop != nil {
+ close(resumeStop)
+ resumeStop = nil
+ }
+ if resume {
+ if err := os.Remove(resumePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ fmt.Fprintf(messageWriter, "WARNING: Unable to remove resume file: %v\n", err)
+ }
+ }
+ if stopStatus != nil {
+ stopStatus <- true
+ }
return 0
}
if s.chunks[longest].length <= 5*torBlock {
@@ -502,6 +674,7 @@ var force bool
var minLifetime int
var name string
var quiet bool
+var resume bool
var silent bool
var torPort int
var verbose bool
@@ -530,6 +703,9 @@ func init() {
flag.BoolVar(&quiet, "quiet", false, "Suppress most text output (still show errors).")
flag.BoolVar(&quiet, "q", false, "Suppress most text output (still show errors).")
+ flag.BoolVar(&resume, "resume", false, "Resume download if a matching .tordl.resume file exists.")
+ flag.BoolVar(&resume, "r", false, "Resume download if a matching .tordl.resume file exists.")
+
flag.BoolVar(&silent, "silent", false, "Suppress all text output (including errors).")
flag.BoolVar(&silent, "s", false, "Suppress all text output (including errors).")
@@ -543,7 +719,7 @@ func init() {
flag.Usage = func() {
w := flag.CommandLine.Output()
msg := `tor-dl - fast large file downloader over locally installed Tor
-Copyright © 2025 Bryan Cuneo
+Copyright © 2026 Bryan Cuneo
Licensed under GNU GPL version 3
Based on torget by Michał Trojnara
@@ -562,6 +738,8 @@ Usage: tor-dl [FLAGS] {file.txt | URL [URL2...]}
Output filename. (default filename from URL)
-quiet, -q bool
Suppress most text output (still show errors).
+ -resume, -r bool
+ Resume download if a matching .tordl.resume file exists.
-silent, -s bool
Suppress all text output (including errors).
-tor-port, -p int