leecher 0.0.1

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.
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in leecher.gemspec
4
+ gemspec
@@ -0,0 +1,10 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require "rspec/core/rake_task"
5
+ desc "Run specs"
6
+ RSpec::Core::RakeTask.new do |t|
7
+ t.rspec_opts = %w(-fs --color)
8
+ # t.ruby_opts = %w(-w)
9
+ end
10
+
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module Leecher
4
+ class Cli
5
+
6
+ require "optparse"
7
+ require "yaml"
8
+ require "erubis"
9
+ require "leecher/log"
10
+ require "leecher/client"
11
+
12
+
13
+ RC_OK = 0
14
+ RC_USAGE = 1
15
+ RC_CONF_MISSING = 2
16
+ RC_CATASTROPHE = 99
17
+
18
+
19
+ def initialize()
20
+ @config_fn = File.join(ENV["HOME"], ".leecher.yml")
21
+ @foreground = false
22
+ end
23
+
24
+
25
+ def parse_opts(argv)
26
+ optparser = OptionParser.new() do |o|
27
+ o.banner = "leecher [options]"
28
+
29
+ o.separator("")
30
+ o.separator("options:")
31
+
32
+ o.on("--config=FILE",
33
+ "File containing settings",
34
+ "Default: #{@config_fn}") do |v|
35
+ @config_fn = v
36
+ end
37
+
38
+ o.on("-f", "--[no-]foreground",
39
+ "Run as a loop in a daemon?",
40
+ "Default #{@foreground}") do |v|
41
+ @foreground = v
42
+ end
43
+
44
+ o.on("--getting-started",
45
+ "Run a tutorial which will give you a config") do
46
+ do_getting_started()
47
+ Kernel.exit!(RC_OK)
48
+ end
49
+
50
+ o.separator("")
51
+ o.on("-h", "--help", "You're reading it :-)") do
52
+ puts o
53
+ Kernel.exit!(RC_USAGE)
54
+ end
55
+ end
56
+
57
+ optparser.parse(argv)
58
+ self
59
+ end
60
+
61
+ def do_getting_started()
62
+ log.info("We'll ask you a few questions then write you a config in #{@config_fn}")
63
+ log.info(".. you can change where your config lives with -c/--config")
64
+
65
+ STDOUT.puts("\n\n")
66
+ config = Hash.new()
67
+ [
68
+ [:username, "your username on the website?", :required],
69
+ [:password, ".. and the password for that user?", :password],
70
+ [:log_fn, "filename to log to:", :disable],
71
+ [:host, "where does the amqp queue live?", "nzb.trim.za.net"],
72
+ [:aria2_bin, "which aria2 must I use?", %x{which aria2c}.strip()],
73
+ ].each do |setting, question, default|
74
+ config[setting] = get_user_input(question, default)
75
+ end
76
+
77
+ erb = Erubis::Eruby.new(File.read(File.join(File.dirname(__FILE__),
78
+ "../config/client.yml")))
79
+ File.open(@config_fn, "w") do |f|
80
+ f.puts(erb.result(config))
81
+ end
82
+
83
+ STDOUT.puts("\n\n")
84
+ log.info("Looking good, now take us for a real spin!")
85
+ end
86
+
87
+ def get_user_input(question, default)
88
+ default_text = case default
89
+ when :required, :password; nil
90
+ when :disable; "blank to disable"
91
+ else; "default: #{default}"
92
+ end
93
+
94
+ response = ask([
95
+ question,
96
+ default_text ? "(#{default_text})" : nil,
97
+ ].compact.join(" "),
98
+ default == :password)
99
+
100
+ if response.empty?
101
+ response = case default
102
+ when :required, :password
103
+ log.error("That option was required! Try again :-)")
104
+ get_user_input(question, default)
105
+ when :disable
106
+ nil
107
+ else
108
+ default
109
+ end
110
+ end
111
+
112
+ return response
113
+ end
114
+
115
+ def ask(question, hide_input = false)
116
+ STDOUT.write(question + " ")
117
+ system("stty -echo") if hide_input
118
+ response = STDIN.gets().strip()
119
+ if hide_input
120
+ STDOUT.puts()
121
+ system("stty echo")
122
+ end
123
+ response
124
+ end
125
+
126
+ def run()
127
+ config = load_config(@config_fn)
128
+ unless @foreground
129
+ log.info("Fading into the ether ..")
130
+ make_daemon(config[:daemon])
131
+ end
132
+ client = Leecher::Client.new(config[:username],
133
+ config[:password],
134
+ config[:metalink_queue_name],
135
+ config[:bunny_opts],
136
+ config[:aria2_opts],
137
+ @log)
138
+ client.run()
139
+ Kernel.exit(RC_OK)
140
+ end
141
+
142
+ def log
143
+ @log ||= Leecher::Log.new()
144
+ end
145
+
146
+ def load_config(config_fn)
147
+ config = YAML.load_file(config_fn)
148
+ if (log_conf = config[:log])
149
+ @log = Leecher::Log.new(config[:log][:level],
150
+ config[:log][:filename])
151
+ end
152
+ config
153
+ rescue Errno::ENOENT
154
+ log.error("No such config file #{config_fn.inspect()}")
155
+ log.info("New user? Try #{Leecher::Log::ANSI_GREEN}"\
156
+ "--getting-started#{Leecher::Log::ANSI_NORMAL}")
157
+ Kernel.exit!(RC_CONF_MISSING)
158
+ end
159
+
160
+ def make_daemon(config)
161
+ raise log.fatal("First fork failed") if (pid = Kernel.fork()) == -1
162
+ Kernel.exit!(RC_OK) unless pid.nil?
163
+ Process.setsid()
164
+ Dir.chdir(config[:chdir]) if config.has_key? :chdir
165
+ File.umask(config[:umask]) if config.has_key? :umask
166
+ [
167
+ [STDIN, :stdin],
168
+ [STDOUT, :stdout],
169
+ [STDERR, :stderr],
170
+ ].each do |io, key|
171
+ if config.has_key? key
172
+ io.reopen(*config[key])
173
+ else
174
+ io.reopen("/dev/null")
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ Leecher::Cli.new().parse_opts(ARGV).run()
@@ -0,0 +1,29 @@
1
+ :username: <%= username %>
2
+ :password: <%= password %>
3
+ :metalink_queue_name: metalink
4
+ :log:
5
+ :level: :info
6
+ :filename: <%= log_fn %>
7
+ :bunny_opts:
8
+ :host: <%= host %>
9
+ :port: 5672
10
+ :vhost: /
11
+ :user: <%= username %>
12
+ :pass: <%= password %>
13
+ :ssl: false
14
+ :verify_ssl: false
15
+ :logfile: false
16
+ :logfile: false
17
+ :frame_max: 131072
18
+ :channel_max: 0
19
+ :heartbeat: 300
20
+ :connect_timeout: 5
21
+ :aria2_opts:
22
+ :bin: <%= aria2_bin %>
23
+ :args: [-x5, -V, --follow-metalink=mem]
24
+ :daemon:
25
+ :chdir: nil
26
+ :umask: 0000
27
+ :stdin: nil
28
+ :stdout: nil
29
+ :stderr: nil
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "leecher/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "leecher"
7
+ s.version = Leecher::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Marc Bowes"]
10
+ s.email = ["marcbowes+leecher@gmail.com"]
11
+ s.homepage = ""
12
+ s.summary = %q{Download files off an AMQP queue}
13
+ s.description = %q{A server populates a queue, this client downloads those files}
14
+
15
+ s.rubyforge_project = "leecher"
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20
+ s.require_paths = ["lib"]
21
+
22
+ s.add_development_dependency(%q<rspec>, [">=2"])
23
+ s.add_runtime_dependency(%q<bunny>)
24
+ s.add_runtime_dependency(%q<erubis>)
25
+ end
@@ -0,0 +1,5 @@
1
+ module Leecher
2
+
3
+ require "leecher/version"
4
+ require "leecher/client"
5
+ end
@@ -0,0 +1,125 @@
1
+ module Leecher
2
+
3
+ class Client
4
+
5
+ require "bunny"
6
+ require "leecher/shellout"
7
+
8
+ include Leecher::Shellout
9
+
10
+ class ShutdownError < RuntimeError; end
11
+
12
+ attr_accessor :username
13
+ attr_accessor :password
14
+ attr_accessor :metalink_queue_name
15
+ attr_accessor :bunny_opts
16
+ attr_accessor :aria2_opts
17
+ attr_accessor :log
18
+
19
+ def initialize(username,
20
+ password,
21
+ metalink_queue_name,
22
+ bunny_opts,
23
+ aria2_opts,
24
+ log)
25
+ self.username = username
26
+ self.password = password
27
+ self.metalink_queue_name = metalink_queue_name
28
+ self.bunny_opts = bunny_opts
29
+ self.aria2_opts = aria2_opts
30
+ self.log = log
31
+ end
32
+
33
+
34
+ def run()
35
+ @bunny = make_bunny(self.bunny_opts)
36
+ @bunny.start()
37
+
38
+ @graceful_shutdown = false
39
+ @dont_kill_me = false
40
+ [:INT, :TERM].each do |sig|
41
+ trap(sig) do
42
+ @graceful_shutdown = true
43
+ log.info("Caught SIG{sig}")
44
+ raise ShutdownError.new("SIG#{sig}") unless @dont_kill_me
45
+ end
46
+ end
47
+
48
+ q = get_queue(@bunny, username, metalink_queue_name)
49
+ until @graceful_shutdown
50
+ begin
51
+ drain_metalink_queue(q)
52
+ rescue ShutdownError => e
53
+ log.info(["Going away because of", e.message].join(": "))
54
+ rescue Exception => e
55
+ log.error([e.class.name, e.message].join(": "))
56
+ e.backtrace.each do |line|
57
+ log.debug(line)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ def make_bunny(bunny_opts)
64
+ Bunny.new(bunny_opts)
65
+ end
66
+
67
+ def get_queue(bunny, *queue_joinable_name)
68
+ bunny.queue(File.join(*queue_joinable_name))
69
+ end
70
+
71
+ def drain_metalink_queue(q)
72
+ next_queue_message(q) do |msg|
73
+ @dont_kill_me = true
74
+
75
+ aria_exec = make_aria_exec(URI.parse(msg))
76
+ log.info("Will exec #{aria_exec.inspect()}")
77
+ stdout_rd, stdout_wr = IO.pipe()
78
+ stderr_rd, stderr_wr = IO.pipe()
79
+ rc = shellout(aria_exec, nil, stdout_wr, stderr_wr)
80
+ stdout_wr.close()
81
+ stderr_wr.close()
82
+
83
+ if rc == 0
84
+ log.info("aria2c exited happily")
85
+ return true
86
+ else
87
+ log.error("aria2c exited unhappily (rc = #{rc})")
88
+ stdout_rd.each_line do |line|
89
+ log.error(line)
90
+ end
91
+ stderr_rd.each_line do |line|
92
+ log.debug(line)
93
+ end
94
+ return false
95
+ end
96
+
97
+ @dont_kill_me = false
98
+ end
99
+ end
100
+
101
+ def next_queue_message(q, &block)
102
+ q.subscribe() do |msg|
103
+ payload = msg[:payload]
104
+ case payload
105
+ when :queue_empty
106
+ next
107
+ else
108
+ if block_given?
109
+ yield(payload)
110
+ else
111
+ payload
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def make_aria_exec(uri)
118
+ [
119
+ aria2_opts[:bin],
120
+ aria2_opts[:args].join(" "),
121
+ uri.to_s(),
122
+ ].join(" ")
123
+ end
124
+ end # class Client
125
+ end # module Leecher
@@ -0,0 +1,89 @@
1
+ module Leecher
2
+
3
+ class Log
4
+
5
+ require "time"
6
+
7
+ LEVELS = {
8
+ :debug => 0,
9
+ :warn => 1,
10
+ :info => 2,
11
+ :error => 3,
12
+ :fatal => 4,
13
+ }
14
+
15
+ attr_accessor :log_fn
16
+ attr_reader :level
17
+
18
+ def initialize(level = :debug,
19
+ log_fn = nil)
20
+ self.level = level
21
+ self.log_fn = log_fn
22
+ open_log_fd()
23
+ end
24
+
25
+ def open_log_fd
26
+ @log_fd.close() if @log_fd and not @log_fd.closed?
27
+ if self.log_fn
28
+ @log_fd = File.open(self.log_fn, "a")
29
+ end
30
+ end
31
+
32
+ def close_log_fd
33
+ if @log_fd and not @log_fd.closed?
34
+ @log_fd.flush()
35
+ @log_fd.close()
36
+ end
37
+ end
38
+
39
+ LEVELS.each_key do |level|
40
+ define_method(level) do |message|
41
+ write_to_log(level, message)
42
+ end
43
+ end
44
+
45
+ def level=(level)
46
+ @level = level
47
+ @_enum_level = LEVELS[level]
48
+ end
49
+
50
+ def write_to_log(level, message)
51
+ if LEVELS[level] >= @_enum_level
52
+ do_write_to_log(level, message)
53
+ else
54
+ # Toss it
55
+ end
56
+ end
57
+
58
+ def do_write_to_log(level, message)
59
+ iso8601_timestr = Time.now.iso8601()
60
+ log_for_humans(iso8601_timestr, level, message)
61
+
62
+ if @log_fd and not @log_fd.closed?
63
+ @log_fd.puts("%s %s %s" % [iso8601_timestr, level.to_s.upcase, message])
64
+ end
65
+ end
66
+
67
+ # Map the log level (LOG_LEVEL_*) to an array containing
68
+ # [human_name:String, colour:String(ANSI escape sequence)]
69
+ ANSI_RED = "\033[0;31m"
70
+ ANSI_RED_INVERTED = "\033[7;31m"
71
+ ANSI_BROWN = "\033[0;33m"
72
+ ANSI_MAGENTA = "\033[0;35m"
73
+ ANSI_GREEN = "\033[0;32m"
74
+ ANSI_BOLD_WHITE = "\033[0;37m"
75
+ ANSI_NORMAL = "\033[0m"
76
+ HUMAN_LOG_LEVELS = {
77
+ :fatal => ["FATAL", ANSI_RED_INVERTED],
78
+ :error => ["ERROR", ANSI_RED],
79
+ :info => ["INFO", ANSI_GREEN],
80
+ :warn => ["WARN", ANSI_MAGENTA],
81
+ :debug => ["DEBUG", ANSI_BROWN],
82
+ }
83
+ def log_for_humans(iso8601_timestr, log_level, log_msg)
84
+ level_str, level_colour = HUMAN_LOG_LEVELS[log_level]
85
+ puts("#{ANSI_BOLD_WHITE}#{iso8601_timestr}#{ANSI_NORMAL} "\
86
+ "#{level_colour}#{"%5.5s" % level_str}#{ANSI_NORMAL} #{log_msg}")
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,27 @@
1
+ module Leecher
2
+ module Shellout
3
+
4
+ # FIXME: probably want to implement timeouts here..
5
+ def shellout(cmd, stdin = nil, stdout = nil, stderr = nil)
6
+ child_pid, child_status = nil
7
+ child_pid = Kernel.fork()
8
+
9
+ if child_pid
10
+ # Parent process executes this
11
+ child_status = (Process.waitpid2(child_pid)[1]).to_i >> 8
12
+ else
13
+ Process.setsid()
14
+ STDIN.reopen(stdin || "/dev/null")
15
+ STDOUT.reopen(stdout || "/dev/null")
16
+ STDERR.reopen(stderr || "/dev/null")
17
+ 3.upto(256) { |fd| IO.new(fd).close rescue nil }
18
+
19
+ Kernel.exec(cmd)
20
+ # Never reached.
21
+ Kernel.exit!
22
+ end
23
+
24
+ child_status
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,3 @@
1
+ module Leecher
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,74 @@
1
+ require File.dirname(__FILE__) + "/spec_helper"
2
+
3
+ require "lib/leecher/client"
4
+
5
+ describe Leecher::Client do
6
+
7
+ before do
8
+ mock_log = double("log")
9
+ %w(debug warn info error fatal).each do |log_level|
10
+ mock_log.should_receive(log_level).any_number_of_times
11
+ end
12
+ @c = Leecher::Client.new("username",
13
+ "password",
14
+ "metalink",
15
+ double("bunny_opts"),
16
+ {
17
+ :bin => "/usr/bin/aria2c",
18
+ :args => ["-V"],
19
+ },
20
+ mock_log)
21
+ end
22
+
23
+
24
+ it "should initialize" do
25
+ @c.username.should == "username"
26
+ @c.password.should == "password"
27
+ @c.metalink_queue_name.should == "metalink"
28
+ end
29
+
30
+ describe "#next_queue_message" do
31
+
32
+ it "should only return when something is on the queue" do
33
+ bunny = double("bunny")
34
+ @c.stub(:make_bunny) { bunny }
35
+ q = double("q")
36
+ q.should_receive(:subscribe).
37
+ and_yield({:payload => :queue_empty}).
38
+ and_yield({:payload => "message"})
39
+ @c.next_queue_message(q).should == "message"
40
+ end
41
+ end
42
+
43
+ describe "#drain_metalink_queue" do
44
+
45
+ it "should reject URIs that are not URIs" do
46
+ @c.stub(:next_queue_message).
47
+ and_yield(nil)
48
+ lambda { @c.drain_metalink_queue(double("q")) }.
49
+ should raise_error(URI::InvalidURIError)
50
+ end
51
+
52
+ it "should shellout to aria2" do
53
+ @c.stub(:next_queue_message).
54
+ and_yield("http://some/url")
55
+ @c.should_receive(:shellout).
56
+ with("/usr/bin/aria2c -V http://some/url",
57
+ anything(), anything(), anything()).
58
+ and_return(0)
59
+ @c.drain_metalink_queue(double("q")).should be_true
60
+ end
61
+
62
+ it "should return according to the rc" do
63
+ @c.stub(:next_queue_message).
64
+ and_yield("http://some/url")
65
+ @c.stub(:shellout).
66
+ and_return(0)
67
+ q = double("q")
68
+ @c.drain_metalink_queue(q).should be_true
69
+ @c.stub(:shellout).
70
+ and_return(1)
71
+ @c.drain_metalink_queue(q).should be_false
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,2 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__) + "../lib")
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: leecher
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Marc Bowes
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-04-22 00:00:00 +02:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: rspec
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 7
30
+ segments:
31
+ - 2
32
+ version: "2"
33
+ type: :development
34
+ version_requirements: *id001
35
+ - !ruby/object:Gem::Dependency
36
+ name: bunny
37
+ prerelease: false
38
+ requirement: &id002 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ hash: 3
44
+ segments:
45
+ - 0
46
+ version: "0"
47
+ type: :runtime
48
+ version_requirements: *id002
49
+ - !ruby/object:Gem::Dependency
50
+ name: erubis
51
+ prerelease: false
52
+ requirement: &id003 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ hash: 3
58
+ segments:
59
+ - 0
60
+ version: "0"
61
+ type: :runtime
62
+ version_requirements: *id003
63
+ description: A server populates a queue, this client downloads those files
64
+ email:
65
+ - marcbowes+leecher@gmail.com
66
+ executables:
67
+ - leecher
68
+ extensions: []
69
+
70
+ extra_rdoc_files: []
71
+
72
+ files:
73
+ - .gitignore
74
+ - Gemfile
75
+ - Rakefile
76
+ - bin/leecher
77
+ - config/client.yml
78
+ - leecher.gemspec
79
+ - lib/leecher.rb
80
+ - lib/leecher/client.rb
81
+ - lib/leecher/log.rb
82
+ - lib/leecher/shellout.rb
83
+ - lib/leecher/version.rb
84
+ - spec/client_spec.rb
85
+ - spec/spec_helper.rb
86
+ has_rdoc: true
87
+ homepage: ""
88
+ licenses: []
89
+
90
+ post_install_message:
91
+ rdoc_options: []
92
+
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ none: false
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ hash: 3
101
+ segments:
102
+ - 0
103
+ version: "0"
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ hash: 3
110
+ segments:
111
+ - 0
112
+ version: "0"
113
+ requirements: []
114
+
115
+ rubyforge_project: leecher
116
+ rubygems_version: 1.6.2
117
+ signing_key:
118
+ specification_version: 3
119
+ summary: Download files off an AMQP queue
120
+ test_files: []
121
+