Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 203 additions & 25 deletions tor-dl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package main

/*
tor-dl - fast large file downloader over locally installed Tor
Copyright © 2025 Bryan Cuneo <https://github.com/BryanCuneo/tor-dl/>
Copyright © 2026 Bryan Cuneo <https://github.com/BryanCuneo/tor-dl/>

-resume added by DrJapan

Based on torget by Michał Trojnara <https://github.com/mtrojnar/torget>

Expand All @@ -22,6 +24,7 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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()
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).")

Expand All @@ -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 <https://github.com/BryanCuneo/tor-dl/>
Copyright © 2026 Bryan Cuneo <https://github.com/BryanCuneo/tor-dl/>
Licensed under GNU GPL version 3 <https://www.gnu.org/licenses/>
Based on torget by Michał Trojnara <https://github.com/mtrojnar/torget>

Expand All @@ -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
Expand Down