convertr 0.0.0 → 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.
- data/Rakefile +6 -2
- data/VERSION +1 -1
- data/bin/convertr +3 -3
- data/convertr.gemspec +39 -6
- data/lib/convertr.rb +33 -0
- data/lib/convertr/convertor.rb +199 -0
- data/lib/convertr/file.rb +15 -0
- data/lib/convertr/migration.rb +38 -0
- data/lib/convertr/runner.rb +53 -0
- data/lib/convertr/scheduler.rb +29 -0
- data/lib/convertr/scheduler/all_bt_first.rb +10 -0
- data/lib/convertr/scheduler/bt_600_first.rb +6 -0
- data/lib/convertr/scheduler_factory.rb +11 -0
- data/lib/convertr/task.rb +10 -0
- data/lib/tasks/convertr.rake +22 -0
- data/test/database.yml +39 -0
- data/test/factories/files.rb +5 -0
- data/test/factories/tasks.rb +6 -0
- data/test/fixtures/files.yml +49 -0
- data/test/fixtures/tasks.yml +14 -0
- data/test/helper.rb +10 -1
- data/test/settings.yml +41 -0
- data/test/test_convertor.rb +57 -0
- data/test/test_file.rb +27 -0
- data/test/test_scheduler_allbtfirst.rb +12 -0
- data/test/test_scheduler_factory.rb +17 -0
- data/test/test_task.rb +23 -0
- metadata +65 -11
- data/test/test_convertr.rb +0 -7
data/Rakefile
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'rake'
|
3
|
+
load 'lib/tasks/convertr.rake'
|
3
4
|
|
4
5
|
begin
|
5
6
|
require 'jeweler'
|
@@ -11,7 +12,10 @@ begin
|
|
11
12
|
gem.homepage = "http://github.com/tulaman/convertr"
|
12
13
|
gem.authors = ["Ilya Lityuga", "Alexander Svetkin"]
|
13
14
|
gem.add_development_dependency "shoulda", ">= 0"
|
14
|
-
gem.add_development_dependency "
|
15
|
+
gem.add_development_dependency "mocha", ">= 0"
|
16
|
+
gem.add_development_dependency "factory_girl", ">= 0"
|
17
|
+
gem.add_dependency "activerecord", ">= 3.0.0"
|
18
|
+
gem.requirements << "ffmpeg, any version"
|
15
19
|
# gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
|
16
20
|
end
|
17
21
|
Jeweler::GemcutterTasks.new
|
@@ -39,7 +43,7 @@ rescue LoadError
|
|
39
43
|
end
|
40
44
|
end
|
41
45
|
|
42
|
-
task :test => :check_dependencies
|
46
|
+
task :test => [:check_dependencies, 'convertr:prepare_test']
|
43
47
|
|
44
48
|
task :default => :test
|
45
49
|
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0.
|
1
|
+
0.0.1
|
data/bin/convertr
CHANGED
@@ -1,11 +1,11 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
$LOAD_PATH << File.join(File.dirname(__FILE__), '..')
|
4
|
-
require '
|
3
|
+
$LOAD_PATH << File.join(File.dirname(__FILE__), '..', 'lib')
|
4
|
+
require 'convertr'
|
5
5
|
|
6
6
|
if ARGV.any? { |arg| %w(--version -v).any? { |flag| arg == flag } }
|
7
7
|
puts "Convertr #{Convertr::Version}"
|
8
8
|
exit 0
|
9
9
|
end
|
10
10
|
|
11
|
-
Convertr::Runner.new(ARGV)
|
11
|
+
Convertr::Runner.new(ARGV).run
|
data/convertr.gemspec
CHANGED
@@ -5,11 +5,11 @@
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |s|
|
7
7
|
s.name = %q{convertr}
|
8
|
-
s.version = "0.0.
|
8
|
+
s.version = "0.0.1"
|
9
9
|
|
10
10
|
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
11
|
s.authors = ["Ilya Lityuga", "Alexander Svetkin"]
|
12
|
-
s.date = %q{2010-10-
|
12
|
+
s.date = %q{2010-10-12}
|
13
13
|
s.default_executable = %q{convertr}
|
14
14
|
s.description = %q{Convertr works with database and handles converting tasks. It fetches files from remote sources and converts them to appropriate formats with ffmpeg}
|
15
15
|
s.email = %q{ilya.lityuga@gmail.com}
|
@@ -28,17 +28,44 @@ Gem::Specification.new do |s|
|
|
28
28
|
"bin/convertr",
|
29
29
|
"convertr.gemspec",
|
30
30
|
"lib/convertr.rb",
|
31
|
+
"lib/convertr/convertor.rb",
|
32
|
+
"lib/convertr/file.rb",
|
33
|
+
"lib/convertr/migration.rb",
|
34
|
+
"lib/convertr/runner.rb",
|
35
|
+
"lib/convertr/scheduler.rb",
|
36
|
+
"lib/convertr/scheduler/all_bt_first.rb",
|
37
|
+
"lib/convertr/scheduler/bt_600_first.rb",
|
38
|
+
"lib/convertr/scheduler_factory.rb",
|
39
|
+
"lib/convertr/task.rb",
|
40
|
+
"lib/tasks/convertr.rake",
|
41
|
+
"test/database.yml",
|
42
|
+
"test/factories/files.rb",
|
43
|
+
"test/factories/tasks.rb",
|
44
|
+
"test/fixtures/files.yml",
|
45
|
+
"test/fixtures/tasks.yml",
|
31
46
|
"test/helper.rb",
|
32
|
-
"test/
|
47
|
+
"test/settings.yml",
|
48
|
+
"test/test_convertor.rb",
|
49
|
+
"test/test_file.rb",
|
50
|
+
"test/test_scheduler_allbtfirst.rb",
|
51
|
+
"test/test_scheduler_factory.rb",
|
52
|
+
"test/test_task.rb"
|
33
53
|
]
|
34
54
|
s.homepage = %q{http://github.com/tulaman/convertr}
|
35
55
|
s.rdoc_options = ["--charset=UTF-8"]
|
36
56
|
s.require_paths = ["lib"]
|
57
|
+
s.requirements = ["ffmpeg, any version"]
|
37
58
|
s.rubygems_version = %q{1.3.7}
|
38
59
|
s.summary = %q{Useful utility for converting video files with ffmpeg}
|
39
60
|
s.test_files = [
|
40
|
-
"test/
|
41
|
-
"test/
|
61
|
+
"test/factories/files.rb",
|
62
|
+
"test/factories/tasks.rb",
|
63
|
+
"test/test_scheduler_allbtfirst.rb",
|
64
|
+
"test/test_scheduler_factory.rb",
|
65
|
+
"test/test_file.rb",
|
66
|
+
"test/helper.rb",
|
67
|
+
"test/test_task.rb",
|
68
|
+
"test/test_convertor.rb"
|
42
69
|
]
|
43
70
|
|
44
71
|
if s.respond_to? :specification_version then
|
@@ -47,13 +74,19 @@ Gem::Specification.new do |s|
|
|
47
74
|
|
48
75
|
if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
|
49
76
|
s.add_development_dependency(%q<shoulda>, [">= 0"])
|
50
|
-
s.add_development_dependency(%q<
|
77
|
+
s.add_development_dependency(%q<mocha>, [">= 0"])
|
78
|
+
s.add_development_dependency(%q<factory_girl>, [">= 0"])
|
79
|
+
s.add_runtime_dependency(%q<activerecord>, [">= 3.0.0"])
|
51
80
|
else
|
52
81
|
s.add_dependency(%q<shoulda>, [">= 0"])
|
82
|
+
s.add_dependency(%q<mocha>, [">= 0"])
|
83
|
+
s.add_dependency(%q<factory_girl>, [">= 0"])
|
53
84
|
s.add_dependency(%q<activerecord>, [">= 3.0.0"])
|
54
85
|
end
|
55
86
|
else
|
56
87
|
s.add_dependency(%q<shoulda>, [">= 0"])
|
88
|
+
s.add_dependency(%q<mocha>, [">= 0"])
|
89
|
+
s.add_dependency(%q<factory_girl>, [">= 0"])
|
57
90
|
s.add_dependency(%q<activerecord>, [">= 3.0.0"])
|
58
91
|
end
|
59
92
|
end
|
data/lib/convertr.rb
CHANGED
@@ -1,3 +1,11 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
require 'active_record'
|
3
|
+
require 'active_record/railtie'
|
4
|
+
require 'convertr/runner'
|
5
|
+
require 'convertr/scheduler'
|
6
|
+
require 'convertr/scheduler_factory'
|
7
|
+
require 'convertr/convertor'
|
8
|
+
|
1
9
|
module Convertr
|
2
10
|
module Version
|
3
11
|
class << self
|
@@ -6,4 +14,29 @@ module Convertr
|
|
6
14
|
end
|
7
15
|
end
|
8
16
|
end
|
17
|
+
|
18
|
+
class Config < OpenStruct
|
19
|
+
include Singleton
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.configure(config = nil)
|
23
|
+
config ||= Config.instance
|
24
|
+
yield Config.instance if block_given?
|
25
|
+
enviroment = ENV['RAILS_ENV'] || 'development'
|
26
|
+
config.db_config = YAML.load_file(config.db_config_file)[enviroment]
|
27
|
+
if config.settings_file
|
28
|
+
YAML.load_file(config.settings_file)[enviroment]['convertor'].each do |k, v|
|
29
|
+
config.send("#{k}=", v)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
self.init!
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.init!
|
36
|
+
conf = Config.instance
|
37
|
+
ActiveRecord::Base.establish_connection(conf.db_config)
|
38
|
+
require 'convertr/file'
|
39
|
+
require 'convertr/task'
|
40
|
+
$stderr.puts "Tables not found" && exit(1) unless Convertr::File.table_exists? && Convertr::Task.table_exists?
|
41
|
+
end
|
9
42
|
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'uri'
|
3
|
+
require 'net/ftp'
|
4
|
+
|
5
|
+
class Hash # {{{ small hack for syntax sugar - hash merging by +
|
6
|
+
def +(h)
|
7
|
+
self.merge(h)
|
8
|
+
end
|
9
|
+
end # }}}
|
10
|
+
|
11
|
+
class File # {{{ filename without extension
|
12
|
+
def self.filename(filepath)
|
13
|
+
File.basename(filepath, File.extname(filepath))
|
14
|
+
end
|
15
|
+
end # }}}
|
16
|
+
|
17
|
+
module Convertr
|
18
|
+
class Convertor
|
19
|
+
CONVERTOR_STOPFILE = 'stop.convertr'
|
20
|
+
CONVERTOR_PAUSEFILE = 'pause.convertr'
|
21
|
+
CONVERTOR_PAUSE_DELAY = 120
|
22
|
+
CONVERTOR_MAX_FETCH_TIME = 600
|
23
|
+
|
24
|
+
attr_accessor :scheduler, :max_tasks, :hostname, :initial_dir, :file, :task, :file_path, :work_path, :tasks
|
25
|
+
|
26
|
+
def initialize(max_tasks = 0, scheduler = nil) # инициализация конертера {{{
|
27
|
+
@max_tasks = max_tasks
|
28
|
+
@logger = Logger.new($stderr)
|
29
|
+
@initial_dir = Dir.pwd
|
30
|
+
@scheduler = Convertr::SchedulerFactory.create(scheduler)
|
31
|
+
@hostname = `hostname`.chomp
|
32
|
+
@conf = Convertr::Config.instance
|
33
|
+
@tasks = 0
|
34
|
+
end # }}}
|
35
|
+
|
36
|
+
def run # запуск конвертора {{{
|
37
|
+
loop do
|
38
|
+
break if File.exists? CONVERTOR_STOPFILE
|
39
|
+
if !File.exists?(CONVERTOR_PAUSEFILE) and @task = @scheduler.schedule_next_task(@hostname)
|
40
|
+
task.update_attributes(
|
41
|
+
:convert_status => process_task,
|
42
|
+
:convert_stopped_at => Time.now
|
43
|
+
)
|
44
|
+
break if @max_tasks > 0 && (@tasks += 1) > @max_tasks
|
45
|
+
else
|
46
|
+
sleep(CONVERTOR_PAUSE_DELAY) && next
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end # }}}
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def process_task # выполнение конкретной задачи на конвертацию {{{
|
54
|
+
@file = @task.file
|
55
|
+
@file_path = File.join(@conf.tmp_dir, @file.name)
|
56
|
+
@working_dir = File.join( File.dirname(@file_path), File.filename(@filepath) )
|
57
|
+
@logger.info("Started #@filepath")
|
58
|
+
begin
|
59
|
+
fetch_file(@file.source_location, @file.name)
|
60
|
+
FileUtils.cd @working_dir
|
61
|
+
process_profile(profile_by_bitrate(@task.bitrate))
|
62
|
+
if @task.bitrate == 600
|
63
|
+
count = calc_thumbnails_count(@file.duration)
|
64
|
+
interval = (@file.duration / count).to_i
|
65
|
+
system(make_thumbnails_cmd(count, interval, 150, 0, 2)) or raise "thumbnails generation failed #{$?}"
|
66
|
+
end
|
67
|
+
@logger.info("Done #@filepath")
|
68
|
+
rescue StandardError => e
|
69
|
+
@logger.error e.message
|
70
|
+
return 'FAILURE'
|
71
|
+
ensure
|
72
|
+
FileUtils.cd @initial_dir
|
73
|
+
end
|
74
|
+
'SUCCESS'
|
75
|
+
end # }}}
|
76
|
+
|
77
|
+
def fetch_file(source_url, filename) # скачивание файла по FTP {{{
|
78
|
+
FileUtils.mkpath(@working_dir)
|
79
|
+
dst_file = File.join(@working_dir, File.basename(filename))
|
80
|
+
tmp_file = dst_file + ".part"
|
81
|
+
started_at = Time.now
|
82
|
+
loop do
|
83
|
+
return if File.exists? dst_file
|
84
|
+
if File.exists? tmp_file
|
85
|
+
sleep(CONVERTOR_PAUSE_DELAY)
|
86
|
+
if Time.now > started_at + CONVERTOR_MAX_FETCH_TIME
|
87
|
+
FileUtils.rm_f tmp_file
|
88
|
+
else
|
89
|
+
next
|
90
|
+
end
|
91
|
+
end
|
92
|
+
FileUtils.touch tmp_file
|
93
|
+
break
|
94
|
+
end
|
95
|
+
|
96
|
+
url = URI.parse(source_url)
|
97
|
+
begin
|
98
|
+
ftp = Net::Ftp.open(url.host)
|
99
|
+
ftp.login(@conf.ftp_user, @conf.ftp_pass)
|
100
|
+
ftp.getbinaryfile(url.path, tmp_file, 1024)
|
101
|
+
rescue StandardError => e
|
102
|
+
ftp.close
|
103
|
+
FileUtils.rm_f tmp_file
|
104
|
+
raise e
|
105
|
+
end
|
106
|
+
ftp.close
|
107
|
+
|
108
|
+
FileUtils.rename tmp_file, dst_file
|
109
|
+
end # }}}
|
110
|
+
|
111
|
+
def profile_by_bitrate(bitrate) # bitrate -> profile {{{
|
112
|
+
case bitrate
|
113
|
+
when 300 then 'sd'
|
114
|
+
when 600 then 'hd'
|
115
|
+
when 1000 then 'hdp'
|
116
|
+
else
|
117
|
+
raise "Unsupported bitrate '#{bitrate}'"
|
118
|
+
end
|
119
|
+
end # }}}
|
120
|
+
|
121
|
+
def process_profile(pname) # конвертация, согласно профилю # {{{
|
122
|
+
profile = @conf.enc_profiles[pname]
|
123
|
+
outfile = File.join(@working_dir, File.filename(@file.name), "-#{pname}-0.mp4")
|
124
|
+
infile = File.join(@working_dir, File.basename(@file.name))
|
125
|
+
output_dir = File.join(@conf.output_dir, @working_dir)
|
126
|
+
FileUtils.mkpath output_dir unless File.exists? output_dir
|
127
|
+
FileUtils.chdir output_dir
|
128
|
+
FileUtils.mkdir pname unless File.exists? pname
|
129
|
+
FileUtils.chdir pname
|
130
|
+
|
131
|
+
@conf.enc_pass.each_with_index do |enc_pass_params, i|
|
132
|
+
cmd = mkcmd(enc_pass_params, pname, infile, i == 0 ? '/dev/null' : outfile, @file.aspect)
|
133
|
+
@logger.info(cmd)
|
134
|
+
system(cmd) or raise "system #{cmd} failed: #{$?}"
|
135
|
+
end
|
136
|
+
|
137
|
+
outfile2 = File.join(@working_dir, File.filename(@file.name), "-#{pname}.mp4")
|
138
|
+
cmd = "#{@conv.qtfaststart} #{outfile} #{outfile2}"
|
139
|
+
system(cmd) or FileUtils.rm_f(outfile) && raise("system #{cmd} failed: #{$?}")
|
140
|
+
FileUtils.cd '..'
|
141
|
+
FileUtils.rm_rf pname
|
142
|
+
end # }}}
|
143
|
+
|
144
|
+
def make_thumbnails_cmd(count, interval, w, h, postfix) # генерация тумбнейлов {{{
|
145
|
+
w = (h * @file.float_aspect).to_i if h && w.nil?
|
146
|
+
h = (w / @file.float_aspect).to_i if @file.aspect && w && h.nil?
|
147
|
+
tstamps = (1..count).collect {|i| i * interval}
|
148
|
+
params = ''
|
149
|
+
params += ' -c 20 ' if @task.crop?
|
150
|
+
params += ' -d ' if @task.deinterlace?
|
151
|
+
output_dir = File.join(@conf.output_dir, @working_dir)
|
152
|
+
pattern = File.join(@working_dir, File.filename(@file.name), "-%d-#{postfix}.jpg")
|
153
|
+
"#{@conf.tmaker} -i #{infile} #{params} -w #{w} -h #{h} -o #{output_dir} #{tstamps.join(' ')}"
|
154
|
+
end # }}}
|
155
|
+
|
156
|
+
def mkcmd(enc_pass, pname, infile, outfile, aspect) # подготовка команды на конвертацию {{{
|
157
|
+
float_aspect = aspect.split(':').first.to_f / aspect.split(':').last.to_f
|
158
|
+
params = @conf.enc_global + enc_pass + @conf.enc_profiles[pname]
|
159
|
+
# we must deinterlace movies taken from sat. dvd and rips don't have to be deinterlaced
|
160
|
+
params['deinterlace'] = '' if task.deinterlace? # option w/o a value
|
161
|
+
(w, h) = case
|
162
|
+
when aspect == '4:3' then [640, 480]
|
163
|
+
when aspect == '16:9' then [704, 396]
|
164
|
+
when float_aspect > 16.0 / 9.0 then [704, (704.0 / float_aspect).to_i]
|
165
|
+
when float_aspect < 16.0 / 9.0 && float_aspect > 4.0 / 3.0 then [640, (640.0 / float_aspect).to_i]
|
166
|
+
else
|
167
|
+
params['aspect'] = '1.33333333'
|
168
|
+
[640, 480]
|
169
|
+
end
|
170
|
+
# Frame size must be a multiple of 2
|
171
|
+
h -= 1 if h.odd?
|
172
|
+
if task.crop?
|
173
|
+
(params['croptop'], params['cropbottom'], params['cropleft'], params['cropright']) =
|
174
|
+
case aspect
|
175
|
+
when '16:9'
|
176
|
+
w += 32 && h += 18
|
177
|
+
[10, 8, 16, 16]
|
178
|
+
else # '4:3', '5:3' etc
|
179
|
+
w += 32 && h += 24
|
180
|
+
[20, 4, 16, 16]
|
181
|
+
end
|
182
|
+
end
|
183
|
+
"#{@conf.ffmpeg} -y -i #{infile} -s #{w}x#{h} #{params.map {|k, v| "-#{k} #{v}"}.sort.join(' ')} #{outfile}"
|
184
|
+
end # }}}
|
185
|
+
|
186
|
+
def calc_thumbnails_count(seconds) # выбор количества тумбнейлов {{{
|
187
|
+
case (seconds / 60).to_i
|
188
|
+
when 0..10 then 3
|
189
|
+
when 10..20 then 6
|
190
|
+
when 20..30 then 9
|
191
|
+
when 30..40 then 12
|
192
|
+
when 40..50 then 15
|
193
|
+
when 50..60 then 18
|
194
|
+
when 60..70 then 21
|
195
|
+
else 24
|
196
|
+
end
|
197
|
+
end # }}}
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Convertr
|
2
|
+
class File < ActiveRecord::Base
|
3
|
+
set_table_name :files
|
4
|
+
has_many :tasks, :class_name => 'Convertr::Task'
|
5
|
+
scope :without_convertor, where('convertor is null').order('broadcast_at asc')
|
6
|
+
scope :with_convertor, lambda { |convertor|
|
7
|
+
where(:convertor => convertor).order('broadcast_at asc')
|
8
|
+
}
|
9
|
+
|
10
|
+
def float_aspect
|
11
|
+
(w, h) = aspect.split(':')
|
12
|
+
w.to_f / h.to_f
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Convertr
|
2
|
+
class Migration < ActiveRecord::Migration
|
3
|
+
def self.up
|
4
|
+
create_table :files do |t|
|
5
|
+
t.string :filename
|
6
|
+
t.string :location
|
7
|
+
t.datetime :enqueue_at
|
8
|
+
t.string :convertor
|
9
|
+
t.datetime :broadcast_at
|
10
|
+
t.integer :src_size
|
11
|
+
t.integer :duration
|
12
|
+
t.string :aspect, :limit => 10
|
13
|
+
t.integer :width
|
14
|
+
t.integer :height
|
15
|
+
t.datetime :src_deleted_at
|
16
|
+
t.datetime :convertor_deleted_at
|
17
|
+
end
|
18
|
+
|
19
|
+
create_table :tasks do |t|
|
20
|
+
t.integer :file_id
|
21
|
+
t.integer :bitrate
|
22
|
+
t.string :convert_status, :limit => 20
|
23
|
+
t.boolean :convert_started
|
24
|
+
t.boolean :convert_stopped
|
25
|
+
t.string :copy_status, :limit => 20
|
26
|
+
t.boolean :copy_started
|
27
|
+
t.boolean :copy_stopped
|
28
|
+
t.boolean :crop
|
29
|
+
t.boolean :deinterlace
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.down
|
34
|
+
drop_table :tasks
|
35
|
+
drop_table :files
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module Convertr
|
4
|
+
class Runner
|
5
|
+
class OptParser # {{{
|
6
|
+
def self.parse(args)
|
7
|
+
options = Convertr::Config.instance
|
8
|
+
options.db_config_file = "~/.convertr/database.yml"
|
9
|
+
options.settings_file = "~/.convertr/settings.yml"
|
10
|
+
options.max_tasks = 0
|
11
|
+
options.force_reset_database = false
|
12
|
+
opts = OptionParser.new do |opts|
|
13
|
+
opts.banner = "Usage: convertr [options]"
|
14
|
+
opts.separator ""
|
15
|
+
opts.separator "Specific options:"
|
16
|
+
|
17
|
+
opts.on("-d", "--db_config_file [PATH_TO_DB_CONFIG]",
|
18
|
+
"Specify path to database.yml file") do |dbc|
|
19
|
+
options.db_config_file = dbc
|
20
|
+
end
|
21
|
+
|
22
|
+
opts.on("-c", "--settings_file [PATH_TO_CONFIG]",
|
23
|
+
"Specify path to settings.yml file") do |c|
|
24
|
+
options.settings_file = c
|
25
|
+
end
|
26
|
+
|
27
|
+
opts.on("-m", "--max_tasks N", Integer,
|
28
|
+
"Specify the maximum tasks to complete") do |m|
|
29
|
+
options.max_tasks = m
|
30
|
+
end
|
31
|
+
|
32
|
+
opts.on("-f", "--force",
|
33
|
+
"Force recreating database tables") do |f|
|
34
|
+
options.force = f
|
35
|
+
end
|
36
|
+
end
|
37
|
+
opts.parse!(args)
|
38
|
+
options
|
39
|
+
end
|
40
|
+
end # }}}
|
41
|
+
|
42
|
+
attr_reader :db_config, :config, :options
|
43
|
+
|
44
|
+
def initialize(opts) # {{{
|
45
|
+
config = Convertr::Runner::OptParser.parse(opts)
|
46
|
+
Convertr.configure(config)
|
47
|
+
end
|
48
|
+
# }}}
|
49
|
+
def run # {{{
|
50
|
+
puts "Running"
|
51
|
+
end # }}}
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Convertr
|
2
|
+
module Scheduler
|
3
|
+
class Base
|
4
|
+
def initialize
|
5
|
+
end
|
6
|
+
|
7
|
+
def schedule_next_task(convertor)
|
8
|
+
# TODO: locking
|
9
|
+
if task = get_task_for_schedule(convertor)
|
10
|
+
schedule(task, convertor)
|
11
|
+
end
|
12
|
+
task
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def get_task_for_schedule # abstract
|
18
|
+
raise "Should be overriden"
|
19
|
+
end
|
20
|
+
|
21
|
+
def schedule(task, convertor)
|
22
|
+
Convertr::Task.transaction do
|
23
|
+
task.update_attributes(:convert_status => 'PROGRESS', :convert_started_at => Time.now)
|
24
|
+
task.file.update_attribute(:convertor => convertor)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require 'convertr/scheduler'
|
2
|
+
Dir[File.join(File.dirname(__FILE__), 'scheduler', '*')].each {|f| require f if File.file? f}
|
3
|
+
|
4
|
+
module Convertr
|
5
|
+
class SchedulerFactory
|
6
|
+
def self.create(name = 'AllBtFirst')
|
7
|
+
name ||= 'AllBtFirst'
|
8
|
+
Convertr::Scheduler.const_get(name).new
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module Convertr
|
2
|
+
class Task < ActiveRecord::Base
|
3
|
+
set_table_name :tasks
|
4
|
+
belongs_to :file, :class_name => 'Convertr::File'
|
5
|
+
scope :not_completed, where(:convert_status => 'NONE')
|
6
|
+
scope :for_convertor, lambda { |convertor|
|
7
|
+
not_completed.includes(:file).where('files.convertor' => convertor).order('files.broadcast_at asc')
|
8
|
+
}
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
namespace :convertr do
|
2
|
+
task :load_config do
|
3
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..'))
|
4
|
+
require 'convertr'
|
5
|
+
Convertr.configure do |c|
|
6
|
+
c.db_config_file = 'test/database.yml'
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
task :reset_database => :load_config do
|
11
|
+
require 'convertr/migration'
|
12
|
+
Convertr::Migration.down
|
13
|
+
Convertr::Migration.up
|
14
|
+
end
|
15
|
+
|
16
|
+
task :load_fixtures => :load_config do
|
17
|
+
require 'active_record/fixtures'
|
18
|
+
Fixtures.create_fixtures(File.join(Dir.pwd, 'test', 'fixtures'), [:files, :tasks])
|
19
|
+
end
|
20
|
+
|
21
|
+
task :prepare_test => [:reset_database, :load_fixtures]
|
22
|
+
end
|
data/test/database.yml
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# MySQL. Versions 4.1 and 5.0 are recommended.
|
2
|
+
#
|
3
|
+
# Install the MySQL driver:
|
4
|
+
# gem install mysql2
|
5
|
+
#
|
6
|
+
# And be sure to use new-style password hashing:
|
7
|
+
# http://dev.mysql.com/doc/refman/5.0/en/old-client.html
|
8
|
+
development:
|
9
|
+
adapter: mysql2
|
10
|
+
encoding: utf8
|
11
|
+
reconnect: false
|
12
|
+
database: videomore
|
13
|
+
pool: 5
|
14
|
+
username: root
|
15
|
+
password:
|
16
|
+
socket: /var/lib/mysql/mysql.sock
|
17
|
+
|
18
|
+
# Warning: The database defined as "test" will be erased and
|
19
|
+
# re-generated from your development database when you run "rake".
|
20
|
+
# Do not set this db to the same as development or production.
|
21
|
+
test:
|
22
|
+
adapter: mysql2
|
23
|
+
encoding: utf8
|
24
|
+
reconnect: false
|
25
|
+
database: videomore_test
|
26
|
+
pool: 5
|
27
|
+
username: root
|
28
|
+
password:
|
29
|
+
socket: /var/run/mysqld/mysqld.sock
|
30
|
+
|
31
|
+
production:
|
32
|
+
adapter: mysql2
|
33
|
+
encoding: utf8
|
34
|
+
reconnect: false
|
35
|
+
database: videomore_production
|
36
|
+
pool: 5
|
37
|
+
username: root
|
38
|
+
password:
|
39
|
+
socket: /var/run/mysqld/mysqld.sock
|
@@ -0,0 +1,49 @@
|
|
1
|
+
with_convertor1_1:
|
2
|
+
id: 1
|
3
|
+
filename: test.avi
|
4
|
+
location: /tmp
|
5
|
+
enqueue_at: <%= Time.now %>
|
6
|
+
convertor: convertor1
|
7
|
+
broadcast_at: <%= Time.now - 3600 * 24 %>
|
8
|
+
src_size: 12345
|
9
|
+
duration: 12345
|
10
|
+
aspect: 3x4
|
11
|
+
width: 640
|
12
|
+
height: 480
|
13
|
+
|
14
|
+
with_convertor1_2:
|
15
|
+
id: 2
|
16
|
+
filename: test.avi
|
17
|
+
location: /tmp
|
18
|
+
enqueue_at: <%= Time.now %>
|
19
|
+
convertor: convertor1
|
20
|
+
broadcast_at: <%= Time.now %>
|
21
|
+
src_size: 12345
|
22
|
+
duration: 12345
|
23
|
+
aspect: 3x4
|
24
|
+
width: 640
|
25
|
+
height: 480
|
26
|
+
|
27
|
+
without_convertor_1:
|
28
|
+
id: 3
|
29
|
+
filename: test.avi
|
30
|
+
location: /tmp
|
31
|
+
enqueue_at: <%= Time.now %>
|
32
|
+
broadcast_at: <%= Time.now - 3600 * 24 %>
|
33
|
+
src_size: 12345
|
34
|
+
duration: 12345
|
35
|
+
aspect: 3x4
|
36
|
+
width: 640
|
37
|
+
height: 480
|
38
|
+
|
39
|
+
without_convertor_2:
|
40
|
+
id: 4
|
41
|
+
filename: test.avi
|
42
|
+
location: /tmp
|
43
|
+
enqueue_at: <%= Time.now %>
|
44
|
+
broadcast_at: <%= Time.now %>
|
45
|
+
src_size: 12345
|
46
|
+
duration: 12345
|
47
|
+
aspect: 3x4
|
48
|
+
width: 640
|
49
|
+
height: 480
|
data/test/helper.rb
CHANGED
@@ -1,10 +1,19 @@
|
|
1
1
|
require 'rubygems'
|
2
2
|
require 'test/unit'
|
3
3
|
require 'shoulda'
|
4
|
+
require 'shoulda/active_record'
|
5
|
+
require 'factory_girl'
|
6
|
+
require 'mocha'
|
4
7
|
|
5
8
|
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
6
|
-
|
9
|
+
#$LOAD_PATH.unshift(File.dirname(__FILE__))
|
7
10
|
require 'convertr'
|
11
|
+
Convertr.configure do |c|
|
12
|
+
c.db_config_file = './test/database.yml'
|
13
|
+
c.settings_file = './test/settings.yml'
|
14
|
+
end
|
15
|
+
require 'factories/files'
|
16
|
+
require 'factories/tasks'
|
8
17
|
|
9
18
|
class Test::Unit::TestCase
|
10
19
|
end
|
data/test/settings.yml
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
development: &non_production_settings
|
2
|
+
convertor:
|
3
|
+
tmp_dir: /tmp
|
4
|
+
ftp_user: test
|
5
|
+
ftp_pass: test
|
6
|
+
enc_pass:
|
7
|
+
- vpre: "fastfirstpass-flash"
|
8
|
+
pass: 1
|
9
|
+
an: ""
|
10
|
+
f: mp4
|
11
|
+
- vpre: "hq-flash"
|
12
|
+
pass: 2
|
13
|
+
enc_profiles:
|
14
|
+
sd:
|
15
|
+
b: 236k
|
16
|
+
bt: 236k
|
17
|
+
ab: 64k
|
18
|
+
ac: 1
|
19
|
+
hd:
|
20
|
+
b: 472k
|
21
|
+
bt: 472k
|
22
|
+
ab: 128k
|
23
|
+
ac: 2
|
24
|
+
hdp:
|
25
|
+
b: 872k
|
26
|
+
bt: 872k
|
27
|
+
ab: 128k
|
28
|
+
ac: 2
|
29
|
+
enc_global:
|
30
|
+
acodec: libfaac
|
31
|
+
vcodec: libx264
|
32
|
+
threads: 3
|
33
|
+
ar: 44100
|
34
|
+
ffmpeg: /usr/bin/ffmpeg
|
35
|
+
tmaker: /usr/local/bin/tmaker
|
36
|
+
|
37
|
+
test:
|
38
|
+
<<: *non_production_settings
|
39
|
+
|
40
|
+
production:
|
41
|
+
<<: *non_production_settings
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class TestConvertor < Test::Unit::TestCase
|
4
|
+
context "Convertor" do
|
5
|
+
setup do
|
6
|
+
@convertor = Convertr::Convertor.new
|
7
|
+
end
|
8
|
+
|
9
|
+
should "be able to calculate thumbnails count" do
|
10
|
+
assert_equal 3, @convertor.instance_eval("calc_thumbnails_count(600)")
|
11
|
+
assert_equal 6, @convertor.instance_eval("calc_thumbnails_count(1200)")
|
12
|
+
assert_equal 9, @convertor.instance_eval("calc_thumbnails_count(1800)")
|
13
|
+
end
|
14
|
+
|
15
|
+
should "find the right profile by bitrate" do
|
16
|
+
assert_equal 'sd', @convertor.instance_eval("profile_by_bitrate(300)")
|
17
|
+
assert_equal 'hd', @convertor.instance_eval("profile_by_bitrate(600)")
|
18
|
+
assert_equal 'hdp', @convertor.instance_eval("profile_by_bitrate(1000)")
|
19
|
+
assert_raise RuntimeError do
|
20
|
+
@convertor.instance_eval("profile_by_bitrate(1234)")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context "for some task with crop and deinterlace" do
|
25
|
+
setup do
|
26
|
+
task = Factory.create(:task, :bitrate => 600, :crop => true, :deinterlace => true)
|
27
|
+
@convertor.stubs(:task).returns(task)
|
28
|
+
end
|
29
|
+
should "prepare valid command for ffmpeg" do
|
30
|
+
assert_equal '/usr/bin/ffmpeg -y -i infile.avi -s 1118x414 -ab 64k -ac 1 -acodec libfaac -ar 44100 -b 236k -bt 236k -cropbottom 8 -cropleft 16 -cropright 16 -croptop 10 -deinterlace -pass 2 -threads 3 -vcodec libx264 -vpre hq-flash outfile.mpg',
|
31
|
+
@convertor.instance_eval { mkcmd({'vpre' => 'hq-flash', 'pass' => 2}, 'sd', 'infile.avi', 'outfile.mpg', '16:9') }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
context "for some task with crop and without deinterlace" do
|
36
|
+
setup do
|
37
|
+
task = Factory.create(:task, :bitrate => 600, :crop => true, :deinterlace => false)
|
38
|
+
@convertor.stubs(:task).returns(task)
|
39
|
+
end
|
40
|
+
should "prepare valid command for ffmpeg" do
|
41
|
+
assert_equal '/usr/bin/ffmpeg -y -i infile.avi -s 1118x414 -ab 64k -ac 1 -acodec libfaac -ar 44100 -b 236k -bt 236k -cropbottom 8 -cropleft 16 -cropright 16 -croptop 10 -pass 2 -threads 3 -vcodec libx264 -vpre hq-flash outfile.mpg',
|
42
|
+
@convertor.instance_eval { mkcmd({'vpre' => 'hq-flash', 'pass' => 2}, 'sd', 'infile.avi', 'outfile.mpg', '16:9') }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context "for some task without crop and without deinterlace" do
|
47
|
+
setup do
|
48
|
+
task = Factory.create(:task, :bitrate => 600, :crop => false, :deinterlace => false)
|
49
|
+
@convertor.stubs(:task).returns(task)
|
50
|
+
end
|
51
|
+
should "prepare valid command for ffmpeg" do
|
52
|
+
assert_equal '/usr/bin/ffmpeg -y -i infile.avi -s 704x396 -ab 64k -ac 1 -acodec libfaac -ar 44100 -b 236k -bt 236k -pass 2 -threads 3 -vcodec libx264 -vpre hq-flash outfile.mpg',
|
53
|
+
@convertor.instance_eval { mkcmd({'vpre' => 'hq-flash', 'pass' => 2}, 'sd', 'infile.avi', 'outfile.mpg', '16:9') }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/test/test_file.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class TestFile < Test::Unit::TestCase
|
4
|
+
context "File class" do
|
5
|
+
should "have without_convertor scope" do
|
6
|
+
files = Convertr::File.without_convertor
|
7
|
+
assert_equal 2, files.size
|
8
|
+
assert_equal 3, files.first.id
|
9
|
+
end
|
10
|
+
|
11
|
+
should "have with_convertor scope" do
|
12
|
+
files = Convertr::File.with_convertor('convertor1')
|
13
|
+
assert_equal 2, files.size
|
14
|
+
assert_equal 1, files.first.id
|
15
|
+
end
|
16
|
+
end
|
17
|
+
context "File" do
|
18
|
+
setup do
|
19
|
+
@file = Convertr::File.new
|
20
|
+
end
|
21
|
+
subject { @file }
|
22
|
+
should have_many(:tasks)
|
23
|
+
should "be valid" do
|
24
|
+
assert @file.valid?
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class TestSchedulerAllbtfirst < Test::Unit::TestCase
|
4
|
+
context "AllBtFirst scheduler" do
|
5
|
+
setup do
|
6
|
+
@scheduler = Convertr::SchedulerFactory.create('AllBtFirst')
|
7
|
+
end
|
8
|
+
should "find first task assigned for convertor" do
|
9
|
+
assert_equal 2, @scheduler.get_task_for_schedule('convertor1').id
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class TestSchedulerFactory < Test::Unit::TestCase
|
4
|
+
context "SchedulerFactory" do
|
5
|
+
should "create instance of AllBtFirst by default" do
|
6
|
+
assert_instance_of Convertr::Scheduler::AllBtFirst, Convertr::SchedulerFactory.create
|
7
|
+
end
|
8
|
+
|
9
|
+
should "create instance of appropriate scheduler" do
|
10
|
+
assert_instance_of Convertr::Scheduler::Bt600First, Convertr::SchedulerFactory.create('Bt600First')
|
11
|
+
end
|
12
|
+
|
13
|
+
should "return object inherited from base scheduler" do
|
14
|
+
assert_kind_of Convertr::Scheduler::Base, Convertr::SchedulerFactory.create
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/test/test_task.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'test/helper'
|
2
|
+
|
3
|
+
class TestTask < Test::Unit::TestCase
|
4
|
+
context "Tasks" do
|
5
|
+
should "have for_convertor scope" do
|
6
|
+
assert_equal 2, Convertr::Task.for_convertor('convertor1').size
|
7
|
+
end
|
8
|
+
should "have not_completed scope" do
|
9
|
+
assert_equal 3, Convertr::Task.not_completed.size
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
context "Task" do
|
14
|
+
setup do
|
15
|
+
@task = Convertr::Task.new
|
16
|
+
end
|
17
|
+
subject { @task }
|
18
|
+
should belong_to(:file)
|
19
|
+
should "be valid" do
|
20
|
+
assert @task.valid?
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: convertr
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 29
|
5
5
|
prerelease: false
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 0
|
9
|
-
-
|
10
|
-
version: 0.0.
|
9
|
+
- 1
|
10
|
+
version: 0.0.1
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Ilya Lityuga
|
@@ -16,7 +16,7 @@ autorequire:
|
|
16
16
|
bindir: bin
|
17
17
|
cert_chain: []
|
18
18
|
|
19
|
-
date: 2010-10-
|
19
|
+
date: 2010-10-12 00:00:00 +04:00
|
20
20
|
default_executable: convertr
|
21
21
|
dependencies:
|
22
22
|
- !ruby/object:Gem::Dependency
|
@@ -34,9 +34,37 @@ dependencies:
|
|
34
34
|
type: :development
|
35
35
|
version_requirements: *id001
|
36
36
|
- !ruby/object:Gem::Dependency
|
37
|
-
name:
|
37
|
+
name: mocha
|
38
38
|
prerelease: false
|
39
39
|
requirement: &id002 !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
hash: 3
|
45
|
+
segments:
|
46
|
+
- 0
|
47
|
+
version: "0"
|
48
|
+
type: :development
|
49
|
+
version_requirements: *id002
|
50
|
+
- !ruby/object:Gem::Dependency
|
51
|
+
name: factory_girl
|
52
|
+
prerelease: false
|
53
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
54
|
+
none: false
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
hash: 3
|
59
|
+
segments:
|
60
|
+
- 0
|
61
|
+
version: "0"
|
62
|
+
type: :development
|
63
|
+
version_requirements: *id003
|
64
|
+
- !ruby/object:Gem::Dependency
|
65
|
+
name: activerecord
|
66
|
+
prerelease: false
|
67
|
+
requirement: &id004 !ruby/object:Gem::Requirement
|
40
68
|
none: false
|
41
69
|
requirements:
|
42
70
|
- - ">="
|
@@ -47,8 +75,8 @@ dependencies:
|
|
47
75
|
- 0
|
48
76
|
- 0
|
49
77
|
version: 3.0.0
|
50
|
-
type: :
|
51
|
-
version_requirements: *
|
78
|
+
type: :runtime
|
79
|
+
version_requirements: *id004
|
52
80
|
description: Convertr works with database and handles converting tasks. It fetches files from remote sources and converts them to appropriate formats with ffmpeg
|
53
81
|
email: ilya.lityuga@gmail.com
|
54
82
|
executables:
|
@@ -68,8 +96,28 @@ files:
|
|
68
96
|
- bin/convertr
|
69
97
|
- convertr.gemspec
|
70
98
|
- lib/convertr.rb
|
99
|
+
- lib/convertr/convertor.rb
|
100
|
+
- lib/convertr/file.rb
|
101
|
+
- lib/convertr/migration.rb
|
102
|
+
- lib/convertr/runner.rb
|
103
|
+
- lib/convertr/scheduler.rb
|
104
|
+
- lib/convertr/scheduler/all_bt_first.rb
|
105
|
+
- lib/convertr/scheduler/bt_600_first.rb
|
106
|
+
- lib/convertr/scheduler_factory.rb
|
107
|
+
- lib/convertr/task.rb
|
108
|
+
- lib/tasks/convertr.rake
|
109
|
+
- test/database.yml
|
110
|
+
- test/factories/files.rb
|
111
|
+
- test/factories/tasks.rb
|
112
|
+
- test/fixtures/files.yml
|
113
|
+
- test/fixtures/tasks.yml
|
71
114
|
- test/helper.rb
|
72
|
-
- test/
|
115
|
+
- test/settings.yml
|
116
|
+
- test/test_convertor.rb
|
117
|
+
- test/test_file.rb
|
118
|
+
- test/test_scheduler_allbtfirst.rb
|
119
|
+
- test/test_scheduler_factory.rb
|
120
|
+
- test/test_task.rb
|
73
121
|
has_rdoc: true
|
74
122
|
homepage: http://github.com/tulaman/convertr
|
75
123
|
licenses: []
|
@@ -97,13 +145,19 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
145
|
segments:
|
98
146
|
- 0
|
99
147
|
version: "0"
|
100
|
-
requirements:
|
101
|
-
|
148
|
+
requirements:
|
149
|
+
- ffmpeg, any version
|
102
150
|
rubyforge_project:
|
103
151
|
rubygems_version: 1.3.7
|
104
152
|
signing_key:
|
105
153
|
specification_version: 3
|
106
154
|
summary: Useful utility for converting video files with ffmpeg
|
107
155
|
test_files:
|
156
|
+
- test/factories/files.rb
|
157
|
+
- test/factories/tasks.rb
|
158
|
+
- test/test_scheduler_allbtfirst.rb
|
159
|
+
- test/test_scheduler_factory.rb
|
160
|
+
- test/test_file.rb
|
108
161
|
- test/helper.rb
|
109
|
-
- test/
|
162
|
+
- test/test_task.rb
|
163
|
+
- test/test_convertor.rb
|