leecher 0.2.3 → 1.0.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.
Files changed (46) hide show
  1. data/.gitignore +5 -4
  2. data/.travis.yml +2 -0
  3. data/Gemfile +1 -2
  4. data/Gemfile.lock +48 -0
  5. data/README +58 -0
  6. data/Rakefile +5 -7
  7. data/bin/leecher +2 -265
  8. data/bin/leecher-aria2-event-hook +12 -0
  9. data/config/amqp.example.yml +28 -0
  10. data/config/arguments.rb +12 -0
  11. data/config/aria2.example.yml +6 -0
  12. data/config/boot.rb +64 -0
  13. data/config/environment.rb +22 -0
  14. data/config/environments/development.rb +2 -0
  15. data/config/environments/production.rb +5 -0
  16. data/config/environments/test.rb +2 -0
  17. data/config/gheed.example.yml +2 -0
  18. data/config/leecher.example.yml +1 -0
  19. data/config/post-daemonize/readme +5 -0
  20. data/config/pre-daemonize/amqp.rb +6 -0
  21. data/config/pre-daemonize/json.rb +3 -0
  22. data/config/pre-daemonize/readme +12 -0
  23. data/config/pre-daemonize/safely.rb +13 -0
  24. data/leecher.gemspec +13 -4
  25. data/lib/leecher.rb +24 -2
  26. data/lib/leecher/aria2/client.rb +65 -0
  27. data/lib/leecher/client.rb +31 -211
  28. data/lib/leecher/gheed/client.rb +35 -0
  29. data/lib/leecher/metalink_queue.rb +68 -0
  30. data/lib/leecher/version.rb +1 -1
  31. data/libexec/leecher-daemon.rb +34 -0
  32. data/script/console +4 -0
  33. data/script/destroy +4 -0
  34. data/script/generate +4 -0
  35. data/spec/aria2/client_spec.rb +26 -0
  36. data/spec/client_spec.rb +42 -83
  37. data/spec/metalink_queue_spec.rb +25 -0
  38. data/spec/spec.opts +2 -0
  39. data/spec/spec_helper.rb +0 -2
  40. data/tasks/rspec.rake +6 -0
  41. metadata +134 -90
  42. data/config/aria2.conf +0 -15
  43. data/config/client.yml +0 -32
  44. data/lib/leecher/log.rb +0 -91
  45. data/lib/leecher/shellout.rb +0 -27
  46. data/spec/shellout_spec.rb +0 -26
@@ -0,0 +1,12 @@
1
+ # Argument handling for your daemon is configured here.
2
+ #
3
+ # You have access to two variables when this file is
4
+ # parsed. The first is +opts+, which is the object yielded from
5
+ # +OptionParser.new+, the second is +@options+ which is a standard
6
+ # Ruby hash that is later accessible through
7
+ # DaemonKit.arguments.options and can be used in your daemon process.
8
+
9
+ # Here is an example:
10
+ # opts.on('-f', '--foo FOO', 'Set foo') do |foo|
11
+ # @options[:foo] = foo
12
+ # end
@@ -0,0 +1,6 @@
1
+ config: /home/$USER/.leecher/aria2.conf
2
+ bin: /usr/bin/aria2c
3
+ args: [-D, --enable-rpc]
4
+ user: aria2
5
+ password: aria2
6
+ port: 6800
@@ -0,0 +1,64 @@
1
+ # Don't change this file!
2
+ # Configure your daemon in config/environment.rb
3
+
4
+ DAEMON_ROOT = "#{File.expand_path(File.dirname(__FILE__))}/.." unless defined?( DAEMON_ROOT )
5
+
6
+ require "rubygems"
7
+ require "bundler/setup"
8
+
9
+ module DaemonKit
10
+ class << self
11
+ def boot!
12
+ unless booted?
13
+ pick_boot.run
14
+ end
15
+ end
16
+
17
+ def booted?
18
+ defined? DaemonKit::Initializer
19
+ end
20
+
21
+ def pick_boot
22
+ (vendor_kit? ? VendorBoot : GemBoot).new
23
+ end
24
+
25
+ def vendor_kit?
26
+ File.exists?( "#{DAEMON_ROOT}/vendor/daemon-kit" )
27
+ end
28
+ end
29
+
30
+ class Boot
31
+ def run
32
+ load_initializer
33
+ DaemonKit::Initializer.run
34
+ end
35
+ end
36
+
37
+ class VendorBoot < Boot
38
+ def load_initializer
39
+ require "#{DAEMON_ROOT}/vendor/daemon-kit/lib/daemon_kit/initializer"
40
+ end
41
+ end
42
+
43
+ class GemBoot < Boot
44
+ def load_initializer
45
+ begin
46
+ require 'rubygems' unless defined?( ::Gem )
47
+ gem 'daemon-kit'
48
+ require 'daemon_kit/initializer'
49
+ rescue ::Gem::LoadError => e
50
+ msg = <<EOF
51
+
52
+ You are missing the daemon-kit gem. Please install the following gem:
53
+
54
+ sudo gem install daemon-kit
55
+
56
+ EOF
57
+ $stderr.puts msg
58
+ exit 1
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ DaemonKit.boot!
@@ -0,0 +1,22 @@
1
+ # Be sure to restart your daemon when you modify this file
2
+
3
+ # Uncomment below to force your daemon into production mode
4
+ ENV['DAEMON_ENV'] ||= "production"
5
+
6
+ # Boot up
7
+ require File.join(File.dirname(__FILE__), 'boot')
8
+
9
+ # Auto-require default libraries and those for the current Rails environment.
10
+ Bundler.require :default, DaemonKit.env
11
+
12
+ DaemonKit::Initializer.run do |config|
13
+
14
+ # The name of the daemon as reported by process monitoring tools
15
+ config.daemon_name = 'leecher'
16
+
17
+ # Force the daemon to be killed after X seconds from asking it to
18
+ # config.force_kill_wait = 30
19
+
20
+ # Log backraces when a thread/daemon dies (Recommended)
21
+ # config.backtraces = true
22
+ end
@@ -0,0 +1,2 @@
1
+ # This is the same context as the environment.rb file, it is only
2
+ # loaded afterwards and only in the development environment
@@ -0,0 +1,5 @@
1
+ # This is the same context as the environment.rb file, it is only
2
+ # loaded afterwards and only in the production environment
3
+
4
+ # Change the production log level to debug
5
+ #config.log_level = :debug
@@ -0,0 +1,2 @@
1
+ # This is the same context as the environment.rb file, it is only
2
+ # loaded afterwards and only in the test environment
@@ -0,0 +1,2 @@
1
+ username: whoareyou
2
+ password: proveit
@@ -0,0 +1 @@
1
+ work_dir: /home/$USER/.leecher
@@ -0,0 +1,5 @@
1
+ # You can place files in here to be loaded after the code is daemonized.
2
+ #
3
+ # All the files placed here will just be required into the running
4
+ # process. This is the correct place to open any IO objects, establish
5
+ # database connections, etc.
@@ -0,0 +1,6 @@
1
+ begin
2
+ require 'amqp'
3
+ rescue LoadError
4
+ $stderr.puts "Missing amqp gem. Please run 'bundle install'."
5
+ exit 1
6
+ end
@@ -0,0 +1,3 @@
1
+ require "multi_json"
2
+ require "yajl"
3
+ MultiJson.engine = :yajl
@@ -0,0 +1,12 @@
1
+ # You can place files in here to be loaded before the code is daemonized.
2
+ #
3
+ # DaemonKit looks for a file named '<config.daemon_name>.rb' and loads
4
+ # that file first, and inside a DaemonKit::Initializer block. The
5
+ # remaning files then simply required into the running process.
6
+ #
7
+ # These files are mostly useful for operations that should fail blatantly
8
+ # before daemonizing, like loading gems.
9
+ #
10
+ # Be careful not to open any form of IO in here and expecting it to be
11
+ # open inside the running daemon since all IO instances are closed when
12
+ # daemonizing (including STDIN, STDOUT & STDERR).
@@ -0,0 +1,13 @@
1
+ # Safely is responsible for providing exception reporting and the
2
+ # logging of backtraces when your daemon dies unexpectedly. The full
3
+ # documentation for safely can be found at
4
+ # http://github.com/kennethkalmer/safely/wiki
5
+
6
+ # By default Safely will use the daemon-kit's logger to log exceptions,
7
+ # and will store backtraces in the "log" directory.
8
+
9
+ # Comment out to enable Hoptoad support
10
+ # Safely::Strategy::Hoptoad.hoptoad_key = ""
11
+
12
+ # Comment out to use email exceptions
13
+ # Safely::Strategy::Mail.recipient = "your.name@gmail.com"
@@ -19,8 +19,17 @@ Gem::Specification.new do |s|
19
19
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
20
  s.require_paths = ["lib"]
21
21
 
22
- s.add_development_dependency(%q<rspec>, [">=2"])
23
- s.add_runtime_dependency(%q<bunny>)
24
- s.add_runtime_dependency(%q<erubis>)
25
- s.add_runtime_dependency(%q<yajl-ruby>)
22
+ s.add_runtime_dependency "daemon-kit"
23
+ s.add_runtime_dependency "safely"
24
+ s.add_runtime_dependency "multi_json"
25
+ s.add_runtime_dependency "yajl-ruby"
26
+ s.add_runtime_dependency "amqp"
27
+ s.add_runtime_dependency "erubis"
28
+
29
+ s.add_development_dependency "rake"
30
+ s.add_development_dependency "rspec"
31
+
32
+ if ENV["LEECHER_DEV"] && RUBY_VERSION == "1.9.3"
33
+ s.add_development_dependency "debugger"
34
+ end
26
35
  end
@@ -1,5 +1,27 @@
1
1
  module Leecher
2
-
3
- require "leecher/version"
2
+
4
3
  require "leecher/client"
4
+ require "leecher/gheed/client"
5
+ require "leecher/aria2/client"
6
+
7
+ class << self
8
+
9
+ def config
10
+ DaemonKit::Config.load("leecher")
11
+ end
12
+
13
+ def gheed_client
14
+ config = DaemonKit::Config.load("gheed")
15
+ Leecher::Gheed::Client.new(config)
16
+ end
17
+
18
+ def aria2_client
19
+ config = DaemonKit::Config.load("aria2")
20
+ Leecher::Aria2::Client.new(config)
21
+ end
22
+
23
+ def new
24
+ Client.new(config, aria2_client, gheed_client)
25
+ end
26
+ end
5
27
  end
@@ -0,0 +1,65 @@
1
+ module Leecher
2
+ module Aria2
3
+
4
+ require "xmlrpc/client"
5
+
6
+ class UnableToStartAriaError < RuntimeError; end
7
+
8
+ class Client < XMLRPC::Client
9
+
10
+ attr_reader :config
11
+
12
+ def initialize(config)
13
+ @config = config
14
+ super("127.0.0.1", "/rpc", config.port, nil, nil, config.user, config.password)
15
+ end
16
+
17
+ def call(method, *args)
18
+ retried = false
19
+ super("aria2.#{method}", *args)
20
+ rescue Errno::ECONNREFUSED
21
+ start_aria2
22
+ Kernel.sleep(3) # Give it a chance to start.
23
+ retry unless retried
24
+ end
25
+
26
+ def add_uri(uris, options)
27
+ call("addUri", uris, options)
28
+ end
29
+
30
+ def tell_status(gid, keys = nil)
31
+ if keys.nil? or keys.empty?
32
+ call("tellStatus", gid.to_s)
33
+ else
34
+ call("tellStatus", gid.to_s, keys)
35
+ end
36
+ end
37
+
38
+ def make_aria_exec
39
+ args = config.args.dup
40
+ [
41
+ ["--conf-path", config.config],
42
+ ["--on-download-complete", callback_command],
43
+ ["--on-download-error", callback_command],
44
+ ["--on-download-pause", callback_command],
45
+ ["--on-download-start", callback_command],
46
+ ["--on-download-stop", callback_command],
47
+ ].each do |option , value|
48
+ args.push(option + "=" + value)
49
+ end
50
+
51
+ [config.bin, args.join(" ")].join(" ")
52
+ end
53
+
54
+ def start_aria2
55
+ command = make_aria_exec
56
+ IO.popen(command) {}
57
+ raise UnableToStartAriaError unless $?.success?
58
+ end
59
+
60
+ def callback_command
61
+ File.expand_path("../../../bin/leecher-aria2-event-hook", File.dirname(__FILE__))
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,226 +1,46 @@
1
1
  module Leecher
2
-
3
2
  class Client
4
3
 
5
- require "bunny"
6
- require "leecher/shellout"
7
- require "xmlrpc/client"
8
- require "yajl"
9
-
10
- include Leecher::Shellout
11
-
12
- class ShutdownError < RuntimeError; end
13
-
14
- # This is annoying: Bunny's errors don't inherit from a
15
- # Bunny::Error parent, so we have to keep track of em :-(
16
- BunnyErrors =
17
- [
18
- Bunny::ConnectionError,
19
- Bunny::ForcedChannelCloseError,
20
- Bunny::ForcedConnectionCloseError,
21
- Bunny::MessageError,
22
- Bunny::ProtocolError,
23
- Bunny::ServerDownError,
24
- Bunny::UnsubscribeError,
25
- Bunny::AcknowledgementError,
26
- ]
27
-
28
- attr_accessor :username
29
- attr_accessor :password
30
- attr_accessor :metalink_queue_name
31
- attr_accessor :bunny_opts
32
- attr_accessor :aria2_opts
33
- attr_accessor :log
34
- attr_accessor :rpc_server
35
-
36
- def initialize(username,
37
- password,
38
- metalink_queue_name,
39
- bunny_opts,
40
- aria2_opts,
41
- log)
42
- self.username = username
43
- self.password = password
44
- self.metalink_queue_name = metalink_queue_name
45
- self.bunny_opts = bunny_opts
46
- self.aria2_opts = aria2_opts
47
- self.log = log
48
- self.rpc_server = make_rpc_client(aria2_opts)
49
- end
50
-
51
-
52
- def run()
53
- @graceful_shutdown = false
54
- @dont_kill_me = false
55
- [:INT, :TERM].each do |sig|
56
- trap(sig) do
57
- log.info("Caught SIG#{sig}")
58
-
59
- # Already asked to go away - impatient users :-(
60
- if @graceful_shutdown
61
- log.info("I'm tired of catching this so I'm going to (unsafely) go away.")
62
- raise ShutdownError.new("SIG#{sig}")
63
- end
64
-
65
- @graceful_shutdown = true
66
- raise ShutdownError.new("SIG#{sig}") unless @dont_kill_me
67
- end
68
- end
69
-
70
- until @graceful_shutdown
71
- begin
72
- b = make_bunny(self.bunny_opts)
73
- q = get_queue(b, username, metalink_queue_name)
74
- drain_metalink_queue(q)
75
- rescue ShutdownError => e
76
- log.info(["Going away because of", e.message].join(": "))
77
- rescue *BunnyErrors => e
78
- log.warn("Something has gone wrong with our "\
79
- "connection. We're going to try start again.")
80
- e.backtrace.each do |line|
81
- log.debug(line)
82
- end
83
-
84
- # Silently try throw away our existing bunny.
85
- if b && b.status == :connected
86
- b.stop() rescue Exception
87
- end
88
-
89
- log.debug("Will reconnect after 3s")
90
- rescue Exception => e
91
- log.error([e.class.name, e.message].join(": "))
92
- e.backtrace.each do |line|
93
- log.debug(line)
94
- end
95
- end
96
- end
97
- end
98
-
99
- def make_bunny(bunny_opts)
100
- b = Bunny.new(bunny_opts)
101
- b.start()
102
- b.qos()
103
- b
104
- end
4
+ require "fileutils"
5
+ require "multi_json"
6
+ require "leecher/metalink_queue"
105
7
 
106
- def get_queue(bunny, *queue_joinable_name)
107
- bunny.queue(File.join(*queue_joinable_name))
108
- end
109
-
110
- # Continuously get messages (metalinks) off the rabbitmq queue and
111
- # invoke aria2c to download things.
112
- def drain_metalink_queue(q)
113
- log.info("Subscribing to #{q.name.inspect()}")
114
-
115
- next_queue_message(q) do |msg|
116
- @dont_kill_me = true
8
+ attr_reader :config, :aria2_client, :gheed_client
117
9
 
118
- # FIXME: is it possible something can go wrong but not throw an exception?
119
- begin
120
- uris, options = Yajl::Parser.parse(msg)
121
- enqueue_with_aria(uris, options)
122
- true
123
- rescue Exception => e
124
- log.error("Failed to add uri to aria2c: #{e.message}")
125
- false
126
- ensure
127
- @dont_kill_me = false
128
- end
129
- end
10
+ def initialize(config, aria2_client, gheed_client)
11
+ @config = config
12
+ @aria2_client = aria2_client
13
+ @gheed_client = gheed_client
130
14
  end
131
15
 
132
- def enqueue_with_aria(uris, options)
133
- rewrite_aria_dir_opt(options)
134
- log.info("Will call aria2.addUri(#{uris.inspect()}, #{options.inspect()})")
135
- status = call_rpc("aria2.addUri", uris, options)
136
- log.info("Added uri to aria with gid: #{status}")
137
- end
138
16
 
139
- def rewrite_aria_dir_opt(options)
140
- # Attempt to rewrite +dir+ to a relative path
141
- if (dir = options.delete("dir"))
142
- begin
143
- global_opts = call_rpc("aria2.getGlobalOption")
144
- global_dir = global_opts.delete("dir")
145
- options["dir"] = File.join(global_dir, dir)
146
- rescue Exception => e
147
- log.error("Couldn't get aria2 global options")
148
- end
149
- end
17
+ def queue
18
+ @queue ||= MetalinkQueue.new(filename("queue.yml"))
150
19
  end
151
-
152
- def next_queue_message(q, &block)
153
- # +:timeout+: Sometimes it happens that our connection is stale (e.g. DSL
154
- # drops and comes back) but the connection never drops. Rabbitmq
155
- # kicks us off after 15 min. Thus, use a timeout of 15 minutes
156
- # so that, if lightning strikes, worst case is we're 15 minutes
157
- # behind.
158
- # +:ack+: Acks are sent by +queue.ack+, which is sent via the
159
- # subscribe block automatically if the option is turned on.
160
- q.subscribe(:timeout => 900,
161
- :ack => true) do |msg|
162
- payload = msg[:payload]
163
- case payload
164
- when :queue_empty
165
- next
166
- else
167
- if block_given?
168
- yield(payload)
169
- else
170
- payload
171
- end
172
- end
173
- end
174
- end
175
-
176
- def make_rpc_client(aria2_opts)
177
- XMLRPC::Client.new2("http://#{aria2_opts[:user]}:#{aria2_opts[:password]}@127.0.0.1:#{aria2_opts[:port]}/rpc")
20
+
21
+ def filename(relative)
22
+ File.join(@config.work_dir, relative)
178
23
  end
179
24
 
180
- def call_rpc(method, *args)
181
- retried = false
182
- begin
183
- if args.nil? || args.empty?
184
- rpc_server.call(method)
185
- else
186
- rpc_server.call(method, *args)
187
- end
188
- rescue Errno::ECONNREFUSED => e
189
- if retried == true
190
- raise e
191
- else
192
- retried = true
193
- log.debug("It appears aria isn't running. Attempting to start it up by calling: #{make_aria_exec}")
194
- shellout(make_aria_exec, nil, nil, nil)
195
- sleep 10
196
- retry
197
- end
198
- rescue Errno::EPIPE => e
199
- # The RPC client doesn't realise if the server closes the
200
- # connection. So, we retry (which seems to remake the connection?).
201
- if retried == true
202
- raise e
203
- else
204
- retried = true
205
- retry
206
- end
207
- end
25
+ def process_message_on_metalink_queue(msg)
26
+ # URIs will only ever be one (contract with gheed)
27
+ uris, options = MultiJson.decode(msg)
28
+ queue.add_uris(uris)
29
+ gid = aria2_client.add_uri(uris, options)
30
+ queue.set_store_entry(uris.first, :gid => gid.to_i, :state => :added_to_aria2)
208
31
  end
209
32
 
210
- def make_aria_exec
211
- args = aria2_opts[:args].dup
33
+ def state_change(gid)
34
+ response = aria2_client.tell_status(gid)
35
+ uri = response["files"].first["uris"].first["uri"]
36
+ state = response["status"].to_sym
37
+ queue.set_store_entry(uri, :gid => gid, :state => state)
38
+ notify_gheed(uri, state) if [:complete, :error].include?(state)
39
+ end
212
40
 
213
- if aria2_opts.has_key?(:config)
214
- args.push([
215
- "--conf-path",
216
- aria2_opts[:config],
217
- ].join("="))
218
- end
219
-
220
- [
221
- aria2_opts[:bin],
222
- args.join(" "),
223
- ].join(" ")
41
+ def notify_gheed(uri, state)
42
+ metalink, deliveries = gheed_client.state_change(uri, state)
43
+ queue.set_store_entry(metalink["metalink_url"], :state => :completed)
224
44
  end
225
- end # class Client
226
- end # module Leecher
45
+ end
46
+ end