active_encode 0.5.0 → 0.8.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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +80 -0
  3. data/.rubocop.yml +9 -70
  4. data/.rubocop_todo.yml +68 -0
  5. data/Gemfile +5 -4
  6. data/README.md +69 -0
  7. data/active_encode.gemspec +12 -3
  8. data/app/controllers/active_encode/encode_record_controller.rb +1 -0
  9. data/app/jobs/active_encode/polling_job.rb +1 -1
  10. data/app/models/active_encode/encode_record.rb +1 -0
  11. data/db/migrate/20180822021048_create_active_encode_encode_records.rb +1 -0
  12. data/db/migrate/20190702153755_add_create_options_to_active_encode_encode_records.rb +6 -0
  13. data/db/migrate/20190712174821_add_progress_to_active_encode_encode_records.rb +6 -0
  14. data/lib/active_encode.rb +1 -0
  15. data/lib/active_encode/base.rb +2 -2
  16. data/lib/active_encode/callbacks.rb +1 -0
  17. data/lib/active_encode/core.rb +4 -3
  18. data/lib/active_encode/engine.rb +1 -0
  19. data/lib/active_encode/engine_adapter.rb +1 -0
  20. data/lib/active_encode/engine_adapters.rb +4 -1
  21. data/lib/active_encode/engine_adapters/elastic_transcoder_adapter.rb +31 -29
  22. data/lib/active_encode/engine_adapters/ffmpeg_adapter.rb +138 -87
  23. data/lib/active_encode/engine_adapters/matterhorn_adapter.rb +5 -4
  24. data/lib/active_encode/engine_adapters/media_convert_adapter.rb +399 -0
  25. data/lib/active_encode/engine_adapters/media_convert_output.rb +104 -0
  26. data/lib/active_encode/engine_adapters/pass_through_adapter.rb +239 -0
  27. data/lib/active_encode/engine_adapters/test_adapter.rb +5 -4
  28. data/lib/active_encode/engine_adapters/zencoder_adapter.rb +3 -2
  29. data/lib/active_encode/errors.rb +6 -0
  30. data/lib/active_encode/global_id.rb +2 -1
  31. data/lib/active_encode/input.rb +3 -2
  32. data/lib/active_encode/output.rb +3 -2
  33. data/lib/active_encode/persistence.rb +11 -5
  34. data/lib/active_encode/polling.rb +3 -2
  35. data/lib/active_encode/spec/shared_specs.rb +2 -0
  36. data/{spec/shared_specs/engine_adapter_specs.rb → lib/active_encode/spec/shared_specs/engine_adapter.rb} +37 -38
  37. data/lib/active_encode/status.rb +1 -0
  38. data/lib/active_encode/technical_metadata.rb +3 -2
  39. data/lib/active_encode/version.rb +2 -1
  40. data/lib/file_locator.rb +8 -9
  41. data/spec/controllers/encode_record_controller_spec.rb +4 -3
  42. data/spec/fixtures/ffmpeg/cancelled-id/cancelled +0 -0
  43. data/spec/fixtures/file with space.low.mp4 +0 -0
  44. data/spec/fixtures/file with space.mp4 +0 -0
  45. data/spec/fixtures/fireworks.low.mp4 +0 -0
  46. data/spec/fixtures/media_convert/endpoints.json +1 -0
  47. data/spec/fixtures/media_convert/job_canceled.json +412 -0
  48. data/spec/fixtures/media_convert/job_canceling.json +1 -0
  49. data/spec/fixtures/media_convert/job_completed.json +359 -0
  50. data/spec/fixtures/media_convert/job_completed_detail.json +1 -0
  51. data/spec/fixtures/media_convert/job_completed_detail_query.json +1 -0
  52. data/spec/fixtures/media_convert/job_completed_empty_detail.json +1 -0
  53. data/spec/fixtures/media_convert/job_created.json +408 -0
  54. data/spec/fixtures/media_convert/job_failed.json +406 -0
  55. data/spec/fixtures/media_convert/job_progressing.json +414 -0
  56. data/spec/fixtures/pass_through/cancelled-id/cancelled +0 -0
  57. data/spec/fixtures/pass_through/cancelled-id/input_metadata +90 -0
  58. data/spec/fixtures/pass_through/completed-id/completed +0 -0
  59. data/spec/fixtures/pass_through/completed-id/input_metadata +102 -0
  60. data/spec/fixtures/pass_through/completed-id/output_metadata-high +90 -0
  61. data/spec/fixtures/pass_through/completed-id/output_metadata-low +90 -0
  62. data/spec/fixtures/pass_through/completed-id/video-high.mp4 +0 -0
  63. data/spec/fixtures/pass_through/completed-id/video-low.mp4 +0 -0
  64. data/spec/fixtures/pass_through/failed-id/error.log +1 -0
  65. data/spec/fixtures/pass_through/failed-id/input_metadata +90 -0
  66. data/spec/fixtures/pass_through/running-id/input_metadata +90 -0
  67. data/spec/integration/elastic_transcoder_adapter_spec.rb +30 -30
  68. data/spec/integration/ffmpeg_adapter_spec.rb +93 -25
  69. data/spec/integration/matterhorn_adapter_spec.rb +45 -44
  70. data/spec/integration/media_convert_adapter_spec.rb +152 -0
  71. data/spec/integration/pass_through_adapter_spec.rb +151 -0
  72. data/spec/integration/zencoder_adapter_spec.rb +210 -209
  73. data/spec/rails_helper.rb +1 -0
  74. data/spec/routing/encode_record_controller_routing_spec.rb +1 -0
  75. data/spec/spec_helper.rb +2 -2
  76. data/spec/test_app_templates/lib/generators/test_app_generator.rb +13 -12
  77. data/spec/units/callbacks_spec.rb +3 -2
  78. data/spec/units/core_spec.rb +26 -25
  79. data/spec/units/engine_adapter_spec.rb +1 -0
  80. data/spec/units/file_locator_spec.rb +20 -19
  81. data/spec/units/global_id_spec.rb +12 -11
  82. data/spec/units/input_spec.rb +8 -5
  83. data/spec/units/output_spec.rb +8 -5
  84. data/spec/units/persistence_spec.rb +15 -11
  85. data/spec/units/polling_job_spec.rb +7 -6
  86. data/spec/units/polling_spec.rb +1 -0
  87. data/spec/units/status_spec.rb +3 -3
  88. metadata +158 -14
  89. data/.travis.yml +0 -19
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_model/callbacks'
2
3
 
3
4
  module ActiveEncode
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_support'
2
3
  require 'active_encode/callbacks'
3
4
 
@@ -44,19 +45,19 @@ module ActiveEncode
44
45
  end
45
46
 
46
47
  def initialize(input_url, options = nil)
47
- @input = Input.new.tap{ |input| input.url = input_url }
48
+ @input = Input.new.tap { |input| input.url = input_url }
48
49
  @options = self.class.default_options(input_url).merge(Hash(options))
49
50
  end
50
51
 
51
52
  def create!
52
53
  run_callbacks :create do
53
- merge!(self.class.engine_adapter.create(self.input.url, self.options))
54
+ merge!(self.class.engine_adapter.create(input.url, options))
54
55
  end
55
56
  end
56
57
 
57
58
  def cancel!
58
59
  run_callbacks :cancel do
59
- merge!(self.class.engine_adapter.cancel(self.id))
60
+ merge!(self.class.engine_adapter.cancel(id))
60
61
  end
61
62
  end
62
63
 
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'rails'
2
3
 
3
4
  module ActiveEncode
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require 'active_encode/engine_adapters'
2
3
  require 'active_support/core_ext/class/attribute'
3
4
  require 'active_support/core_ext/string/inflections'
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  module ActiveEncode
2
3
  # == Active Encode adapters
3
4
  #
@@ -13,8 +14,10 @@ module ActiveEncode
13
14
  autoload :ElasticTranscoderAdapter
14
15
  autoload :TestAdapter
15
16
  autoload :FfmpegAdapter
17
+ autoload :MediaConvertAdapter
18
+ autoload :PassThroughAdapter
16
19
 
17
- ADAPTER = 'Adapter'.freeze
20
+ ADAPTER = 'Adapter'
18
21
  private_constant :ADAPTER
19
22
 
20
23
  class << self
@@ -1,15 +1,15 @@
1
+ # frozen_string_literal: true
1
2
  require 'addressable/uri'
2
- require 'aws-sdk'
3
+ require 'aws-sdk-elastictranscoder'
3
4
  require 'file_locator'
4
5
 
5
6
  module ActiveEncode
6
7
  module EngineAdapters
7
8
  class ElasticTranscoderAdapter
8
-
9
9
  JOB_STATES = {
10
10
  "Submitted" => :running, "Progressing" => :running, "Canceled" => :cancelled,
11
11
  "Error" => :failed, "Complete" => :completed
12
- }
12
+ }.freeze
13
13
 
14
14
  # Require options to include :pipeline_id, :masterfile_bucket and :outputs
15
15
  # Example :outputs value:
@@ -29,7 +29,7 @@ module ActiveEncode
29
29
  build_encode(job)
30
30
  end
31
31
 
32
- def find(id, opts = {})
32
+ def find(id, _opts = {})
33
33
  build_encode(get_job_details(id))
34
34
  end
35
35
 
@@ -58,14 +58,14 @@ module ActiveEncode
58
58
  return nil if job.nil?
59
59
  encode = ActiveEncode::Base.new(convert_input(job), {})
60
60
  encode.id = job.id
61
- encode.state = JOB_STATES[job.status]
62
- encode.current_operations = []
63
- encode.percent_complete = convert_percent_complete(job)
64
- encode.created_at = convert_time(job.timing["submit_time_millis"])
65
- encode.updated_at = convert_time(job.timing["finish_time_millis"]) || convert_time(job.timing["start_time_millis"]) || encode.created_at
61
+ encode.state = JOB_STATES[job.status]
62
+ encode.current_operations = []
63
+ encode.percent_complete = convert_percent_complete(job)
64
+ encode.created_at = convert_time(job.timing["submit_time_millis"])
65
+ encode.updated_at = convert_time(job.timing["finish_time_millis"]) || convert_time(job.timing["start_time_millis"]) || encode.created_at
66
66
 
67
- encode.output = convert_output(job)
68
- encode.errors = job.outputs.select { |o| o.status == "Error" }.collect(&:status_detail).compact
67
+ encode.output = convert_output(job)
68
+ encode.errors = job.outputs.select { |o| o.status == "Error" }.collect(&:status_detail).compact
69
69
 
70
70
  tech_md = convert_tech_metadata(job.input.detected_properties)
71
71
  [:width, :height, :frame_rate, :duration, :file_size].each do |field|
@@ -82,7 +82,7 @@ module ActiveEncode
82
82
 
83
83
  def convert_time(time_millis)
84
84
  return nil if time_millis.nil?
85
- Time.at(time_millis / 1000)
85
+ Time.at(time_millis / 1000).utc
86
86
  end
87
87
 
88
88
  def convert_bitrate(rate)
@@ -121,38 +121,38 @@ module ActiveEncode
121
121
  end
122
122
 
123
123
  def convert_input(job)
124
- job.input
124
+ job.input.key
125
125
  end
126
126
 
127
- def copy_to_input_bucket input_url, bucket
127
+ def copy_to_input_bucket(input_url, bucket)
128
128
  case Addressable::URI.parse(input_url).scheme
129
- when nil,'file'
129
+ when nil, 'file'
130
130
  upload_to_s3 input_url, bucket
131
131
  when 's3'
132
132
  check_s3_bucket input_url, bucket
133
133
  end
134
134
  end
135
135
 
136
- def check_s3_bucket input_url, source_bucket
136
+ def check_s3_bucket(input_url, source_bucket)
137
137
  # logger.info("Checking `#{input_url}'")
138
138
  s3_object = FileLocator::S3File.new(input_url).object
139
139
  if s3_object.bucket_name == source_bucket
140
140
  # logger.info("Already in bucket `#{source_bucket}'")
141
141
  s3_object.key
142
142
  else
143
- s3_key = File.join(SecureRandom.uuid,s3_object.key)
143
+ s3_key = File.join(SecureRandom.uuid, s3_object.key)
144
144
  # logger.info("Copying to `#{source_bucket}/#{input_url}'")
145
145
  target = Aws::S3::Object.new(bucket_name: source_bucket, key: input_url)
146
- target.copy_from(s3_object, multipart_copy: s3_object.size > 15728640) # 15.megabytes
146
+ target.copy_from(s3_object, multipart_copy: s3_object.size > 15_728_640) # 15.megabytes
147
147
  s3_key
148
148
  end
149
149
  end
150
150
 
151
- def upload_to_s3 input_url, source_bucket
152
- original_input = input_url
151
+ def upload_to_s3(input_url, source_bucket)
152
+ # original_input = input_url
153
153
  bucket = Aws::S3::Resource.new(client: s3client).bucket(source_bucket)
154
154
  filename = FileLocator.new(input_url).location
155
- s3_key = File.join(SecureRandom.uuid,File.basename(filename))
155
+ s3_key = File.join(SecureRandom.uuid, File.basename(filename))
156
156
  # logger.info("Copying `#{original_input}' to `#{source_bucket}/#{input_url}'")
157
157
  obj = bucket.object(s3_key)
158
158
  obj.upload_file filename
@@ -161,20 +161,22 @@ module ActiveEncode
161
161
  end
162
162
 
163
163
  def read_preset(id)
164
- client.read_preset(id: id).preset
164
+ @presets ||= {}
165
+ @presets[id] ||= client.read_preset(id: id).preset
165
166
  end
166
167
 
167
168
  def convert_output(job)
168
- pipeline = client.read_pipeline(id: job.pipeline_id).pipeline
169
+ @pipeline ||= client.read_pipeline(id: job.pipeline_id).pipeline
169
170
  job.outputs.collect do |joutput|
170
171
  preset = read_preset(joutput.preset_id)
171
172
  extension = preset.container == 'ts' ? '.m3u8' : ''
172
- tech_md = convert_tech_metadata(joutput, preset).merge({
173
+ additional_metadata = {
173
174
  managed: false,
174
175
  id: joutput.id,
175
176
  label: joutput.key.split("/", 2).first,
176
- url: "s3://#{pipeline.output_bucket}/#{job.output_key_prefix}#{joutput.key}#{extension}"
177
- })
177
+ url: "s3://#{@pipeline.output_bucket}/#{job.output_key_prefix}#{joutput.key}#{extension}"
178
+ }
179
+ tech_md = convert_tech_metadata(joutput, preset).merge(additional_metadata)
178
180
 
179
181
  output = ActiveEncode::Output.new
180
182
  output.state = convert_state(joutput)
@@ -194,7 +196,7 @@ module ActiveEncode
194
196
  job.outputs.select { |o| o.status == "Error" }.collect(&:status_detail).compact
195
197
  end
196
198
 
197
- def convert_tech_metadata(props, preset=nil)
199
+ def convert_tech_metadata(props, preset = nil)
198
200
  return {} if props.nil? || props.empty?
199
201
  metadata_fields = {
200
202
  file_size: { key: :file_size, method: :itself },
@@ -216,13 +218,13 @@ module ActiveEncode
216
218
  unless preset.nil?
217
219
  audio = preset.audio
218
220
  video = preset.video
219
- metadata.merge!({
221
+ metadata.merge!(
220
222
  audio_codec: audio&.codec,
221
223
  audio_channels: audio&.channels,
222
224
  audio_bitrate: convert_bitrate(audio&.bit_rate),
223
225
  video_codec: video&.codec,
224
226
  video_bitrate: convert_bitrate(video&.bit_rate)
225
- })
227
+ )
226
228
  end
227
229
 
228
230
  metadata
@@ -1,16 +1,31 @@
1
+ # frozen_string_literal: true
1
2
  require 'fileutils'
2
3
  require 'nokogiri'
4
+ require 'shellwords'
3
5
 
4
6
  module ActiveEncode
5
7
  module EngineAdapters
6
8
  class FfmpegAdapter
7
9
  WORK_DIR = ENV["ENCODE_WORK_DIR"] || "encodes" # Should read from config
10
+ MEDIAINFO_PATH = ENV["MEDIAINFO_PATH"] || "mediainfo"
11
+ FFMPEG_PATH = ENV["FFMPEG_PATH"] || "ffmpeg"
8
12
 
9
13
  def create(input_url, options = {})
14
+ # Decode file uris for ffmpeg (mediainfo works either way)
15
+ case input_url
16
+ when /^file\:\/\/\//
17
+ input_url = URI.decode(input_url)
18
+ when /^s3\:\/\//
19
+ require 'file_locator'
20
+
21
+ s3_object = FileLocator::S3File.new(input_url).object
22
+ input_url = URI.parse(s3_object.presigned_url(:get))
23
+ end
24
+
10
25
  new_encode = ActiveEncode::Base.new(input_url, options)
11
26
  new_encode.id = SecureRandom.uuid
12
- new_encode.created_at = Time.new
13
- new_encode.updated_at = Time.new
27
+ new_encode.created_at = Time.now.utc
28
+ new_encode.updated_at = Time.now.utc
14
29
  new_encode.current_operations = []
15
30
  new_encode.output = []
16
31
 
@@ -19,18 +34,24 @@ module ActiveEncode
19
34
  FileUtils.mkdir_p working_path("outputs", new_encode.id)
20
35
 
21
36
  # Extract technical metadata from input file
22
- `mediainfo --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} #{input_url}`
37
+ curl_option = if options && options[:headers]
38
+ headers = options[:headers].map { |k, v| "#{k}: #{v}" }
39
+ (["--File_curl=HttpHeader"] + headers).join(",").yield_self { |s| "'#{s}'" }
40
+ else
41
+ ""
42
+ end
43
+ `#{MEDIAINFO_PATH} #{curl_option} --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} "#{input_url}"`
23
44
  new_encode.input = build_input new_encode
24
45
 
25
46
  if new_encode.input.duration.blank?
26
47
  new_encode.state = :failed
27
48
  new_encode.percent_complete = 1
28
49
 
29
- if new_encode.input.file_size.blank?
30
- new_encode.errors = ["#{input_url} does not exist or is not accessible"]
31
- else
32
- new_encode.errors = ["Error inspecting input: #{input_url}"]
33
- end
50
+ new_encode.errors = if new_encode.input.file_size.blank?
51
+ ["#{input_url} does not exist or is not accessible"]
52
+ else
53
+ ["Error inspecting input: #{input_url}"]
54
+ end
34
55
 
35
56
  write_errors new_encode
36
57
  return new_encode
@@ -42,20 +63,27 @@ module ActiveEncode
42
63
 
43
64
  # Run the ffmpeg command and save its pid
44
65
  command = ffmpeg_command(input_url, new_encode.id, options)
45
- pid = Process.spawn(command)
66
+ pid = Process.spawn(command, err: working_path('error.log', new_encode.id))
46
67
  File.open(working_path("pid", new_encode.id), 'w') { |file| file.write pid }
47
68
  new_encode.input.id = pid
48
69
 
49
- # Prevent zombie process
50
- Process.detach(pid)
51
-
52
70
  new_encode
71
+ rescue StandardError => e
72
+ new_encode.state = :failed
73
+ new_encode.percent_complete = 1
74
+ new_encode.errors = [e.full_message]
75
+ write_errors new_encode
76
+ return new_encode
77
+ ensure
78
+ # Prevent zombie process
79
+ Process.detach(pid) if pid.present?
53
80
  end
54
81
 
55
82
  # Return encode object from file system
56
- def find(id, opts={})
83
+ def find(id, opts = {})
57
84
  encode_class = opts[:cast]
58
- encode = ActiveEncode::Base.new(nil, opts)
85
+ encode_class ||= ActiveEncode::Base
86
+ encode = encode_class.new(nil, opts)
59
87
  encode.id = id
60
88
  encode.output = []
61
89
  encode.created_at, encode.updated_at = get_times encode.id
@@ -65,28 +93,21 @@ module ActiveEncode
65
93
  pid = get_pid(id)
66
94
  encode.input.id = pid if pid.present?
67
95
 
68
- if File.file? working_path("error.log", id)
69
- error = File.read working_path("error.log", id)
70
- if error.present?
71
- encode.state = :failed
72
- encode.errors = [error]
73
-
74
- return encode
75
- end
76
- end
77
-
78
- encode.errors = []
79
-
80
96
  encode.current_operations = []
81
97
  encode.created_at, encode.updated_at = get_times encode.id
82
-
83
- if running? pid
98
+ encode.errors = read_errors(id)
99
+ if encode.errors.present?
100
+ encode.state = :failed
101
+ elsif running? pid
84
102
  encode.state = :running
85
103
  encode.current_operations = ["transcoding"]
86
104
  elsif progress_ended?(encode.id) && encode.percent_complete == 100
87
- encode.state = :completed
105
+ encode.state = :completed
106
+ elsif cancelled? encode.id
107
+ encode.state = :cancelled
88
108
  elsif encode.percent_complete < 100
89
- encode.state = :cancelled
109
+ encode.errors << "Encoding has completed but the output duration is shorter than the input"
110
+ encode.state = :failed
90
111
  end
91
112
 
92
113
  encode.output = build_outputs encode if encode.completed?
@@ -96,31 +117,61 @@ module ActiveEncode
96
117
 
97
118
  # Cancel ongoing encode using pid file
98
119
  def cancel(id)
99
- pid = get_pid(id)
100
- Process.kill 'SIGTERM', pid.to_i
120
+ encode = find id
121
+ if encode.running?
122
+ pid = get_pid(id)
123
+
124
+ IO.popen("ps -ef | grep #{pid}") do |pipe|
125
+ child_pids = pipe.readlines.map do |line|
126
+ parts = line.split(/\s+/)
127
+ parts[1] if parts[2] == pid.to_s && parts[1] != pipe.pid.to_s
128
+ end.compact
129
+
130
+ child_pids.each do |cpid|
131
+ Process.kill 'SIGTERM', cpid.to_i
132
+ end
133
+ end
101
134
 
102
- find id
135
+ Process.kill 'SIGTERM', pid.to_i
136
+ File.write(working_path("cancelled", id), "")
137
+ encode = find id
138
+ end
139
+ encode
140
+ rescue Errno::ESRCH
141
+ raise NotRunningError
142
+ rescue StandardError
143
+ raise CancelError
103
144
  end
104
145
 
105
- private
146
+ private
106
147
 
107
- def get_times id
148
+ def get_times(id)
108
149
  updated_at = if File.file? working_path("progress", id)
109
- File.mtime(working_path("progress", id))
110
- elsif File.file? working_path("error.log", id)
111
- File.mtime(working_path("error.log", id))
112
- else
113
- File.mtime(working_path("input_metadata", id))
114
- end
115
-
116
- return File.mtime(working_path("input_metadata", id)), updated_at
150
+ File.mtime(working_path("progress", id))
151
+ elsif File.file? working_path("error.log", id)
152
+ File.mtime(working_path("error.log", id))
153
+ else
154
+ File.mtime(working_path("input_metadata", id))
155
+ end
156
+
157
+ [File.mtime(working_path("input_metadata", id)), updated_at]
117
158
  end
118
159
 
119
- def write_errors encode
160
+ def write_errors(encode)
120
161
  File.write(working_path("error.log", encode.id), encode.errors.join("\n"))
121
162
  end
122
163
 
123
- def build_input encode
164
+ def read_errors(id)
165
+ err_path = working_path("error.log", id)
166
+ error = File.read(err_path) if File.file? err_path
167
+ if error.present?
168
+ [error]
169
+ else
170
+ []
171
+ end
172
+ end
173
+
174
+ def build_input(encode)
124
175
  input = ActiveEncode::Input.new
125
176
  metadata = get_tech_metadata(working_path("input_metadata", encode.id))
126
177
  input.url = metadata[:url]
@@ -132,22 +183,21 @@ private
132
183
  input
133
184
  end
134
185
 
135
- def build_outputs encode
186
+ def build_outputs(encode)
136
187
  id = encode.id
137
188
  outputs = []
138
189
  Dir["#{File.absolute_path(working_path('outputs', id))}/*"].each do |file_path|
139
190
  output = ActiveEncode::Output.new
140
191
  output.url = "file://#{file_path}"
141
- original_extension = File.extname(encode.input.url)
142
- original_filename = File.basename(encode.input.url).chomp(original_extension)
143
- output.label = file_path[/#{Regexp.quote(original_filename)}\-(.*?)#{Regexp.quote(File.extname(file_path))}$/, 1]
192
+ sanitized_filename = sanitize_base encode.input.url
193
+ output.label = file_path[/#{Regexp.quote(sanitized_filename)}\-(.*?)#{Regexp.quote(File.extname(file_path))}$/, 1]
144
194
  output.id = "#{encode.input.id}-#{output.label}"
145
195
  output.created_at = encode.created_at
146
196
  output.updated_at = File.mtime file_path
147
197
 
148
198
  # Extract technical metadata from output file
149
199
  metadata_path = working_path("output_metadata-#{output.label}", id)
150
- `mediainfo --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
200
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
151
201
  output.assign_tech_metadata(get_tech_metadata(metadata_path))
152
202
 
153
203
  outputs << output
@@ -158,70 +208,75 @@ private
158
208
 
159
209
  def ffmpeg_command(input_url, id, opts)
160
210
  output_opt = opts[:outputs].collect do |output|
161
- file_name = "outputs/#{File.basename(input_url, File.extname(input_url))}-#{output[:label]}.#{output[:extension]}"
211
+ sanitized_filename = sanitize_base input_url
212
+ file_name = "outputs/#{sanitized_filename}-#{output[:label]}.#{output[:extension]}"
162
213
  " #{output[:ffmpeg_opt]} #{working_path(file_name, id)}"
163
214
  end.join(" ")
164
-
165
- "ffmpeg -y -loglevel error -progress #{working_path("progress", id)} -i #{input_url} #{output_opt} > #{working_path("error.log", id)} 2>&1"
215
+ header_opt = Array(opts[:headers]).map do |k, v|
216
+ "#{k}: #{v}\r\n"
217
+ end.join
218
+ header_opt = "-headers '#{header_opt}'" if header_opt.present?
219
+ "#{FFMPEG_PATH} #{header_opt} -y -loglevel error -progress #{working_path('progress', id)} -i \"#{input_url}\" #{output_opt}"
166
220
  end
167
221
 
168
- def get_pid(id)
169
- if File.file? working_path("pid", id)
170
- File.read(working_path("pid", id)).remove("\n")
222
+ def sanitize_base(input_url)
223
+ if input_url.is_a? URI::HTTP
224
+ File.basename(input_url.path, File.extname(input_url.path))
171
225
  else
172
- nil
226
+ File.basename(input_url, File.extname(input_url)).gsub(/[^0-9A-Za-z.\-]/, '_')
173
227
  end
174
228
  end
175
229
 
230
+ def get_pid(id)
231
+ File.read(working_path("pid", id)).remove("\n") if File.file? working_path("pid", id)
232
+ end
233
+
176
234
  def working_path(path, id)
177
235
  File.join(WORK_DIR, id, path)
178
236
  end
179
237
 
180
238
  def running?(pid)
181
- begin
182
- Process.getpgid pid.to_i
183
- true
184
- rescue Errno::ESRCH
185
- false
186
- end
239
+ Process.getpgid pid.to_i
240
+ true
241
+ rescue Errno::ESRCH
242
+ false
187
243
  end
188
244
 
189
- def calculate_percent_complete encode
245
+ def calculate_percent_complete(encode)
190
246
  data = read_progress encode.id
191
247
  if data.blank?
192
248
  1
193
249
  else
194
250
  progress_in_milliseconds = progress_value("out_time_ms=", data).to_i / 1000.0
195
- (progress_in_milliseconds / encode.input.duration * 100).round
251
+ output = (progress_in_milliseconds / encode.input.duration * 100).ceil
252
+ return 100 if output > 100
253
+ output
196
254
  end
197
255
  end
198
256
 
199
- def read_progress id
200
- if File.file? working_path("progress", id)
201
- File.read working_path("progress", id)
202
- else
203
- nil
204
- end
257
+ def cancelled?(id)
258
+ File.exist? working_path("cancelled", id)
259
+ end
260
+
261
+ def read_progress(id)
262
+ File.read working_path("progress", id) if File.file? working_path("progress", id)
205
263
  end
206
264
 
207
- def progress_ended? id
265
+ def progress_ended?(id)
208
266
  "end" == progress_value("progress=", read_progress(id))
209
267
  end
210
268
 
211
- def progress_value key, data
212
- if data.present? && key.present?
213
- ri = data.rindex(key) + key.length
214
- data[ri..data.index("\n", ri)-1]
215
- else
216
- nil
217
- end
269
+ def progress_value(key, data)
270
+ return nil unless data.present? && key.present?
271
+ ri = data.rindex(key) + key.length
272
+ data[ri..data.index("\n", ri) - 1]
218
273
  end
219
274
 
220
- def get_tech_metadata file_path
275
+ def get_tech_metadata(file_path)
221
276
  doc = Nokogiri::XML File.read(file_path)
222
277
  doc.remove_namespaces!
223
278
  duration = get_xpath_text(doc, '//Duration/text()', :to_f)
224
- duration = duration * 1000 unless duration.nil? # Convert to milliseconds
279
+ duration *= 1000 unless duration.nil? # Convert to milliseconds
225
280
  { url: get_xpath_text(doc, '//media/@ref', :to_s),
226
281
  width: get_xpath_text(doc, '//Width/text()', :to_f),
227
282
  height: get_xpath_text(doc, '//Height/text()', :to_f),
@@ -234,12 +289,8 @@ private
234
289
  video_bitrate: get_xpath_text(doc, '//track[@type="Video"]/BitRate/text()', :to_i) }
235
290
  end
236
291
 
237
- def get_xpath_text doc, xpath, cast_method
238
- if doc.xpath(xpath).first
239
- doc.xpath(xpath).first.text.send(cast_method)
240
- else
241
- nil
242
- end
292
+ def get_xpath_text(doc, xpath, cast_method)
293
+ doc.xpath(xpath).first&.text&.send(cast_method)
243
294
  end
244
295
  end
245
296
  end