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