tootsie 0.9.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.
@@ -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