quartz_flow 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,247 @@
1
+ require 'open-uri'
2
+ require 'quartz_torrent.rb'
3
+ require 'fileutils'
4
+ require 'quartz_flow/usagetracker'
5
+ require 'thread'
6
+
7
+ class TorrentManager
8
+ def initialize(peerClient, torrentFileDir, monthlyResetDay)
9
+ @peerClient = peerClient
10
+ @cachedTorrentData = nil
11
+ @cachedAt = nil
12
+ @cacheLifetime = 2
13
+ @torrentFileDir = torrentFileDir
14
+ @peerClientStopped = false
15
+ @usageTracker = UsageTracker.new(monthlyResetDay)
16
+ # Start a thread to keep track of usage.
17
+ startUsageTrackerThread
18
+ end
19
+
20
+ attr_reader :peerClient
21
+
22
+ def torrentData(infoHash = nil)
23
+ if (! @cachedTorrentData || Time.new - @cachedAt > @cacheLifetime) && ! @peerClientStopped
24
+ @cachedTorrentData = @peerClient.torrentData
25
+ @cachedAt = Time.new
26
+ end
27
+
28
+ @cachedTorrentData
29
+ end
30
+
31
+ def stopPeerClient
32
+ @peerClient.stop
33
+ @peerClientStopped = true
34
+ end
35
+
36
+ # Start torrents that already exist in the torrent file directory
37
+ def startExistingTorrents
38
+ Dir.new(@torrentFileDir).each do |e|
39
+ path = @torrentFileDir + File::SEPARATOR + e
40
+ if e =~ /\.torrent$/
41
+ puts "Starting .torrent '#{path}'"
42
+ startTorrentFile(path)
43
+ elsif e =~ /\.magnet$/
44
+ magnet = loadMagnet(path)
45
+ puts "Starting magnet '#{magnet.raw}'"
46
+ startMagnet magnet
47
+ end
48
+ end
49
+ end
50
+
51
+ # Convert torrent data such that:
52
+ # - The TorrentDataDelegate objects are converted to hashes.
53
+ def simplifiedTorrentData
54
+ result = {}
55
+ torrentData.each do |k,d|
56
+ h = d.to_h
57
+ asciiInfoHash = QuartzTorrent::bytesToHex(h[:infoHash])
58
+ h[:infoHash] = asciiInfoHash
59
+ h[:downloadRate] = QuartzTorrent::Formatter.formatSpeed(h[:downloadRate])
60
+ h[:uploadRate] = QuartzTorrent::Formatter.formatSpeed(h[:uploadRate])
61
+ h[:downloadRateDataOnly] = QuartzTorrent::Formatter.formatSpeed(h[:downloadRateDataOnly])
62
+ h[:uploadRateDataOnly] = QuartzTorrent::Formatter.formatSpeed(h[:uploadRateDataOnly])
63
+ h[:dataLength] = QuartzTorrent::Formatter.formatSize(h[:dataLength])
64
+ h[:completedBytes] = QuartzTorrent::Formatter.formatSize(h[:completedBytes])
65
+ # Sort peers
66
+ h[:peers].sort! do |a,b|
67
+ c = (b[:uploadRate].to_i <=> a[:uploadRate].to_i)
68
+ c = (b[:downloadRate].to_i <=> a[:downloadRate].to_i) if c == 0
69
+ c
70
+ end
71
+ # Format peer rates
72
+ h[:peers].each do |p|
73
+ p[:uploadRate] = QuartzTorrent::Formatter.formatSpeed(p[:uploadRate])
74
+ p[:downloadRate] = QuartzTorrent::Formatter.formatSpeed(p[:downloadRate])
75
+ end
76
+ if h[:info]
77
+ h[:info][:files].each do |file|
78
+ file[:length] = QuartzTorrent::Formatter.formatSize(file[:length])
79
+ end
80
+ end
81
+ h[:uploadRateLimit] = QuartzTorrent::Formatter.formatSpeed(h[:uploadRateLimit])
82
+ h[:downloadRateLimit] = QuartzTorrent::Formatter.formatSize(h[:downloadRateLimit])
83
+ h[:bytesUploaded] = QuartzTorrent::Formatter.formatSize(h[:bytesUploaded])
84
+ h[:bytesDownloaded] = QuartzTorrent::Formatter.formatSize(h[:bytesDownloaded])
85
+
86
+ h[:completePieces] = d.completePieceBitfield ? d.completePieceBitfield.countSet : 0
87
+ h[:totalPieces] = d.completePieceBitfield ? d.completePieceBitfield.length : 0
88
+
89
+ result[asciiInfoHash] = h
90
+ end
91
+ result
92
+ end
93
+
94
+ # Download a .torrent file from a specified URL. Return the path to the
95
+ # downloaded .torrent file.
96
+ def downloadTorrentFile(url)
97
+ # open-uri doesn't handle [ and ] properly
98
+ encodedSourcePath = URI.escape(url, /[\[\]]/)
99
+
100
+ path = nil
101
+ open(encodedSourcePath) do |f|
102
+ uriPath = f.base_uri.path
103
+ raise "The file '#{uriPath}' doesn't have the .torrent extension" if uriPath !~ /.torrent$/
104
+ path = @torrentFileDir + File::SEPARATOR + File.basename(uriPath)
105
+ File.open(path, "w"){ |outfile| outfile.write(f.read) }
106
+ end
107
+ path
108
+ end
109
+
110
+ # Store a magnet link in a file in the torrent file directory.
111
+ def storeMagnet(magnet)
112
+ asciiInfoHash = QuartzTorrent::bytesToHex(magnet.btInfoHash)
113
+ path = @torrentFileDir + File::SEPARATOR + asciiInfoHash + ".magnet"
114
+ File.open(path, "w"){ |outfile| outfile.write(magnet.raw) }
115
+ end
116
+
117
+ # Load a magnet link in a file
118
+ def loadMagnet(path)
119
+ raw = nil
120
+ File.open(path, "r"){ |infile| raw = infile.read }
121
+ QuartzTorrent::MagnetURI.new(raw)
122
+ end
123
+
124
+ # Store an uploaded .torrent file in the torrent directory. Return the path to the
125
+ # final .torrent file.
126
+ def storeUploadedTorrentFile(path, name)
127
+ name += ".torrent" if name !~ /\.torrent$/
128
+ dpath = @torrentFileDir + File::SEPARATOR + name
129
+ FileUtils.mv path, dpath
130
+ dpath
131
+ end
132
+
133
+ # Start running the torrent specified by the .torrent file given in path.
134
+ def startTorrentFile(path)
135
+ startTorrent do
136
+ begin
137
+ metainfo = QuartzTorrent::Metainfo.createFromFile(path)
138
+ @peerClient.addTorrentByMetainfo(metainfo)
139
+ rescue BEncode::DecodeError
140
+ # Delete the file
141
+ begin
142
+ FileUtils.rm path
143
+ rescue
144
+ end
145
+ raise $!
146
+ end
147
+ end
148
+ end
149
+
150
+ # Start running the magnet
151
+ def startMagnet(magnet)
152
+ startTorrent do
153
+ @peerClient.addTorrentByMagnetURI(magnet)
154
+ end
155
+ end
156
+
157
+ # Remove the specified torrent. Pass the infoHash as an ascii string, not binary.
158
+ def removeTorrent(infoHash, deleteFiles)
159
+ infoHashBytes = QuartzTorrent::hexToBytes(infoHash)
160
+ @peerClient.removeTorrent infoHashBytes, deleteFiles
161
+
162
+ # Remove torrent from torrent dir
163
+ Dir.new(@torrentFileDir).each do |e|
164
+ if e =~ /\.torrent$/
165
+ path = @torrentFileDir + File::SEPARATOR + e
166
+ metainfo = QuartzTorrent::Metainfo.createFromFile(path)
167
+ if metainfo.infoHash == infoHashBytes
168
+ FileUtils.rm path
169
+ break
170
+ end
171
+ end
172
+ end
173
+
174
+ # Remove torrent settings
175
+ helper = SettingsHelper.new
176
+ helper.deleteForOwner infoHash
177
+
178
+ # Remove magnet file if it exists
179
+ magnetFile = @torrentFileDir + File::SEPARATOR + infoHash + ".magnet"
180
+ FileUtils.rm magnetFile if File.exists?(magnetFile)
181
+ end
182
+
183
+ # Update the torrent settings (upload rate limit, etc) from database values
184
+ def applyTorrentSettings(infoHash)
185
+ asciiInfoHash = QuartzTorrent::bytesToHex(infoHash)
186
+ helper = SettingsHelper.new
187
+
188
+ # Set limits based on per-torrent settings if they exist, otherwise to default global limits if they exist.
189
+ uploadRateLimit = to_i(helper.get(:uploadRateLimit, :unfiltered, asciiInfoHash))
190
+ uploadRateLimit = to_i(helper.get(:defaultUploadRateLimit, :unfiltered)) if ! uploadRateLimit
191
+
192
+ downloadRateLimit = to_i(helper.get(:downloadRateLimit, :unfiltered, asciiInfoHash))
193
+ downloadRateLimit = to_i(helper.get(:defaultDownloadRateLimit, :unfiltered)) if ! downloadRateLimit
194
+
195
+ ratio = helper.get(:ratio, :filter, asciiInfoHash)
196
+ ratio = helper.get(:defaultRatio, :filter) if ! ratio
197
+
198
+ @peerClient.setUploadRateLimit infoHash, uploadRateLimit
199
+ @peerClient.setDownloadRateLimit infoHash, downloadRateLimit
200
+ @peerClient.setUploadRatio infoHash, ratio
201
+ end
202
+
203
+ # Get the usage for the current period of the specified type.
204
+ # periodType should be one of :daily or :monthly.
205
+ def currentPeriodUsage(periodType)
206
+ @usageTracker.currentUsage(periodType).value
207
+ end
208
+
209
+ private
210
+ # Helper for starting torrents. Expects a block that when called will add a torrent to the
211
+ # @peerClient, and return the infoHash.
212
+ def startTorrent
213
+ raise "Torrent client is shutting down" if @peerClientStopped
214
+ infoHash = yield
215
+
216
+ applyTorrentSettings infoHash
217
+ end
218
+
219
+ def to_i(val)
220
+ val = val.to_i if val
221
+ val
222
+ end
223
+
224
+ # Start a thread to keep track of usage.
225
+ def startUsageTrackerThread
226
+ @usageTrackerThread = Thread.new do
227
+ QuartzTorrent.initThread("torrent_usage_tracking")
228
+
229
+ Thread.current[:stopped] = false
230
+
231
+ while ! Thread.current[:stopped]
232
+ begin
233
+ sleep 4
234
+ torrentData = @peerClient.torrentData
235
+ usage = 0
236
+ torrentData.each do |k,v|
237
+ usage += v.bytesUploaded + v.bytesDownloaded
238
+ end
239
+ @usageTracker.update(usage)
240
+ rescue
241
+ puts "Error in usage tracking thread: #{$!}"
242
+ puts $!.backtrace.join "\n"
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,335 @@
1
+ require 'quartz_flow/model'
2
+
3
+ class Bucket
4
+ def initialize(label = nil, criteriaData = nil, value = nil)
5
+ @label = label
6
+ @criteriaData = criteriaData
7
+ @value = value
8
+ @absoluteUsageAtStartOfBucket = nil
9
+ end
10
+ attr_accessor :label
11
+ # Data used by BucketChangeCriteria to determine when we need a new bucket.
12
+ attr_accessor :criteriaData
13
+ # At the time this bucket was created, the value of the absolute usage for all time
14
+ attr_accessor :absoluteUsageAtStartOfBucket
15
+ # The amount of usage for this bucket alone
16
+ attr_accessor :value
17
+
18
+ def toHash
19
+ {"label" => @label, "absoluteUsageAtStartOfBucket" => @absoluteUsageAtStartOfBucket, "criteriaData" => @criteriaData, "value" => @value}
20
+ end
21
+
22
+ def fromHash(hash)
23
+ @label = hash["label"]
24
+ @absoluteUsageAtStartOfBucket = hash["absoluteUsageAtStartOfBucket"]
25
+ @criteriaData = hash["criteriaData"]
26
+ @value = hash["value"]
27
+ end
28
+
29
+ def fromModel(bucket)
30
+ @label = bucket.label
31
+ @absoluteUsageAtStartOfBucket = bucket.absoluteUsage
32
+ @criteriaData = bucket.criteriaData
33
+ @value = bucket.value
34
+ end
35
+
36
+ def toModel
37
+ model = UsageBucket.new
38
+ model.attributes = {
39
+ :label => @label,
40
+ :absoluteUsage => @absoluteUsageAtStartOfBucket,
41
+ :criteriaData => @criteriaData,
42
+ :value => @value
43
+ }
44
+ model
45
+ end
46
+ end
47
+
48
+ class BucketChangeCriteria
49
+ # Is it now time for a new bucket?
50
+ def newBucket?(currentBucket)
51
+ false
52
+ end
53
+
54
+ # Make a new bucket and return it.
55
+ def newBucket
56
+ innerNewBucket
57
+ end
58
+
59
+ def innerNewBucket
60
+ raise "Implement me!"
61
+ end
62
+ end
63
+
64
+ class PeriodicBuckets
65
+ def initialize(criteria, maxBuckets = nil)
66
+ setBucketChangeCriteria(criteria)
67
+ @buckets = []
68
+ @maxBuckets = maxBuckets
69
+ @maxBuckets = 1 if @maxBuckets && @maxBuckets < 1
70
+ end
71
+
72
+ # Set the criteria that determines when the current bucket is full
73
+ # and we should make a new empty bucket the current bucket, and that is used
74
+ # to set the label for the new bucket.
75
+ def setBucketChangeCriteria(criteria)
76
+ @bucketChangeCriteria = criteria
77
+ end
78
+
79
+ def update(absoluteUsage = nil)
80
+ if @buckets.size == 0
81
+ prev = nil
82
+ @buckets.push @bucketChangeCriteria.newBucket
83
+ setAbsoluteUsage(prev, @buckets.last, absoluteUsage) if absoluteUsage
84
+ else
85
+ prev = @buckets.last
86
+ # Time for a new bucket?
87
+ if @bucketChangeCriteria.newBucket?(@buckets.last)
88
+ @buckets.push @bucketChangeCriteria.newBucket
89
+ setAbsoluteUsage(prev, @buckets.last, absoluteUsage) if absoluteUsage
90
+ end
91
+ @buckets.shift if @maxBuckets && @buckets.size > @maxBuckets
92
+ end
93
+
94
+ setValue(@buckets.last, absoluteUsage) if absoluteUsage
95
+ end
96
+
97
+ def current(absoluteUsage = nil)
98
+ @buckets.last
99
+ end
100
+
101
+ def all
102
+ @buckets
103
+ end
104
+
105
+ def toHash
106
+ array = []
107
+ @buckets.each do |b|
108
+ array.push b.toHash
109
+ end
110
+ { "buckets" => array }
111
+ end
112
+
113
+ def fromHash(hash)
114
+ @buckets = []
115
+ hash["buckets"].each do |b|
116
+ bucket = Bucket.new(nil, nil, nil)
117
+ bucket.fromHash b
118
+ @buckets.push bucket
119
+ end
120
+ end
121
+
122
+ def fromModel(type)
123
+ @buckets = []
124
+ buckets = UsageBucket.all(:type => type, :order => [:index.asc])
125
+ buckets.each do |model|
126
+ bucket = Bucket.new(nil, nil, nil)
127
+ bucket.fromModel model
128
+ @buckets.push bucket
129
+ end
130
+ end
131
+
132
+ def toModel(type)
133
+ UsageBucket.all(:type => type).destroy!
134
+ index = 0
135
+ @buckets.each do |b|
136
+ model = b.toModel
137
+ model.type = type
138
+ model.index = index
139
+ index += 1
140
+ model.save
141
+ end
142
+ end
143
+
144
+ private
145
+ def setAbsoluteUsage(previousBucket, newBucket, absoluteUsage)
146
+ if previousBucket
147
+ newBucket.absoluteUsageAtStartOfBucket = previousBucket.absoluteUsageAtStartOfBucket + previousBucket.value
148
+ else
149
+ newBucket.absoluteUsageAtStartOfBucket = absoluteUsage
150
+ end
151
+ end
152
+ def setValue(newBucket, absoluteUsage)
153
+ newBucket.value =
154
+ absoluteUsage -
155
+ newBucket.absoluteUsageAtStartOfBucket
156
+ end
157
+ end
158
+
159
+ class DailyBucketChangeCriteria < BucketChangeCriteria
160
+ def newBucket?(currentBucket)
161
+ now = Time.new
162
+ currentBucket.criteriaData.day != now.day
163
+ end
164
+
165
+ def newBucket
166
+ now = Time.new
167
+ Bucket.new(now.strftime("%b %e"), now, 0)
168
+ end
169
+
170
+ def criteriaData
171
+ Time.new
172
+ end
173
+ end
174
+
175
+ class MonthlyBucketChangeCriteria < BucketChangeCriteria
176
+ def initialize(resetDay)
177
+ @resetDay = resetDay
178
+ end
179
+
180
+ def newBucket?(currentBucket)
181
+ Time.new > currentBucket.criteriaData
182
+ end
183
+
184
+ def newBucket
185
+ now = Time.new
186
+ # Set the bucket's criteriaData to the date after which we need a new bucket.
187
+ data = criteriaData
188
+ Bucket.new(now.strftime("%b %Y"), data, 0)
189
+ end
190
+
191
+ def criteriaData
192
+ now = Time.new
193
+ nextMonth = now.mon % 12 + 1
194
+ Time.local(now.year, nextMonth, @resetDay)
195
+ end
196
+ end
197
+
198
+
199
+ # For testing
200
+ class MinuteBucketChangeCriteria < BucketChangeCriteria
201
+ def newBucket?(currentBucket)
202
+ now = Time.new
203
+ currentBucket.criteriaData.min != now.min
204
+ end
205
+
206
+ def newBucket
207
+ now = Time.new
208
+ Bucket.new(now.strftime("%H:%M"), now, 0)
209
+ end
210
+ end
211
+
212
+ class UsageTracker
213
+ def initialize(monthlyResetDay)
214
+ @buckets = {}
215
+ @buckets[:daily] = PeriodicBuckets.new(DailyBucketChangeCriteria.new,31)
216
+ #@buckets[:minute] = PeriodicBuckets.new(MinuteBucketChangeCriteria.new,3)
217
+ @buckets[:monthly] = PeriodicBuckets.new(MonthlyBucketChangeCriteria.new(monthlyResetDay),2)
218
+ @usageForAllTimeAdjustment = 0
219
+ loadBucketsFromDatastore
220
+ end
221
+
222
+ # Update the UsageTracker with more usage. The value passed
223
+ # should be the usage since the torrentflow session was created.
224
+ # If a datastore is not used, then this means stopping and starting the session
225
+ # will cause UsageTracking to only track usage for the session. However
226
+ # if Mongo is used then the usage can be saved and persisted between sessions
227
+ # and internally the value passed here is added to the value loaded from Mongo.
228
+ def update(usageForAllTime)
229
+ usageForAllTime += @usageForAllTimeAdjustment
230
+ @buckets.each do |k,buckets|
231
+ buckets.update usageForAllTime
232
+ end
233
+ saveBucketsToDatastore
234
+ end
235
+
236
+ # This method returns the usage in the current bucket for the specified
237
+ # period type (:daily or :monthly). The usage is accurate as of the last
238
+ # time update() was called.
239
+ # The returned value is a single Bucket object.
240
+ def currentUsage(periodType)
241
+ getBuckets(periodType).current
242
+ end
243
+
244
+ # Returns the usage as of the last time update() was called.
245
+ # This method returns all the tracked usage for the specified
246
+ # period type (:daily or :monthly). The usage is accurate as of the last
247
+ # time update() was called.
248
+ # The returned value is an array of Bucket objects.
249
+ def allUsage(periodType)
250
+ getBuckets(periodType).all
251
+ end
252
+
253
+ private
254
+ def getBuckets(type)
255
+ buckets = @buckets[type]
256
+ raise "Unsupported periodType #{periodType.to_s}" if ! buckets
257
+ buckets
258
+ end
259
+
260
+ def saveBucketsToDatastore
261
+ @buckets[:daily].toModel(:daily)
262
+ @buckets[:monthly].toModel(:monthly)
263
+ =begin
264
+ if @mongoDb
265
+ dailyCollection = @mongoDb.collection("daily_usage")
266
+ monthlyCollection = @mongoDb.collection("monthly_usage")
267
+ # Remove all previous documents
268
+ dailyCollection.remove
269
+ monthlyCollection.remove
270
+
271
+ dailyCollection.insert @buckets[:daily].toHash
272
+ monthlyCollection.insert @buckets[:monthly].toHash
273
+ end
274
+ =end
275
+ end
276
+
277
+ def loadBucketsFromDatastore
278
+ @buckets[:daily].fromModel(:daily)
279
+ @buckets[:monthly].fromModel(:monthly)
280
+
281
+ # If we are loading from datastore it means that the absolute usage returned from the current torrent session will not
282
+ # contain the usage that we previously tracked, so we must add the old tracked value to what the torrent
283
+ # session reports.
284
+ if @buckets[:daily].current
285
+ @usageForAllTimeAdjustment = @buckets[:daily].current.absoluteUsageAtStartOfBucket + @buckets[:daily].current.value
286
+ end
287
+
288
+ =begin
289
+ if @mongoDb
290
+ $logger.info "Loading usage from Mongo."
291
+ dailyCollection = @mongoDb.collection("daily_usage")
292
+ monthlyCollection = @mongoDb.collection("monthly_usage")
293
+
294
+ arr = dailyCollection.find_one
295
+ @buckets[:daily].fromHash arr if arr
296
+ arr = monthlyCollection.find_one
297
+ @buckets[:monthly].fromHash arr if arr
298
+
299
+ # If we are loading from Mongo it means that the absolute usage returned from the torrentflow session will not
300
+ # contain the usage that we previously tracked, so we must add the old tracked value to what the torrentflow
301
+ # session reports.
302
+ if @buckets[:daily].current
303
+ @usageForAllTimeAdjustment = @buckets[:daily].current.absoluteUsageAtStartOfBucket + @buckets[:daily].current.value
304
+ $logger.info "Absolute usage at start of current daily bucket: " + @buckets[:daily].current.absoluteUsageAtStartOfBucket.to_s
305
+ $logger.info "Usage in current daily bucket: " + @buckets[:daily].current.value.to_s
306
+ $logger.info "Usage for all time adjustment: " + @usageForAllTimeAdjustment.to_s
307
+ else
308
+ $logger.info "No usage loaded in Mongo (empty collection)."
309
+ end
310
+ else
311
+ $logger.info "Not loading usage from Mongo."
312
+ end
313
+ =end
314
+ end
315
+ end
316
+
317
+ =begin
318
+ # Testing
319
+ tracker = UsageTracker.new
320
+
321
+ abs = 200
322
+ while true
323
+ tracker.update(abs)
324
+
325
+ puts
326
+ puts "Usage for all time: #{abs}"
327
+ puts "Buckets:"
328
+ tracker.allUsage(:minute).each do |b|
329
+ puts " #{b.label}: #{b.value}"
330
+ end
331
+ abs += 10
332
+ sleep 10
333
+ end
334
+
335
+ =end
@@ -0,0 +1,124 @@
1
+ require 'quartz_torrent/formatter.rb'
2
+
3
+ module QuartzTorrent
4
+
5
+ class Metainfo::FileInfo
6
+ def to_h
7
+ { path: @path, length: @length }
8
+ end
9
+ end
10
+
11
+ class Metainfo::Info
12
+ def to_h
13
+ result = {}
14
+
15
+ result[:name] = @name
16
+ result[:pieceLen] = @pieceLen
17
+ result[:files] = @files.collect{ |e| e.to_h }
18
+
19
+ result
20
+ end
21
+ end
22
+
23
+ class TrackerPeer
24
+ def to_h
25
+ { ip: @ip, port: @port }
26
+ end
27
+ end
28
+
29
+ class Peer
30
+ def to_h
31
+ result = {}
32
+
33
+ result[:trackerPeer] = @trackerPeer.to_h
34
+ result[:amChoked] = @amChoked
35
+ result[:amInterested] = @amInterested
36
+ result[:peerChoked] = @peerChoked
37
+ result[:peerInterested] = @peerInterested
38
+ result[:firstEstablishTime] = @firstEstablishTime
39
+ result[:maxRequestedBlocks] = @maxRequestedBlocks
40
+ result[:state] = @state
41
+ result[:isUs] = @isUs
42
+ result[:uploadRate] = @uploadRate
43
+ result[:downloadRate] = @downloadRate
44
+ if @bitfield
45
+ result[:pctComplete] = "%.2f" % (100.0 * @bitfield.countSet / @bitfield.length)
46
+ else
47
+ result[:pctComplete] = 0
48
+ end
49
+
50
+ result
51
+ end
52
+ end
53
+
54
+ class TorrentDataDelegate
55
+ # Convert to a hash. Also flattens some of the data into new fields.
56
+ def to_h
57
+ result = {}
58
+
59
+ ## Extra fields added by this method:
60
+ # Length of the torrent
61
+ result[:dataLength] = @info ? @info.dataLength : 0
62
+ # Percent complete
63
+ pct = withCurrentAndTotalBytes{ |cur, total| (cur.to_f / total.to_f * 100.0).round 1 }
64
+ result[:percentComplete] = pct
65
+ # Time left
66
+ secondsLeft = withCurrentAndTotalBytes do |cur, total|
67
+ if @downloadRateDataOnly && @downloadRateDataOnly > 0
68
+ (total.to_f - cur.to_f) / @downloadRateDataOnly
69
+ else
70
+ 0
71
+ end
72
+ end
73
+ result[:timeLeft] = Formatter.formatTime(secondsLeft)
74
+
75
+ ## Regular fields
76
+ result[:info] = @info ? @info.to_h : nil
77
+ result[:infoHash] = @infoHash
78
+ result[:recommendedName] = @recommendedName
79
+ result[:downloadRate] = @downloadRate
80
+ result[:uploadRate] = @uploadRate
81
+ result[:downloadRateDataOnly] = @downloadRateDataOnly
82
+ result[:uploadRateDataOnly] = @uploadRateDataOnly
83
+ result[:completedBytes] = @completedBytes
84
+ result[:peers] = @peers.collect{ |p| p.to_h }
85
+ result[:state] = @state
86
+ #result[:completePieceBitfield] = @completePieceBitfield
87
+ result[:metainfoLength] = @metainfoLength
88
+ result[:metainfoCompletedLength] = @metainfoCompletedLength
89
+ result[:paused] = @paused
90
+ result[:uploadRateLimit] = @uploadRateLimit
91
+ result[:downloadRateLimit] = @downloadRateLimit
92
+ result[:ratio] = @ratio
93
+ result[:bytesUploaded] = @bytesUploaded
94
+ result[:bytesDownloaded] = @bytesDownloaded
95
+
96
+ result
97
+ end
98
+
99
+ private
100
+ def withCurrentAndTotalBytes
101
+ if @info
102
+ yield @completedBytes, @info.dataLength
103
+ elsif @state == :downloading_metainfo && @metainfoCompletedLength && @metainfoLength
104
+ yield @metainfoCompletedLength, @metainfoLength
105
+ else
106
+ 0
107
+ end
108
+ end
109
+
110
+
111
+ def calcEstimatedTime(torrent)
112
+ # Time left = amount_left / download_rate
113
+ # = total_size * (1-progress) / download_rate
114
+ if torrentHandle.has_metadata && torrentHandle.status.download_rate.to_f > 0
115
+ secondsLeft = torrentHandle.info.total_size.to_f * (1 - torrentHandle.status.progress.to_f) / torrentHandle.status.download_rate.to_f
116
+ Formatter.formatTime(secondsLeft)
117
+ else
118
+ "unknown"
119
+ end
120
+ end
121
+
122
+ end
123
+ end
124
+