tootsie 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,48 @@
1
+ require 's3'
2
+ require 'sqs'
3
+
4
+ module Tootsie
5
+
6
+ class Application
7
+
8
+ def initialize(options = {})
9
+ @@instance = self
10
+ @environment = options[:environment] || :development
11
+ @logger = options[:logger] || Logger.new($stderr)
12
+ @configuration = Configuration.new
13
+ end
14
+
15
+ def configure!
16
+ @configuration.load_from_file(File.join(Dir.pwd, "config/#{@environment}.yml"))
17
+ @queue = Tootsie::SqsQueue.new(@configuration.sqs_queue_name, sqs_service)
18
+ @task_manager = TaskManager.new(@queue)
19
+ end
20
+
21
+ def s3_service
22
+ return @s3_service ||= ::S3::Service.new(
23
+ :access_key_id => @configuration.aws_access_key_id,
24
+ :secret_access_key => @configuration.aws_secret_access_key)
25
+ end
26
+
27
+ def sqs_service
28
+ return @sqs_service ||= ::Sqs::Service.new(
29
+ :access_key_id => @configuration.aws_access_key_id,
30
+ :secret_access_key => @configuration.aws_secret_access_key)
31
+ end
32
+
33
+ class << self
34
+ def get
35
+ @@instance
36
+ end
37
+ end
38
+
39
+ attr_accessor :environment
40
+
41
+ attr_reader :configuration
42
+ attr_reader :task_manager
43
+ attr_reader :queue
44
+ attr_reader :logger
45
+
46
+ end
47
+
48
+ end
@@ -0,0 +1,12 @@
1
+ module Tootsie
2
+
3
+ class Client
4
+
5
+ # TODO: Implement client access control.
6
+ def self.generate_signature(k)
7
+ 'signature'
8
+ end
9
+
10
+ end
11
+
12
+ end
@@ -0,0 +1,58 @@
1
+ module Tootsie
2
+
3
+ class CommandExecutionFailed < StandardError; end
4
+
5
+ class CommandRunner
6
+
7
+ def initialize(command_line, options = {})
8
+ @options = options.symbolize_keys
9
+ @options.assert_valid_keys(:ignore_exit_code, :output_encoding)
10
+ @command_line = command_line
11
+ @logger = Application.get.logger
12
+ end
13
+
14
+ def run(params = {}, &block)
15
+ command_line = @command_line
16
+ if params.any?
17
+ params = params.with_indifferent_access
18
+ command_line = command_line.gsub(/(^|\s):(\w+)/) do
19
+ pre, key, all = $1, $2, $~[0]
20
+ if params.include?(key)
21
+ value = params[key]
22
+ value = "'#{value}'" if value =~ /\s/
23
+ "#{pre}#{value}"
24
+ else
25
+ all
26
+ end
27
+ end
28
+ end
29
+ command_line = "#{command_line} 2>&1"
30
+
31
+ @logger.info("Running command: #{command_line}") if @logger.info?
32
+ IO.popen(command_line, "r:#{@options[:output_encoding] || 'utf-8'}") do |output|
33
+ output.each_line do |line|
34
+ @logger.info("[Command output] #{line.strip}") if @logger.info?
35
+ yield line if block_given?
36
+ end
37
+ end
38
+ status = $?
39
+ if status.exited?
40
+ if status.exitstatus != 0
41
+ if @options[:ignore_exit_code]
42
+ return false
43
+ else
44
+ raise CommandExecutionFailed, "Command failed with exit code #{status.exitstatus}: #{command_line}"
45
+ end
46
+ end
47
+ elsif status.stopped?
48
+ raise CommandExecutionFailed, "Command stopped unexpectedly with signal #{status.stopsig}: #{command_line}"
49
+ elsif status.signaled?
50
+ raise CommandExecutionFailed, "Command died unexpectedly by signal #{status.termsig}: #{command_line}"
51
+ else
52
+ raise CommandExecutionFailed, "Command died unexpectedly: #{command_line}"
53
+ end
54
+ true
55
+ end
56
+
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ module Tootsie
2
+
3
+ class Configuration
4
+
5
+ def initialize
6
+ @ffmpeg_thread_count = 1
7
+ @sqs_queue_name = 'tootsie'
8
+ end
9
+
10
+ def load_from_file(file_name)
11
+ config = (YAML.load(File.read(file_name)) || {}).with_indifferent_access
12
+ [:aws_access_key_id, :aws_secret_access_key, :ffmpeg_thread_count,
13
+ :sqs_queue_name].each do |key|
14
+ if config.include?(key)
15
+ value = config[key]
16
+ value = $1.to_i if value =~ /\A\s*(\d+)\s*\z/
17
+ instance_variable_set("@#{key}", value)
18
+ end
19
+ end
20
+ end
21
+
22
+ attr_accessor :aws_access_key_id
23
+ attr_accessor :aws_secret_access_key
24
+ attr_accessor :ffmpeg_thread_count
25
+ attr_accessor :sqs_queue_name
26
+
27
+ end
28
+
29
+ end
@@ -0,0 +1,282 @@
1
+ require "logger"
2
+ require "fileutils"
3
+ require 'optparse'
4
+
5
+ module Tootsie
6
+
7
+ class DaemonError < Exception; end
8
+ class DaemonAlreadyRunning < DaemonError; end
9
+ class DaemonNotRunning < DaemonError; end
10
+ class DaemonTerminationFailed < DaemonError; end
11
+ class DaemonNotConfigured < DaemonError; end
12
+ class DaemonStartFailed < DaemonError; end
13
+
14
+ # Daemon controller class that encapsulates a running daemon and a remote interface to it.
15
+ class Daemon
16
+
17
+ # Initializes the daemon controller.
18
+ def initialize(options = {})
19
+ @root = options[:root] || Dir.pwd
20
+ @pid_file = options[:pid_file]
21
+ @logger = options[:logger] || Logger.new('/dev/null')
22
+ @on_spawn = nil
23
+ end
24
+
25
+ # Specifies a block to execute to run the actual daemon. Each call overrides the previous one.
26
+ def on_spawn(&block)
27
+ @on_spawn = block
28
+ end
29
+
30
+ # Specifies a block to execute to termiantion. Each call overrides the previous one.
31
+ def on_terminate(&block)
32
+ @on_terminate = block
33
+ end
34
+
35
+ # Control the daemon through command-line arguments.
36
+ def control(args, title = nil)
37
+ $stderr.sync = true
38
+ title ||= File.basename($0)
39
+ command = args.shift
40
+ control_with_command(command, args, title)
41
+ end
42
+
43
+ # Control the daemon through a specific command.
44
+ def control_with_command(command, args, title = nil)
45
+ case command
46
+ when "start"
47
+ $stderr << "Starting #{title}: "
48
+ handle_errors do
49
+ start
50
+ $stderr << "started\n"
51
+ end
52
+ when "stop"
53
+ $stderr << "Stopping #{title}: "
54
+ options = {}
55
+ handle_errors do
56
+ stop({:signal => "TERM"}.merge(options))
57
+ $stderr << "stopped\n"
58
+ end
59
+ when "restart"
60
+ $stderr << "Restarting #{title}: "
61
+ handle_errors do
62
+ restart
63
+ $stderr << "restarted\n"
64
+ end
65
+ when "status"
66
+ if running?
67
+ $stderr << "#{title} is running\n"
68
+ else
69
+ $stderr << "#{title} is not running\n"
70
+ end
71
+ else
72
+ if command
73
+ $stderr << "#{File.basename($0)}: Invalid command #{command}\n"
74
+ else
75
+ puts "Usage: #{File.basename($0)} [start | stop | restart | status]"
76
+ end
77
+ end
78
+ end
79
+
80
+ # Starts daemon.
81
+ def start
82
+ raise DaemonNotConfigured, "Daemon not configured" unless @on_spawn
83
+ FileUtils.mkdir_p(File.dirname(@pid_file)) rescue nil
84
+ pid = self.pid
85
+ if pid
86
+ if pid_running?(pid)
87
+ raise DaemonAlreadyRunning, "Process is already running with pid #{pid}"
88
+ end
89
+ end
90
+ File.delete(@pid_file) rescue nil
91
+ child_pid = Process.fork
92
+ if child_pid
93
+ sleep(1)
94
+ unless running?
95
+ pid = self.pid
96
+ if pid == child_pid
97
+ raise DaemonStartFailed, "Daemon started, but failed prematurely"
98
+ else
99
+ raise DaemonStartFailed, "Daemon failed to start for some unknown reason"
100
+ end
101
+ end
102
+ return
103
+ end
104
+ class << logger
105
+ def format_message(severity, timestamp, progname, msg)
106
+ "[#{timestamp}] #{msg}\n"
107
+ end
108
+ end
109
+ logger.info("Starting")
110
+ begin
111
+ Process.setsid
112
+ 0.upto(255) do |n|
113
+ File.for_fd(n, "r").close rescue nil
114
+ end
115
+ File.umask(27)
116
+ Dir.chdir(@root)
117
+ $stdin = File.open("/dev/null", File::RDWR)
118
+ $stdout = File.open("/dev/null", File::RDWR)
119
+ $stderr = File.open("/dev/null", File::RDWR)
120
+ @pid = Process.pid
121
+ File.open(@pid_file, "w") do |file|
122
+ file << Process.pid
123
+ end
124
+ Signal.trap("HUP") do
125
+ logger.debug("Ignoring SIGHUP")
126
+ end
127
+ Signal.trap("TERM") do
128
+ if $$ == @pid
129
+ logger.info("Terminating (#{$$})")
130
+ @on_terminate.call if @on_terminate
131
+ File.delete(@pid_file) rescue nil
132
+ else
133
+ # Was sent to a child
134
+ end
135
+ exit(0)
136
+ end
137
+ @on_spawn.call
138
+ exit(0)
139
+ rescue SystemExit
140
+ # Do nothing
141
+ rescue Exception => e
142
+ message = "#{e.message}\n"
143
+ message << e.backtrace.map { |line| "\tfrom #{line}\n" }.join
144
+ logger.error(message)
145
+ exit(1)
146
+ ensure
147
+ logger.close
148
+ end
149
+ end
150
+
151
+ # Stops daemon.
152
+ def stop(options = {})
153
+ stopped = false
154
+ found = false
155
+ pid = self.pid
156
+ if pid
157
+ # Send TERM to process
158
+ begin
159
+ Process.kill(options[:signal] || "TERM", pid)
160
+ rescue Errno::ESRCH
161
+ stopped = true
162
+ rescue Exception => e
163
+ raise DaemonTerminationFailed, "Could not stop process #{pid}: #{e.message}"
164
+ end
165
+ unless stopped
166
+ # Process was signaled, now wait for it to die
167
+ found = true
168
+ 30.times do
169
+ begin
170
+ if not pid_running?(pid)
171
+ stopped = true
172
+ break
173
+ end
174
+ sleep(1)
175
+ rescue Exception => e
176
+ raise DaemonTerminationFailed, "Could not stop process #{pid}: #{e.message}"
177
+ end
178
+ end
179
+ if found and not stopped
180
+ # Process still running after wait, force kill and wait
181
+ begin
182
+ Process.kill("KILL", pid)
183
+ rescue Errno::ESRCH
184
+ stopped = true
185
+ end
186
+ 30.times do
187
+ begin
188
+ if not pid_running?(pid)
189
+ stopped = true
190
+ break
191
+ end
192
+ sleep(1)
193
+ rescue Exception => e
194
+ raise DaemonTerminationFailed, "Could not stop process #{pid}: #{e.message}"
195
+ end
196
+ end
197
+ if not stopped
198
+ raise DaemonTerminationFailed, "Timeout attempting to stop process #{pid}"
199
+ end
200
+ end
201
+ end
202
+ end
203
+ unless found
204
+ raise DaemonNotRunning, "Process is not running"
205
+ end
206
+ end
207
+
208
+ # Restarts daemon.
209
+ def restart
210
+ if running?
211
+ begin
212
+ stop
213
+ rescue DaemonNotRunning
214
+ # Ignore
215
+ end
216
+ end
217
+ start
218
+ end
219
+
220
+ # Is the daemon running?
221
+ def running?
222
+ !pid.nil?
223
+ end
224
+
225
+ # Returns the daemon pid.
226
+ def pid
227
+ pid = nil
228
+ maybe_pid = File.read(@pid_file) rescue nil
229
+ if maybe_pid =~ /([0-9]+)/
230
+ maybe_pid = $1.to_i
231
+ begin
232
+ Process.kill(0, maybe_pid)
233
+ rescue Errno::ESRCH
234
+ else
235
+ pid = maybe_pid
236
+ end
237
+ end
238
+ pid
239
+ end
240
+
241
+ # Signals the daemon.
242
+ def signal(signal)
243
+ pid = self.pid
244
+ if pid
245
+ Process.kill(signal, pid)
246
+ else
247
+ raise DaemonNotRunning, "Process is not running"
248
+ end
249
+ end
250
+
251
+ attr_reader :root
252
+ attr_reader :pid_file
253
+ attr_reader :logger
254
+
255
+ private
256
+
257
+ def pid_running?(pid)
258
+ begin
259
+ Process.kill(0, pid)
260
+ rescue Errno::ESRCH
261
+ return false
262
+ end
263
+ return true
264
+ end
265
+
266
+ def handle_errors(&block)
267
+ begin
268
+ yield
269
+ rescue DaemonError => e
270
+ $stderr << "#{e.message}\n"
271
+ if e.is_a?(DaemonAlreadyRunning) or e.is_a?(DaemonNotRunning)
272
+ exit_code = 0
273
+ else
274
+ exit_code = 1
275
+ end
276
+ exit(exit_code)
277
+ end
278
+ end
279
+
280
+ end
281
+
282
+ end
@@ -0,0 +1,132 @@
1
+ module Tootsie
2
+
3
+ class FfmpegAdapter
4
+
5
+ def initialize(options = {})
6
+ @logger = Application.get.logger
7
+ @ffmpeg_binary = 'ffmpeg'
8
+ @ffmpeg_arguments = {}
9
+ @ffmpeg_arguments['threads'] = (options[:thread_count] || 1)
10
+ @ffmpeg_arguments['v'] = 1
11
+ if false
12
+ # TODO: Only in newer FFmpeg versions
13
+ @ffmpeg_arguments['xerror'] = true
14
+ @ffmpeg_arguments['loglevel'] = 'verbose'
15
+ end
16
+ @ffmpeg_arguments['y'] = true
17
+ end
18
+
19
+ # Transcode a file by taking an input file and writing an output file.
20
+ def transcode(input_filename, output_filename, options = {})
21
+ arguments = @ffmpeg_arguments.dup
22
+ if options[:audio_codec].to_s == 'none'
23
+ arguments['an'] = true
24
+ else
25
+ case options[:audio_codec].try(:to_s)
26
+ when 'aac'
27
+ arguments['acodec'] = 'libfaac'
28
+ when String
29
+ arguments['acodec'] = options[:audio_codec]
30
+ end
31
+ arguments['ar'] = options[:audio_sample_rate] if options[:audio_sample_rate]
32
+ arguments['ab'] = options[:audio_bitrate] if options[:audio_bitrate]
33
+ end
34
+ if options[:video_codec].to_s == 'none'
35
+ arguments['vn'] = true
36
+ else
37
+ case options[:video_codec].try(:to_s)
38
+ when 'h264'
39
+ arguments['vcodec'] = 'libx264'
40
+ arguments['vpre'] = ['medium', 'main'] # TODO: Allow override
41
+ arguments['crf'] = 15 # TODO: Allow override
42
+ arguments['threads'] = 0
43
+ when String
44
+ arguments['vcodec'] = options[:video_codec]
45
+ end
46
+ arguments['b'] = options[:video_bitrate] if options[:video_bitrate]
47
+ arguments['r'] = options[:video_frame_rate] if options[:video_frame_rate]
48
+ arguments['s'] = "#{options[:width]}x#{options[:height]}" if options[:width] or options[:height]
49
+ arguments['sameq'] = true
50
+ end
51
+ arguments['f'] = options[:format] if options[:format]
52
+
53
+ progress, expected_duration = @progress, nil
54
+ result_width, result_height = nil
55
+ run_ffmpeg(input_filename, output_filename, arguments) do |line|
56
+ if progress
57
+ case line
58
+ when /^\s*Duration: (\d+):(\d+):(\d+)\./
59
+ unless expected_duration
60
+ hours, minutes, seconds = $1.to_i, $2.to_i, $3.to_i
61
+ expected_duration = seconds + minutes * 60 + hours * 60 * 60
62
+ end
63
+ when /^frame=.* time=(\d+)\./
64
+ if expected_duration
65
+ elapsed_time = $1.to_i
66
+ end
67
+ when /Stream.*Video: .*, (\d+)x(\d+)\s/
68
+ unless result_width and result_height
69
+ result_width, result_height = $1.to_i, $2.to_i
70
+ end
71
+ end
72
+ progress.call(elapsed_time, expected_duration) if elapsed_time
73
+ end
74
+ end
75
+
76
+ thumbnail_options = options[:thumbnail]
77
+ if thumbnail_options
78
+ thumb_width = thumbnail_options[:width].try(:to_i) || options[:width].try(:to_i)
79
+ thumb_height = thumbnail_options[:height].try(:to_i) || options[:height].try(:to_i)
80
+ if not thumbnail_options[:force_aspect_ratio] and result_width and result_height
81
+ thumb_height = (thumb_width / (result_width / result_height.to_f)).to_i
82
+ end
83
+ at_seconds = thumbnail_options[:at_seconds].try(:to_f)
84
+ at_seconds ||= (expected_duration || 0) * (thumbnail_options[:at_fraction].try(:to_f) || 0.5)
85
+ @logger.info("Getting thumbnail frame (#{thumb_width}x#{thumb_height}) with FFmpeg at #{at_seconds} seconds")
86
+ begin
87
+ run_ffmpeg(input_filename, thumbnail_options[:filename], @ffmpeg_arguments.merge(
88
+ :ss => at_seconds,
89
+ :vcodec => :mjpeg,
90
+ :vframes => 1,
91
+ :an => true,
92
+ :f => :rawvideo,
93
+ :s => "#{thumb_width}x#{thumb_height}"))
94
+ rescue FfmpegAdapterExecutionFailed => e
95
+ @logger.error("Thumbnail rendering failed, ignoring: #{e}")
96
+ end
97
+ end
98
+ end
99
+
100
+ attr_accessor :ffmpeg_binary
101
+ attr_accessor :ffmpeg_arguments
102
+
103
+ # Output captured from FFmpeg command line tool so far.
104
+ attr_reader :output
105
+
106
+ # Progress reporter that implements +call(seconds, total_seconds)+ to record
107
+ # transcoding progress.
108
+ attr_accessor :progress
109
+
110
+ private
111
+
112
+ def run_ffmpeg(input_filename, output_filename, arguments, &block)
113
+ command_line = @ffmpeg_binary.dup
114
+ command_line << " -i '#{input_filename}' "
115
+ command_line << arguments.map { |k, v|
116
+ case v
117
+ when TrueClass, FalseClass
118
+ "-#{k}"
119
+ when Array
120
+ v.map { |w| "-#{k} '#{w}'" }.join(' ')
121
+ else
122
+ "-#{k} '#{v}'"
123
+ end
124
+ }.join(' ')
125
+ command_line << ' '
126
+ command_line << "'#{output_filename}'"
127
+ CommandRunner.new(command_line).run(&block)
128
+ end
129
+
130
+ end
131
+
132
+ end