quartz_torrent 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +2 -5
- data/bin/quartztorrent_download_curses +32 -24
- data/bin/quartztorrent_show_info +2 -5
- data/lib/quartz_torrent/alarm.rb +36 -0
- data/lib/quartz_torrent/blockstate.rb +7 -0
- data/lib/quartz_torrent/formatter.rb +10 -1
- data/lib/quartz_torrent/{httptrackerclient.rb → httptrackerdriver.rb} +15 -7
- data/lib/quartz_torrent/magnet.rb +10 -0
- data/lib/quartz_torrent/metainfo.rb +6 -1
- data/lib/quartz_torrent/metainfopiecestate.rb +6 -2
- data/lib/quartz_torrent/peerclient.rb +353 -157
- data/lib/quartz_torrent/peermsgserialization.rb +1 -1
- data/lib/quartz_torrent/ratelimit.rb +6 -0
- data/lib/quartz_torrent/reactor.rb +18 -89
- data/lib/quartz_torrent/semaphore.rb +25 -11
- data/lib/quartz_torrent/timermanager.rb +120 -0
- data/lib/quartz_torrent/torrentqueue.rb +91 -0
- data/lib/quartz_torrent/trackerclient.rb +128 -24
- data/lib/quartz_torrent/udptrackerdriver.rb +95 -0
- data/lib/quartz_torrent/util.rb +1 -1
- metadata +7 -4
- data/lib/quartz_torrent/udptrackerclient.rb +0 -70
@@ -1,8 +1,8 @@
|
|
1
1
|
require "quartz_torrent/log"
|
2
2
|
require "quartz_torrent/metainfo"
|
3
3
|
require "quartz_torrent/udptrackermsg"
|
4
|
-
require "quartz_torrent/
|
5
|
-
require "quartz_torrent/
|
4
|
+
require "quartz_torrent/httptrackerdriver"
|
5
|
+
require "quartz_torrent/udptrackerdriver"
|
6
6
|
require "quartz_torrent/interruptiblesleep"
|
7
7
|
require "quartz_torrent/util"
|
8
8
|
require "net/http"
|
@@ -67,6 +67,10 @@ module QuartzTorrent
|
|
67
67
|
else
|
68
68
|
@left = 0
|
69
69
|
end
|
70
|
+
@port = 6881
|
71
|
+
@peerId = "-QR0001-" # Azureus style
|
72
|
+
@peerId << Process.pid.to_s
|
73
|
+
@peerId = @peerId + "x" * (20-@peerId.length)
|
70
74
|
end
|
71
75
|
# Number of bytes uploaded
|
72
76
|
attr_accessor :uploaded
|
@@ -74,6 +78,8 @@ module QuartzTorrent
|
|
74
78
|
attr_accessor :downloaded
|
75
79
|
# Number of bytes left to download before torrent is completed
|
76
80
|
attr_accessor :left
|
81
|
+
attr_accessor :port
|
82
|
+
attr_accessor :peerId
|
77
83
|
end
|
78
84
|
|
79
85
|
# Represents the response from a tracker request
|
@@ -100,6 +106,24 @@ module QuartzTorrent
|
|
100
106
|
end
|
101
107
|
end
|
102
108
|
|
109
|
+
# Low-level interface to trackers. TrackerClient uses an instance of a subclass of this to talk to
|
110
|
+
# trackers using different protocols.
|
111
|
+
class TrackerDriver
|
112
|
+
def initialize(dataLength = 0)
|
113
|
+
@dynamicRequestParamsBuilder = Proc.new{ TrackerDynamicRequestParams.new(dataLength) }
|
114
|
+
end
|
115
|
+
|
116
|
+
# This should be set to a Proc that when called will return a TrackerDynamicRequestParams object
|
117
|
+
# with up-to-date information.
|
118
|
+
attr_accessor :dynamicRequestParamsBuilder
|
119
|
+
attr_accessor :port
|
120
|
+
attr_accessor :peerId
|
121
|
+
|
122
|
+
def request(event = nil)
|
123
|
+
raise "Implement me"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
103
127
|
# This class represents a connection to a tracker for a specific torrent. It can be used to get
|
104
128
|
# peers for that torrent.
|
105
129
|
class TrackerClient
|
@@ -124,10 +148,30 @@ module QuartzTorrent
|
|
124
148
|
@event = :started
|
125
149
|
@worker = nil
|
126
150
|
@logger = LogManager.getLogger("tracker_client")
|
127
|
-
@
|
151
|
+
@announceUrlList = announceUrl
|
128
152
|
@infoHash = infoHash
|
129
153
|
@peersChangedListeners = []
|
130
|
-
@dynamicRequestParamsBuilder = Proc.new
|
154
|
+
@dynamicRequestParamsBuilder = Proc.new do
|
155
|
+
result = TrackerDynamicRequestParams.new(dataLength)
|
156
|
+
result.port = @port
|
157
|
+
result.peerId = @peerId
|
158
|
+
result
|
159
|
+
end
|
160
|
+
@alarms = nil
|
161
|
+
|
162
|
+
# Convert announceUrl to an array
|
163
|
+
if @announceUrlList.nil?
|
164
|
+
@announceUrlList = []
|
165
|
+
elsif @announceUrlList.is_a? String
|
166
|
+
@announceUrlList = [@announceUrlList]
|
167
|
+
@announceUrlList.compact!
|
168
|
+
else
|
169
|
+
@announceUrlList = @announceUrlList.flatten.sort.uniq
|
170
|
+
end
|
171
|
+
|
172
|
+
raise "AnnounceURL contained no valid trackers" if @announceUrlList.size == 0
|
173
|
+
|
174
|
+
@announceUrlIndex = 0
|
131
175
|
end
|
132
176
|
|
133
177
|
attr_reader :peerId
|
@@ -136,6 +180,10 @@ module QuartzTorrent
|
|
136
180
|
# with up-to-date information.
|
137
181
|
attr_accessor :dynamicRequestParamsBuilder
|
138
182
|
|
183
|
+
# This member can be set to an Alarms object. If it is, this tracker will raise alarms
|
184
|
+
# when it doesn't get a response, and clear them when it does.
|
185
|
+
attr_accessor :alarms
|
186
|
+
|
139
187
|
# Return true if this TrackerClient is started, false otherwise.
|
140
188
|
def started?
|
141
189
|
@started
|
@@ -166,23 +214,35 @@ module QuartzTorrent
|
|
166
214
|
@errors
|
167
215
|
end
|
168
216
|
|
169
|
-
# Create a new TrackerClient using the passed information.
|
170
|
-
# a tracker that talks the protocol specified in the URL.
|
217
|
+
# Create a new TrackerClient using the passed information. The announceUrl may be a string or a list.
|
171
218
|
def self.create(announceUrl, infoHash, dataLength = 0, start = true)
|
219
|
+
result = TrackerClient.new(announceUrl, infoHash, dataLength)
|
220
|
+
result.start if start
|
221
|
+
result
|
222
|
+
end
|
223
|
+
|
224
|
+
# Create a new TrackerClient using the passed Metainfo object.
|
225
|
+
def self.createFromMetainfo(metainfo, start = true)
|
226
|
+
announce = []
|
227
|
+
announce.push metainfo.announce if metainfo.announce
|
228
|
+
announce = announce.concat(metainfo.announceList) if metainfo.announceList
|
229
|
+
create(announce, metainfo.infoHash, metainfo.info.dataLength, start)
|
230
|
+
end
|
231
|
+
|
232
|
+
# Create a TrackerDriver for the specified URL. TrackerDriver is a lower-level interface to the tracker.
|
233
|
+
def self.createDriver(announceUrl, infoHash)
|
172
234
|
result = nil
|
173
235
|
if announceUrl =~ /udp:\/\//
|
174
|
-
result =
|
236
|
+
result = UdpTrackerDriver.new(announceUrl, infoHash)
|
175
237
|
else
|
176
|
-
result =
|
238
|
+
result = HttpTrackerDriver.new(announceUrl, infoHash)
|
177
239
|
end
|
178
|
-
result.start if start
|
179
|
-
|
180
240
|
result
|
181
241
|
end
|
182
242
|
|
183
|
-
# Create a
|
184
|
-
def self.
|
185
|
-
|
243
|
+
# Create a TrackerDriver using the passed Metainfo object. TrackerDriver is a lower-level interface to the tracker.
|
244
|
+
def self.createDriverFromMetainfo(metainfo)
|
245
|
+
TrackerClient.createDriver(metainfo.announce, metainfo.infoHash)
|
186
246
|
end
|
187
247
|
|
188
248
|
# Start the worker thread
|
@@ -198,21 +258,36 @@ module QuartzTorrent
|
|
198
258
|
while ! @stopped
|
199
259
|
begin
|
200
260
|
response = nil
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
261
|
+
|
262
|
+
driver = currentDriver
|
263
|
+
|
264
|
+
if driver
|
265
|
+
begin
|
266
|
+
@logger.debug "Sending request to tracker #{currentAnnounceUrl}"
|
267
|
+
response = driver.request(@event)
|
268
|
+
@event = nil
|
269
|
+
trackerInterval = response.interval
|
270
|
+
rescue
|
271
|
+
addError $!
|
272
|
+
@logger.info "Request failed due to exception: #{$!}"
|
273
|
+
@logger.debug $!.backtrace.join("\n")
|
274
|
+
changeToNextTracker
|
275
|
+
next
|
276
|
+
|
277
|
+
@alarms.raise Alarm.new(:tracker, "Tracker request failed: #{$!}") if @alarms
|
278
|
+
end
|
210
279
|
end
|
211
280
|
|
212
281
|
if response && response.successful?
|
282
|
+
@alarms.clear :tracker if @alarms
|
213
283
|
# Replace the list of peers
|
214
284
|
peersHash = {}
|
215
285
|
@logger.info "Response contained #{response.peers.length} peers"
|
286
|
+
|
287
|
+
if response.peers.length == 0
|
288
|
+
@alarms.raise Alarm.new(:tracker, "Response from tracker contained no peers") if @alarms
|
289
|
+
end
|
290
|
+
|
216
291
|
response.peers.each do |p|
|
217
292
|
peersHash[p] = 1
|
218
293
|
end
|
@@ -223,8 +298,11 @@ module QuartzTorrent
|
|
223
298
|
@peersChangedListeners.each{ |l| l.call }
|
224
299
|
end
|
225
300
|
else
|
226
|
-
@logger.
|
301
|
+
@logger.info "Response was unsuccessful from tracker: #{response.error}"
|
227
302
|
addError response.error if response
|
303
|
+
@alarms.raise Alarm.new(:tracker, "Unsuccessful response from tracker: #{response.error}") if @alarms && response
|
304
|
+
changeToNextTracker
|
305
|
+
next
|
228
306
|
end
|
229
307
|
|
230
308
|
# If we have no interval from the tracker yet, and the last request didn't error out leaving us with no peers,
|
@@ -244,7 +322,8 @@ module QuartzTorrent
|
|
244
322
|
@logger.info "Worker thread shutting down"
|
245
323
|
@logger.info "Sending final update to tracker"
|
246
324
|
begin
|
247
|
-
|
325
|
+
driver = currentDriver
|
326
|
+
driver.request(:stopped) if driver
|
248
327
|
rescue
|
249
328
|
addError $!
|
250
329
|
@logger.debug "Request failed due to exception: #{$!}"
|
@@ -280,6 +359,31 @@ module QuartzTorrent
|
|
280
359
|
def eventValid?(event)
|
281
360
|
event == :started || event == :stopped || event == :completed
|
282
361
|
end
|
362
|
+
|
363
|
+
def currentDriver
|
364
|
+
announceUrl = currentAnnounceUrl
|
365
|
+
return nil if ! announceUrl
|
366
|
+
|
367
|
+
driver = TrackerClient.createDriver announceUrl, @infoHash
|
368
|
+
driver.dynamicRequestParamsBuilder = @dynamicRequestParamsBuilder if driver
|
369
|
+
driver.port = @port
|
370
|
+
driver.peerId = @peerId
|
371
|
+
driver
|
372
|
+
end
|
373
|
+
|
374
|
+
def currentAnnounceUrl
|
375
|
+
return nil if @announceUrlList.size == 0
|
376
|
+
@announceUrlIndex = 0 if @announceUrlIndex > @announceUrlList.size-1
|
377
|
+
|
378
|
+
@announceUrlList[@announceUrlIndex]
|
379
|
+
end
|
380
|
+
|
381
|
+
def changeToNextTracker
|
382
|
+
@announceUrlIndex += 1
|
383
|
+
@logger.info "Changed to next tracker #{currentAnnounceUrl}"
|
384
|
+
sleep 0.5
|
385
|
+
end
|
386
|
+
|
283
387
|
end
|
284
388
|
|
285
389
|
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module QuartzTorrent
|
2
|
+
# A tracker driver that uses the UDP protocol as defined by http://xbtt.sourceforge.net/udp_tracker_protocol.html
|
3
|
+
class UdpTrackerDriver < TrackerDriver
|
4
|
+
# Set UDP receive length to a value that allows up to 100 peers to be returned in an announce.
|
5
|
+
ReceiveLength = 620
|
6
|
+
def initialize(announceUrl, infoHash, timeout = 2)
|
7
|
+
super()
|
8
|
+
@announceUrl = announceUrl
|
9
|
+
@infoHash = infoHash
|
10
|
+
@timeout = timeout
|
11
|
+
@logger = LogManager.getLogger("udp_tracker_client")
|
12
|
+
if @announceUrl =~ /udp:\/\/([^:]+):(\d+)/
|
13
|
+
@host = $1
|
14
|
+
@trackerPort = $2
|
15
|
+
else
|
16
|
+
throw "UDP Tracker announce URL is invalid: #{announceUrl}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def request(event = nil)
|
21
|
+
if event == :started
|
22
|
+
event = UdpTrackerMessage::EventStarted
|
23
|
+
elsif event == :stopped
|
24
|
+
event = UdpTrackerMessage::EventStopped
|
25
|
+
elsif event == :completed
|
26
|
+
event = UdpTrackerMessage::EventCompleted
|
27
|
+
else
|
28
|
+
event = UdpTrackerMessage::EventNone
|
29
|
+
end
|
30
|
+
|
31
|
+
socket = UDPSocket.new
|
32
|
+
result = nil
|
33
|
+
begin
|
34
|
+
socket.connect @host, @trackerPort
|
35
|
+
|
36
|
+
@logger.debug "Sending UDP tracker request to #{@host}:#{@trackerPort}"
|
37
|
+
|
38
|
+
# Send connect request
|
39
|
+
req = UdpTrackerConnectRequest.new
|
40
|
+
socket.send req.serialize, 0
|
41
|
+
resp = UdpTrackerConnectResponse.unserialize(readWithTimeout(socket,ReceiveLength,@timeout))
|
42
|
+
@logger.debug "Connect response: #{resp.inspect}"
|
43
|
+
raise "Invalid connect response: response transaction id is different from the request transaction id" if resp.transactionId != req.transactionId
|
44
|
+
connectionId = resp.connectionId
|
45
|
+
|
46
|
+
dynamicParams = @dynamicRequestParamsBuilder.call
|
47
|
+
|
48
|
+
# Send announce request
|
49
|
+
req = UdpTrackerAnnounceRequest.new(connectionId)
|
50
|
+
req.peerId = dynamicParams.peerId
|
51
|
+
req.infoHash = @infoHash
|
52
|
+
req.downloaded = dynamicParams.downloaded
|
53
|
+
req.left = dynamicParams.left
|
54
|
+
req.uploaded = dynamicParams.uploaded
|
55
|
+
req.event = event
|
56
|
+
#req.port = socket.addr[1]
|
57
|
+
req.port = dynamicParams.port
|
58
|
+
socket.send req.serialize, 0
|
59
|
+
resp = UdpTrackerAnnounceResponse.unserialize(readWithTimeout(socket,ReceiveLength,@timeout))
|
60
|
+
@logger.debug "Announce response: #{resp.inspect}"
|
61
|
+
|
62
|
+
peers = []
|
63
|
+
resp.ips.length.times do |i|
|
64
|
+
ip = resp.ips[i].unpack("CCCC").join('.')
|
65
|
+
port = resp.ports[i].unpack("n").first
|
66
|
+
peers.push TrackerPeer.new ip, port
|
67
|
+
end
|
68
|
+
peers
|
69
|
+
|
70
|
+
result = TrackerResponse.new(true, nil, peers)
|
71
|
+
result.interval = resp.interval if resp.interval
|
72
|
+
rescue
|
73
|
+
result = TrackerResponse.new(false, $!, [])
|
74
|
+
ensure
|
75
|
+
socket.close if ! socket.closed?
|
76
|
+
end
|
77
|
+
result
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
# Throws exception if timeout occurs
|
82
|
+
def readWithTimeout(socket, length, timeout)
|
83
|
+
rc = IO.select([socket], nil, nil, timeout)
|
84
|
+
if ! rc
|
85
|
+
raise "Waiting for response from UDP tracker #{@host}:#{@trackerPort} timed out after #{@timeout} seconds"
|
86
|
+
elsif rc[0].size > 0
|
87
|
+
socket.recvfrom(length)[0]
|
88
|
+
else
|
89
|
+
raise "Error receiving response from UDP tracker #{@host}:#{@trackerPort}"
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
end
|
data/lib/quartz_torrent/util.rb
CHANGED
@@ -71,7 +71,7 @@ module QuartzTorrent
|
|
71
71
|
|
72
72
|
# Log backtraces of all threads currently running. The threads are logged to the
|
73
73
|
# passed io, or if that's nil they are written to the logger named 'util' at error level.
|
74
|
-
def self.logBacktraces(io)
|
74
|
+
def self.logBacktraces(io = nil)
|
75
75
|
logger = nil
|
76
76
|
logger = LogManager.getLogger("util") if ! io
|
77
77
|
isLinux = RUBY_PLATFORM.downcase.include?("linux")
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: quartz_torrent
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2014-02-06 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bencode
|
@@ -135,15 +135,19 @@ extra_rdoc_files: []
|
|
135
135
|
files:
|
136
136
|
- lib/quartz_torrent.rb
|
137
137
|
- lib/quartz_torrent/blockstate.rb
|
138
|
+
- lib/quartz_torrent/httptrackerdriver.rb
|
138
139
|
- lib/quartz_torrent/memprofiler.rb
|
140
|
+
- lib/quartz_torrent/alarm.rb
|
139
141
|
- lib/quartz_torrent/regionmap.rb
|
140
|
-
- lib/quartz_torrent/
|
142
|
+
- lib/quartz_torrent/udptrackerdriver.rb
|
143
|
+
- lib/quartz_torrent/torrentqueue.rb
|
141
144
|
- lib/quartz_torrent/interruptiblesleep.rb
|
142
145
|
- lib/quartz_torrent/extension.rb
|
143
146
|
- lib/quartz_torrent/util.rb
|
144
147
|
- lib/quartz_torrent/log.rb
|
145
148
|
- lib/quartz_torrent/metainfo.rb
|
146
149
|
- lib/quartz_torrent/trackerclient.rb
|
150
|
+
- lib/quartz_torrent/timermanager.rb
|
147
151
|
- lib/quartz_torrent/peermsgserialization.rb
|
148
152
|
- lib/quartz_torrent/semaphore.rb
|
149
153
|
- lib/quartz_torrent/reactor.rb
|
@@ -162,7 +166,6 @@ files:
|
|
162
166
|
- lib/quartz_torrent/peermsg.rb
|
163
167
|
- lib/quartz_torrent/filemanager.rb
|
164
168
|
- lib/quartz_torrent/magnet.rb
|
165
|
-
- lib/quartz_torrent/udptrackerclient.rb
|
166
169
|
- README.md
|
167
170
|
- LICENSE
|
168
171
|
- .yardopts
|
@@ -1,70 +0,0 @@
|
|
1
|
-
module QuartzTorrent
|
2
|
-
# A tracker client that uses the UDP protocol as defined by http://xbtt.sourceforge.net/udp_tracker_protocol.html
|
3
|
-
class UdpTrackerClient < TrackerClient
|
4
|
-
# Set UDP receive length to a value that allows up to 100 peers to be returned in an announce.
|
5
|
-
ReceiveLength = 620
|
6
|
-
def initialize(announceUrl, infoHash, dataLength)
|
7
|
-
super(announceUrl, infoHash, dataLength)
|
8
|
-
@logger = LogManager.getLogger("udp_tracker_client")
|
9
|
-
if @announceUrl =~ /udp:\/\/([^:]+):(\d+)/
|
10
|
-
@host = $1
|
11
|
-
@trackerPort = $2
|
12
|
-
else
|
13
|
-
throw "UDP Tracker announce URL is invalid: #{announceUrl}"
|
14
|
-
end
|
15
|
-
end
|
16
|
-
|
17
|
-
def request(event = nil)
|
18
|
-
if event == :started
|
19
|
-
event = UdpTrackerMessage::EventStarted
|
20
|
-
elsif event == :stopped
|
21
|
-
event = UdpTrackerMessage::EventStopped
|
22
|
-
elsif event == :completed
|
23
|
-
event = UdpTrackerMessage::EventCompleted
|
24
|
-
else
|
25
|
-
event = UdpTrackerMessage::EventNone
|
26
|
-
end
|
27
|
-
|
28
|
-
socket = UDPSocket.new
|
29
|
-
socket.connect @host, @trackerPort
|
30
|
-
|
31
|
-
@logger.debug "Sending UDP tracker request to #{@host}:#{@trackerPort}"
|
32
|
-
|
33
|
-
# Send connect request
|
34
|
-
req = UdpTrackerConnectRequest.new
|
35
|
-
socket.send req.serialize, 0
|
36
|
-
resp = UdpTrackerConnectResponse.unserialize(socket.recvfrom(ReceiveLength)[0])
|
37
|
-
raise "Invalid connect response: response transaction id is different from the request transaction id" if resp.transactionId != req.transactionId
|
38
|
-
connectionId = resp.connectionId
|
39
|
-
|
40
|
-
dynamicParams = @dynamicRequestParamsBuilder.call
|
41
|
-
|
42
|
-
# Send announce request
|
43
|
-
req = UdpTrackerAnnounceRequest.new(connectionId)
|
44
|
-
req.peerId = @peerId
|
45
|
-
req.infoHash = @infoHash
|
46
|
-
req.downloaded = dynamicParams.downloaded
|
47
|
-
req.left = dynamicParams.left
|
48
|
-
req.uploaded = dynamicParams.uploaded
|
49
|
-
req.event = event
|
50
|
-
#req.port = socket.addr[1]
|
51
|
-
req.port = @port
|
52
|
-
socket.send req.serialize, 0
|
53
|
-
resp = UdpTrackerAnnounceResponse.unserialize(socket.recvfrom(ReceiveLength)[0])
|
54
|
-
socket.close
|
55
|
-
|
56
|
-
peers = []
|
57
|
-
resp.ips.length.times do |i|
|
58
|
-
ip = resp.ips[i].unpack("CCCC").join('.')
|
59
|
-
port = resp.ports[i].unpack("n").first
|
60
|
-
peers.push TrackerPeer.new ip, port
|
61
|
-
end
|
62
|
-
peers
|
63
|
-
|
64
|
-
result = TrackerResponse.new(true, nil, peers)
|
65
|
-
result.interval = resp.interval if resp.interval
|
66
|
-
result
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
|
-
end
|