quartz_torrent 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/quartztorrent_download +127 -0
- data/bin/quartztorrent_download_curses +841 -0
- data/bin/quartztorrent_magnet_from_torrent +32 -0
- data/bin/quartztorrent_show_info +62 -0
- data/lib/quartz_torrent.rb +2 -0
- data/lib/quartz_torrent/bitfield.rb +314 -0
- data/lib/quartz_torrent/blockstate.rb +354 -0
- data/lib/quartz_torrent/classifiedpeers.rb +95 -0
- data/lib/quartz_torrent/extension.rb +37 -0
- data/lib/quartz_torrent/filemanager.rb +543 -0
- data/lib/quartz_torrent/formatter.rb +92 -0
- data/lib/quartz_torrent/httptrackerclient.rb +121 -0
- data/lib/quartz_torrent/interruptiblesleep.rb +27 -0
- data/lib/quartz_torrent/log.rb +132 -0
- data/lib/quartz_torrent/magnet.rb +92 -0
- data/lib/quartz_torrent/memprofiler.rb +27 -0
- data/lib/quartz_torrent/metainfo.rb +221 -0
- data/lib/quartz_torrent/metainfopiecestate.rb +265 -0
- data/lib/quartz_torrent/peer.rb +145 -0
- data/lib/quartz_torrent/peerclient.rb +1627 -0
- data/lib/quartz_torrent/peerholder.rb +123 -0
- data/lib/quartz_torrent/peermanager.rb +170 -0
- data/lib/quartz_torrent/peermsg.rb +502 -0
- data/lib/quartz_torrent/peermsgserialization.rb +102 -0
- data/lib/quartz_torrent/piecemanagerrequestmetadata.rb +12 -0
- data/lib/quartz_torrent/rate.rb +58 -0
- data/lib/quartz_torrent/ratelimit.rb +48 -0
- data/lib/quartz_torrent/reactor.rb +949 -0
- data/lib/quartz_torrent/regionmap.rb +124 -0
- data/lib/quartz_torrent/semaphore.rb +43 -0
- data/lib/quartz_torrent/trackerclient.rb +271 -0
- data/lib/quartz_torrent/udptrackerclient.rb +70 -0
- data/lib/quartz_torrent/udptrackermsg.rb +250 -0
- data/lib/quartz_torrent/util.rb +100 -0
- metadata +195 -0
@@ -0,0 +1,92 @@
|
|
1
|
+
module QuartzTorrent
|
2
|
+
# Class that can be used to format different quantities into
|
3
|
+
# human readable strings.
|
4
|
+
class Formatter
|
5
|
+
Kb = 1024
|
6
|
+
Meg = 1024*Kb
|
7
|
+
Gig = 1024*Meg
|
8
|
+
|
9
|
+
# Format a size in bytes.
|
10
|
+
def self.formatSize(size)
|
11
|
+
s = size.to_f
|
12
|
+
if s >= Gig
|
13
|
+
s = "%.2fGB" % (s / Gig)
|
14
|
+
elsif s >= Meg
|
15
|
+
s = "%.2fMB" % (s / Meg)
|
16
|
+
elsif s >= Kb
|
17
|
+
s = "%.2fKB" % (s / Kb)
|
18
|
+
else
|
19
|
+
s = "%.2fB" % s
|
20
|
+
end
|
21
|
+
s
|
22
|
+
end
|
23
|
+
|
24
|
+
# Format a floating point number as a percentage with one decimal place.
|
25
|
+
def self.formatPercent(frac)
|
26
|
+
s = "%.1f" % (frac.to_f*100)
|
27
|
+
s + "%"
|
28
|
+
end
|
29
|
+
|
30
|
+
# Format a speed in bytes per second.
|
31
|
+
def self.formatSpeed(s)
|
32
|
+
Formatter.formatSize(s) + "/s"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Format a duration of time in seconds.
|
36
|
+
def self.formatTime(secs)
|
37
|
+
s = ""
|
38
|
+
time = secs.to_i
|
39
|
+
arr = []
|
40
|
+
conv = [60,60]
|
41
|
+
unit = ["s","m","h"]
|
42
|
+
conv.each{ |c|
|
43
|
+
v = time % c
|
44
|
+
time = time / c
|
45
|
+
arr.push v
|
46
|
+
}
|
47
|
+
arr.push time
|
48
|
+
i = unit.size-1
|
49
|
+
arr.reverse.each{ |v|
|
50
|
+
if v == 0
|
51
|
+
i -= 1
|
52
|
+
else
|
53
|
+
break
|
54
|
+
end
|
55
|
+
}
|
56
|
+
while i >= 0
|
57
|
+
s << arr[i].to_s + unit[i]
|
58
|
+
i -= 1
|
59
|
+
end
|
60
|
+
|
61
|
+
s = "0s" if s.length == 0
|
62
|
+
|
63
|
+
s
|
64
|
+
end
|
65
|
+
|
66
|
+
# Parse a size in the format '50 KB'
|
67
|
+
def self.parseSize(size)
|
68
|
+
if size =~ /(\d+(?:\.\d+)?)\s*([^\d]+)*/
|
69
|
+
value = $1.to_f
|
70
|
+
suffix = ($2 ? $2.downcase : nil)
|
71
|
+
|
72
|
+
multiplicand = 1
|
73
|
+
if suffix.nil? || suffix[0] == 'b'
|
74
|
+
multiplicand = 1
|
75
|
+
elsif suffix[0,2] == 'kb'
|
76
|
+
multiplicand = Kb
|
77
|
+
elsif suffix[0,2] == 'mb'
|
78
|
+
multiplicand = Meg
|
79
|
+
elsif suffix[0,2] == 'gb'
|
80
|
+
multiplicand = Gig
|
81
|
+
else
|
82
|
+
raise "Unknown suffix '#{suffix}' for size '#{size}'"
|
83
|
+
end
|
84
|
+
|
85
|
+
value*multiplicand
|
86
|
+
else
|
87
|
+
raise "Malformed size '#{size}'"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module QuartzTorrent
|
2
|
+
class TrackerClient
|
3
|
+
end
|
4
|
+
|
5
|
+
# A tracker client that uses the HTTP protocol. This is the classic BitTorrent tracker protocol.
|
6
|
+
class HttpTrackerClient < TrackerClient
|
7
|
+
def initialize(announceUrl, infoHash, dataLength)
|
8
|
+
super(announceUrl, infoHash, dataLength)
|
9
|
+
@startSent = false
|
10
|
+
@logger = LogManager.getLogger("http_tracker_client")
|
11
|
+
end
|
12
|
+
|
13
|
+
# Request a list of peers from the tracker and return it as a TrackerResponse.
|
14
|
+
# Event, if specified, may be set to :started, :stopped, or :completed.
|
15
|
+
# This is used to notify the tracker that this is the first request,
|
16
|
+
# that we are shutting down, or that we have the full torrent respectively.
|
17
|
+
# Not specifying the event just means this is a regular poll.
|
18
|
+
#def getPeers(event = nil)
|
19
|
+
def request(event = nil)
|
20
|
+
|
21
|
+
uri = URI(@announceUrl)
|
22
|
+
|
23
|
+
dynamicParams = @dynamicRequestParamsBuilder.call
|
24
|
+
|
25
|
+
params = {}
|
26
|
+
params['info_hash'] = CGI.escape(@infoHash)
|
27
|
+
params['peer_id'] = @peerId
|
28
|
+
params['port'] = @port
|
29
|
+
params['uploaded'] = dynamicParams.uploaded.to_s
|
30
|
+
params['downloaded'] = dynamicParams.downloaded.to_s
|
31
|
+
params['left'] = dynamicParams.left.to_s
|
32
|
+
params['compact'] = "1"
|
33
|
+
params['no_peer_id'] = "1"
|
34
|
+
if ! @startSent
|
35
|
+
event = :started
|
36
|
+
@startSent = true
|
37
|
+
end
|
38
|
+
params['event'] = event.to_s if event
|
39
|
+
|
40
|
+
|
41
|
+
@logger.debug "Request parameters: "
|
42
|
+
params.each do |k,v|
|
43
|
+
@logger.debug " #{k}: #{v}"
|
44
|
+
end
|
45
|
+
|
46
|
+
query = ""
|
47
|
+
params.each do |k,v|
|
48
|
+
query << "&" if query.length > 0
|
49
|
+
query << "#{k}=#{v}"
|
50
|
+
end
|
51
|
+
uri.query = query
|
52
|
+
|
53
|
+
res = Net::HTTP.get_response(uri)
|
54
|
+
@logger.debug "Tracker response code: #{res.code}"
|
55
|
+
@logger.debug "Tracker response body: #{res.body}"
|
56
|
+
result = buildTrackerResponse(res)
|
57
|
+
@logger.debug "TrackerResponse: #{result.inspect}"
|
58
|
+
result
|
59
|
+
end
|
60
|
+
|
61
|
+
protected
|
62
|
+
def decodePeers(peersProp)
|
63
|
+
peers = []
|
64
|
+
if peersProp.is_a?(String)
|
65
|
+
# Compact format: 4byte IP followed by 2byte port, in network byte order
|
66
|
+
index = 0
|
67
|
+
while index + 6 <= peersProp.length
|
68
|
+
ip = peersProp[index,4].unpack("CCCC").join('.')
|
69
|
+
port = peersProp[index+4,2].unpack("n").first
|
70
|
+
peers.push TrackerPeer.new(ip, port)
|
71
|
+
index += 6
|
72
|
+
end
|
73
|
+
else
|
74
|
+
# Non-compact format
|
75
|
+
peersProp.each do |peer|
|
76
|
+
ip = peer['ip']
|
77
|
+
port = peer['port']
|
78
|
+
if ip && port
|
79
|
+
peers.push TrackerPeer.new(ip, port)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
#raise "Non-compact peer format not implemented"
|
83
|
+
end
|
84
|
+
peers
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
def buildTrackerResponse(netHttpResponse)
|
89
|
+
error = nil
|
90
|
+
peers = []
|
91
|
+
interval = nil
|
92
|
+
success = netHttpResponse.code.to_i >= 200 && netHttpResponse.code.to_i < 300
|
93
|
+
if success
|
94
|
+
begin
|
95
|
+
decoded = netHttpResponse.body.bdecode
|
96
|
+
rescue
|
97
|
+
error = "Tracker netHttpResponse body was not a valid bencoded string"
|
98
|
+
success = false
|
99
|
+
return self
|
100
|
+
end
|
101
|
+
|
102
|
+
if decoded.has_key? 'peers'
|
103
|
+
peers = decodePeers(decoded['peers'])
|
104
|
+
else
|
105
|
+
error = "Tracker netHttpResponse didn't contain a peers property"
|
106
|
+
success = false
|
107
|
+
end
|
108
|
+
|
109
|
+
if decoded.has_key? 'interval'
|
110
|
+
interval = decoded['interval'].to_i
|
111
|
+
end
|
112
|
+
else
|
113
|
+
error = netHttpResponse.body
|
114
|
+
end
|
115
|
+
|
116
|
+
result = TrackerResponse.new(success, error, peers)
|
117
|
+
result.interval = interval if interval
|
118
|
+
result
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
3
|
+
module QuartzTorrent
|
4
|
+
# Class that implements a sleep for a specified number of seconds that can be interrupted.
|
5
|
+
# When a caller calls sleep, another thread can call wake to wake the sleeper.
|
6
|
+
class InterruptibleSleep
|
7
|
+
def initialize
|
8
|
+
@eventRead, @eventWrite = IO.pipe
|
9
|
+
@eventPending = false
|
10
|
+
@mutex = Mutex.new
|
11
|
+
end
|
12
|
+
|
13
|
+
# Sleep.
|
14
|
+
def sleep(seconds)
|
15
|
+
if IO.select([@eventRead], nil, nil, seconds)
|
16
|
+
@eventRead.read(1)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Wake the sleeper.
|
21
|
+
def wake
|
22
|
+
@mutex.synchronize do
|
23
|
+
@eventWrite.print "X" if ! @eventPending
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'log4r'
|
2
|
+
|
3
|
+
# For some reason the Log4r log level constants (ERROR, etc) are not defined until we
|
4
|
+
# create at least one logger. Here I create one unused one so that the constants exist.
|
5
|
+
Log4r::Logger.new '__init'
|
6
|
+
|
7
|
+
module QuartzTorrent
|
8
|
+
# Class used to control logging.
|
9
|
+
class LogManager
|
10
|
+
|
11
|
+
@@outputter = nil
|
12
|
+
@@defaultLevel = Log4r::ERROR
|
13
|
+
@@maxOldLogs = 10
|
14
|
+
@@maxLogSize = 1048576
|
15
|
+
@@dest = nil
|
16
|
+
|
17
|
+
# Initialize logging based on environment variables. The QUARTZ_TORRENT_LOGFILE variable controls where logging is written,
|
18
|
+
# and should be either a file path, 'stdout' or 'stderr'.
|
19
|
+
def self.initializeFromEnv
|
20
|
+
@@dest = ENV['QUARTZ_TORRENT_LOGFILE']
|
21
|
+
@@defaultLevel = parseLogLevel(ENV['QUARTZ_TORRENT_LOGLEVEL'])
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
# Initialize the log manager. This method expects a block, and the block may call the following methods:
|
26
|
+
#
|
27
|
+
# setLogfile(path)
|
28
|
+
# setDefaultLevel(level)
|
29
|
+
# setMaxOldLogs(num)
|
30
|
+
# setMaxLogSize(size)
|
31
|
+
#
|
32
|
+
# In the above methods, `path` defines where logging is written, and should be either a file path, 'stdout' or 'stderr';
|
33
|
+
# `level` is a logging level as per setLevel, `num` is an integer, and `size` is a value in bytes.
|
34
|
+
def self.setup(&block)
|
35
|
+
self.instance_eval &block
|
36
|
+
|
37
|
+
dest = @@dest
|
38
|
+
if dest
|
39
|
+
if dest.downcase == 'stdout'
|
40
|
+
dest = Log4r::Outputter.stdout
|
41
|
+
elsif dest.downcase == 'stderr'
|
42
|
+
dest = Log4r::Outputter.stderr
|
43
|
+
else
|
44
|
+
dest = Log4r::RollingFileOutputter.new('outputter', {filename: dest, maxsize: @@maxLogSize, max_backups: @@maxOldLogs})
|
45
|
+
end
|
46
|
+
end
|
47
|
+
@@outputter = dest
|
48
|
+
end
|
49
|
+
|
50
|
+
# Set log level for the named logger. The level can be one of
|
51
|
+
# fatal, error, warn, info, or debug as a string or symbol.
|
52
|
+
def self.setLevel(name, level)
|
53
|
+
level = parseLogLevel(level)
|
54
|
+
logger = LogManager.getLogger(name)
|
55
|
+
logger.level = level
|
56
|
+
end
|
57
|
+
|
58
|
+
# Get the logger with the specified name. Currently this returns a log4r Logger.
|
59
|
+
def self.getLogger(name)
|
60
|
+
if ! @@outputter
|
61
|
+
Log4r::Logger.root
|
62
|
+
else
|
63
|
+
logger = Log4r::Logger[name]
|
64
|
+
if ! logger
|
65
|
+
logger = Log4r::Logger.new name
|
66
|
+
logger.level = @@defaultLevel
|
67
|
+
logger.outputters = @@outputter
|
68
|
+
end
|
69
|
+
logger
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
# DSL method used by setup.
|
75
|
+
# Set the logfile.
|
76
|
+
def self.setLogfile(dest)
|
77
|
+
@@dest = dest
|
78
|
+
end
|
79
|
+
# DSL method used by setup.
|
80
|
+
# Set the default log level.
|
81
|
+
def self.setDefaultLevel(level)
|
82
|
+
@@defaultLevel = parseLogLevel(level)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Set number of old log files to keep when rotating.
|
86
|
+
def self.setMaxOldLogs(num)
|
87
|
+
@@maxOldLogs = num
|
88
|
+
end
|
89
|
+
|
90
|
+
# Max size of a single logfile in bytes
|
91
|
+
def self.setMaxLogSize(size)
|
92
|
+
@@maxLogSize = size
|
93
|
+
end
|
94
|
+
|
95
|
+
def self.parseLogLevel(level)
|
96
|
+
if level
|
97
|
+
if level.is_a? Symbol
|
98
|
+
if level == :fatal
|
99
|
+
level = Log4r::FATAL
|
100
|
+
elsif level == :error
|
101
|
+
level = Log4r::ERROR
|
102
|
+
elsif level == :warn
|
103
|
+
level = Log4r::WARN
|
104
|
+
elsif level == :info
|
105
|
+
level = Log4r::INFO
|
106
|
+
elsif level == :debug
|
107
|
+
level = Log4r::DEBUG
|
108
|
+
else
|
109
|
+
level = Log4r::ERROR
|
110
|
+
end
|
111
|
+
else
|
112
|
+
if level.downcase == 'fatal'
|
113
|
+
level = Log4r::FATAL
|
114
|
+
elsif level.downcase == 'error'
|
115
|
+
level = Log4r::ERROR
|
116
|
+
elsif level.downcase == 'warn'
|
117
|
+
level = Log4r::WARN
|
118
|
+
elsif level.downcase == 'info'
|
119
|
+
level = Log4r::INFO
|
120
|
+
elsif level.downcase == 'debug'
|
121
|
+
level = Log4r::DEBUG
|
122
|
+
else
|
123
|
+
level = Log4r::ERROR
|
124
|
+
end
|
125
|
+
end
|
126
|
+
else
|
127
|
+
level = Log4r::ERROR
|
128
|
+
end
|
129
|
+
level
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require 'quartz_torrent/util'
|
2
|
+
require 'uri'
|
3
|
+
require 'base32'
|
4
|
+
|
5
|
+
module QuartzTorrent
|
6
|
+
class MagnetURI
|
7
|
+
@@regex = /magnet:\?(.*)/
|
8
|
+
|
9
|
+
# Create a new MagnetURI object given a magnet URI string.
|
10
|
+
def initialize(str)
|
11
|
+
@params = {}
|
12
|
+
@raw = str
|
13
|
+
|
14
|
+
if str =~ @@regex
|
15
|
+
parseQuery $1
|
16
|
+
else
|
17
|
+
raise "Not a magnet URI"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_reader :raw
|
22
|
+
|
23
|
+
def self.magnetURI?(str)
|
24
|
+
str =~ @@regex
|
25
|
+
end
|
26
|
+
|
27
|
+
# Return the value of the specified key from the magnet URI.
|
28
|
+
def [](key)
|
29
|
+
@params[key]
|
30
|
+
end
|
31
|
+
|
32
|
+
# Return the first Bittorrent info hash found in the magnet URI. The returned
|
33
|
+
# info hash is in binary format.
|
34
|
+
def btInfoHash
|
35
|
+
result = nil
|
36
|
+
@params['xt'].each do |topic|
|
37
|
+
if topic =~ /urn:btih:(.*)/
|
38
|
+
hash = $1
|
39
|
+
if hash.length == 40
|
40
|
+
# Hex-encoded info hash. Convert to binary.
|
41
|
+
result = [hash].pack "H*"
|
42
|
+
else
|
43
|
+
# Base32 encoded
|
44
|
+
result = Base32.decode hash
|
45
|
+
end
|
46
|
+
break
|
47
|
+
end
|
48
|
+
end
|
49
|
+
result
|
50
|
+
end
|
51
|
+
|
52
|
+
# Return the first tracker URL found in the magnet link. Returns nil if the magnet has no tracker info.
|
53
|
+
def tracker
|
54
|
+
tr = @params['tr']
|
55
|
+
if tr
|
56
|
+
tr.first
|
57
|
+
else
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Return the first display name found in the magnet link. Returns nil if the magnet has no display name.
|
63
|
+
def displayName
|
64
|
+
dn = @params['dn']
|
65
|
+
if dn
|
66
|
+
dn.first
|
67
|
+
else
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Create a magnet URI string given the metainfo from a torrent file.
|
73
|
+
def self.encodeFromMetainfo(metainfo)
|
74
|
+
s = "magnet:?xt=urn:btih:"
|
75
|
+
s << metainfo.infoHash.unpack("H*").first
|
76
|
+
s << "&tr="
|
77
|
+
s << metainfo.announce
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
def parseQuery(query)
|
82
|
+
query.split('&').each do |part|
|
83
|
+
if part =~ /(.*)=(.*)/
|
84
|
+
name = $1
|
85
|
+
val = $2
|
86
|
+
name = $1 if name =~ /(.*).\d+$/
|
87
|
+
@params.pushToList name, URI.unescape(val).tr('+',' ')
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|