active_encode 0.6.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +26 -18
  3. data/.rubocop_todo.yml +3 -0
  4. data/active_encode.gemspec +3 -0
  5. data/lib/active_encode/base.rb +1 -2
  6. data/lib/active_encode/engine_adapters.rb +1 -0
  7. data/lib/active_encode/engine_adapters/elastic_transcoder_adapter.rb +5 -4
  8. data/lib/active_encode/engine_adapters/ffmpeg_adapter.rb +86 -30
  9. data/lib/active_encode/engine_adapters/pass_through_adapter.rb +239 -0
  10. data/lib/active_encode/errors.rb +6 -0
  11. data/lib/active_encode/persistence.rb +5 -3
  12. data/lib/active_encode/version.rb +1 -1
  13. data/spec/controllers/encode_record_controller_spec.rb +2 -2
  14. data/spec/fixtures/ffmpeg/cancelled-id/cancelled +0 -0
  15. data/spec/fixtures/file with space.low.mp4 +0 -0
  16. data/spec/fixtures/file with space.mp4 +0 -0
  17. data/spec/fixtures/fireworks.low.mp4 +0 -0
  18. data/spec/fixtures/pass_through/cancelled-id/cancelled +0 -0
  19. data/spec/fixtures/pass_through/cancelled-id/input_metadata +90 -0
  20. data/spec/fixtures/pass_through/completed-id/completed +0 -0
  21. data/spec/fixtures/pass_through/completed-id/input_metadata +102 -0
  22. data/spec/fixtures/pass_through/completed-id/output_metadata-high +90 -0
  23. data/spec/fixtures/pass_through/completed-id/output_metadata-low +90 -0
  24. data/spec/fixtures/pass_through/completed-id/video-high.mp4 +0 -0
  25. data/spec/fixtures/pass_through/completed-id/video-low.mp4 +0 -0
  26. data/spec/fixtures/pass_through/failed-id/error.log +1 -0
  27. data/spec/fixtures/pass_through/failed-id/input_metadata +90 -0
  28. data/spec/fixtures/pass_through/running-id/input_metadata +90 -0
  29. data/spec/integration/ffmpeg_adapter_spec.rb +66 -2
  30. data/spec/integration/pass_through_adapter_spec.rb +151 -0
  31. data/spec/shared_specs/engine_adapter_specs.rb +7 -8
  32. metadata +50 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22be45555a3ea9208b56552c17e86a0052865e753cd2cd03fae9a876ebee6964
4
- data.tar.gz: 55ba9a105b56e2d660b8bbe237cee26dc10caea24c87dd0b7e81a46b2ad28979
3
+ metadata.gz: 5aed6c18b1e8d3b2a0ecb4e8dd296660f2af65ca00973edad791b0315964da4c
4
+ data.tar.gz: 2ce90e1367403bc7d45ffb3d3ba5ada6991ca1f80d0a1b59944abeb63d47d372
5
5
  SHA512:
6
- metadata.gz: b3ced87039d84e596fc2f011f70b586ef46d2501af00d94e64e91a2241df93c9e93b153814125454eb8af96ae63a2289188067fa4193fe01ee6568d5b9471113
7
- data.tar.gz: 5dc21ccd48592fe2460599404f17c886f242f77a57cd3dbc9a8064176da44f9f1566a9cbf2c529491f037f25da252e0dd3fe40e345b15d9310b7c4464add310a
6
+ metadata.gz: a1edfa784257928dc9d06463434a4132a7ab07995a5679aca9051aa68335dd1ed644cd2c5f9544dc439911f29087d2af1b358c839ef2692a29a0478fae5e5ab2
7
+ data.tar.gz: f3702f56f989045165a70b29850fc7e480f02c85877912db3115b4a9909e39673090d79fe23127a0a1a30d5b0be96c15f6bba4ea04af751b388531b864786e33
@@ -1,6 +1,6 @@
1
1
  version: 2.1
2
2
  orbs:
3
- samvera: samvera/circleci-orb@0
3
+ samvera: samvera/circleci-orb@0.3.2
4
4
  jobs:
5
5
  bundle_and_test:
6
6
  parameters:
@@ -12,35 +12,50 @@ jobs:
12
12
  default: 5.2.3
13
13
  bundler_version:
14
14
  type: string
15
- default: 2.0.1
15
+ default: 2.0.2
16
+ ffmpeg_version:
17
+ type: string
18
+ default: 4.1.4
16
19
  executor:
17
20
  name: 'samvera/ruby'
18
21
  ruby_version: << parameters.ruby_version >>
19
22
  environment:
20
23
  ENGINE_CART_RAILS_OPTIONS: --skip-git --skip-bundle --skip-listen --skip-spring --skip-yarn --skip-keeps --skip-coffee --skip-puma --skip-test
21
24
  RAILS_VERSION: << parameters.rails_version >>
25
+ FFMPEG_PATH: /tmp/ffmpeg
22
26
  working_directory: ~/project
23
27
  steps:
24
- - run: sudo apt-get install libmms0
25
- - run: sudo wget -P /tmp/ https://mediaarea.net/download/binary/libzen0/0.4.37/libzen0v5_0.4.37-1_amd64.Debian_9.0.deb
26
- - run: sudo wget -P /tmp/ https://mediaarea.net/download/binary/libmediainfo0/19.04/libmediainfo0v5_19.04-1_amd64.Debian_9.0.deb
27
- - run: sudo wget -P /tmp/ https://mediaarea.net/download/binary/mediainfo/19.04/mediainfo_19.04-1_amd64.Debian_9.0.deb
28
- - run: sudo dpkg -i /tmp/libzen0v5_0.4.37-1_amd64.Debian_9.0.deb /tmp/libmediainfo0v5_19.04-1_amd64.Debian_9.0.deb /tmp/mediainfo_19.04-1_amd64.Debian_9.0.deb
28
+ - run:
29
+ command: |
30
+ sudo apt-get install libmms0
31
+ sudo wget -P /tmp/ https://mediaarea.net/download/binary/libzen0/0.4.37/libzen0v5_0.4.37-1_amd64.Debian_9.0.deb
32
+ sudo wget -P /tmp/ https://mediaarea.net/download/binary/libmediainfo0/19.04/libmediainfo0v5_19.04-1_amd64.Debian_9.0.deb
33
+ sudo wget -P /tmp/ https://mediaarea.net/download/binary/mediainfo/19.04/mediainfo_19.04-1_amd64.Debian_9.0.deb
34
+ sudo dpkg -i /tmp/libzen0v5_0.4.37-1_amd64.Debian_9.0.deb /tmp/libmediainfo0v5_19.04-1_amd64.Debian_9.0.deb /tmp/mediainfo_19.04-1_amd64.Debian_9.0.deb
35
+
36
+ - restore_cache:
37
+ keys:
38
+ - v1-ffmpeg-<< parameters.ffmpeg_version >>
39
+
40
+ - run: curl https://www.johnvansickle.com/ffmpeg/old-releases/ffmpeg-<< parameters.ffmpeg_version >>-amd64-static.tar.xz | tar xJ -C /tmp/ --strip-components=1
41
+
42
+ - save_cache:
43
+ key: v1-ffmpeg-<< parameters.ffmpeg_version >>`
44
+ paths:
45
+ - /tmp/ffmpeg
29
46
 
30
47
  - samvera/cached_checkout
31
48
 
32
- - samvera/bundle_for_gem:
49
+ - samvera/bundle:
33
50
  ruby_version: << parameters.ruby_version >>
34
51
  bundler_version: << parameters.bundler_version >>
35
- project: active_encode
36
52
 
37
53
  - samvera/engine_cart_generate:
38
54
  cache_key: v2-internal-test-app-{{ checksum "active_encode.gemspec" }}-{{ checksum "spec/test_app_templates/lib/generators/test_app_generator.rb" }}-<< parameters.rails_version >>-<< parameters.ruby_version >>
39
55
 
40
- - samvera/bundle_for_gem:
56
+ - samvera/bundle:
41
57
  ruby_version: << parameters.ruby_version >>
42
58
  bundler_version: << parameters.bundler_version >>
43
- project: active_encode
44
59
 
45
60
  - samvera/rubocop
46
61
 
@@ -55,9 +70,6 @@ workflows:
55
70
  - bundle_and_test:
56
71
  name: "ruby2-5_rails5-2"
57
72
  ruby_version: "2.5.5"
58
- - bundle_and_test:
59
- name: "ruby2-4_rails5-2"
60
- ruby_version: "2.4.6"
61
73
  - bundle_and_test:
62
74
  name: "ruby2-6_rails5-1"
63
75
  ruby_version: "2.6.3"
@@ -66,7 +78,3 @@ workflows:
66
78
  name: "ruby2-5_rails5-1"
67
79
  ruby_version: "2.5.5"
68
80
  rails_version: "5.1.7"
69
- - bundle_and_test:
70
- name: "ruby2-4_rails5-1"
71
- ruby_version: "2.4.6"
72
- rails_version: "5.1.7"
@@ -40,6 +40,7 @@ Metrics/PerceivedComplexity:
40
40
  RSpec/AnyInstance:
41
41
  Exclude:
42
42
  - 'spec/integration/ffmpeg_adapter_spec.rb'
43
+ - 'spec/integration/pass_through_adapter_spec.rb'
43
44
 
44
45
  RSpec/ExampleLength:
45
46
  Enabled: false
@@ -47,6 +48,7 @@ RSpec/ExampleLength:
47
48
  RSpec/InstanceVariable:
48
49
  Exclude:
49
50
  - 'spec/integration/ffmpeg_adapter_spec.rb'
51
+ - 'spec/integration/pass_through_adapter_spec.rb'
50
52
 
51
53
  RSpec/MessageSpies:
52
54
  Exclude:
@@ -61,4 +63,5 @@ RSpec/VerifiedDoubles:
61
63
 
62
64
  Style/GuardClause:
63
65
  Exclude:
66
+ - 'lib/active_encode/engine_adapters/pass_through_adapter.rb'
64
67
  - 'lib/file_locator.rb'
@@ -33,4 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.add_development_dependency "rspec-its"
34
34
  spec.add_development_dependency 'rspec_junit_formatter'
35
35
  spec.add_development_dependency "rspec-rails"
36
+
37
+ # Pin sprockets to < 4 so it works with ruby 2.5+
38
+ spec.add_dependency 'sprockets', '< 4'
36
39
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require 'active_encode/core'
3
3
  require 'active_encode/engine_adapter'
4
+ require 'active_encode/errors'
4
5
  require 'active_encode/status'
5
6
  require 'active_encode/technical_metadata'
6
7
  require 'active_encode/input'
@@ -18,6 +19,4 @@ module ActiveEncode #:nodoc:
18
19
  include Callbacks
19
20
  include GlobalID
20
21
  end
21
-
22
- class NotFound < RuntimeError; end
23
22
  end
@@ -14,6 +14,7 @@ module ActiveEncode
14
14
  autoload :ElasticTranscoderAdapter
15
15
  autoload :TestAdapter
16
16
  autoload :FfmpegAdapter
17
+ autoload :PassThroughAdapter
17
18
 
18
19
  ADAPTER = 'Adapter'
19
20
  private_constant :ADAPTER
@@ -121,7 +121,7 @@ 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
127
  def copy_to_input_bucket(input_url, bucket)
@@ -161,11 +161,12 @@ 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' : ''
@@ -173,7 +174,7 @@ module ActiveEncode
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
+ url: "s3://#{@pipeline.output_bucket}/#{job.output_key_prefix}#{joutput.key}#{extension}"
177
178
  }
178
179
  tech_md = convert_tech_metadata(joutput, preset).merge(additional_metadata)
179
180
 
@@ -1,13 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
  require 'fileutils'
3
3
  require 'nokogiri'
4
+ require 'shellwords'
4
5
 
5
6
  module ActiveEncode
6
7
  module EngineAdapters
7
8
  class FfmpegAdapter
8
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"
9
12
 
10
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
+
11
25
  new_encode = ActiveEncode::Base.new(input_url, options)
12
26
  new_encode.id = SecureRandom.uuid
13
27
  new_encode.created_at = Time.now.utc
@@ -20,7 +34,7 @@ module ActiveEncode
20
34
  FileUtils.mkdir_p working_path("outputs", new_encode.id)
21
35
 
22
36
  # Extract technical metadata from input file
23
- `mediainfo --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} #{input_url}`
37
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} "#{input_url}"`
24
38
  new_encode.input = build_input new_encode
25
39
 
26
40
  if new_encode.input.duration.blank?
@@ -43,14 +57,20 @@ module ActiveEncode
43
57
 
44
58
  # Run the ffmpeg command and save its pid
45
59
  command = ffmpeg_command(input_url, new_encode.id, options)
46
- pid = Process.spawn(command)
60
+ pid = Process.spawn(command, err: working_path('error.log', new_encode.id))
47
61
  File.open(working_path("pid", new_encode.id), 'w') { |file| file.write pid }
48
62
  new_encode.input.id = pid
49
63
 
50
- # Prevent zombie process
51
- Process.detach(pid)
52
-
53
64
  new_encode
65
+ rescue StandardError => e
66
+ new_encode.state = :failed
67
+ new_encode.percent_complete = 1
68
+ new_encode.errors = [e.full_message]
69
+ write_errors new_encode
70
+ return new_encode
71
+ ensure
72
+ # Prevent zombie process
73
+ Process.detach(pid) if pid.present?
54
74
  end
55
75
 
56
76
  # Return encode object from file system
@@ -67,28 +87,21 @@ module ActiveEncode
67
87
  pid = get_pid(id)
68
88
  encode.input.id = pid if pid.present?
69
89
 
70
- if File.file? working_path("error.log", id)
71
- error = File.read working_path("error.log", id)
72
- if error.present?
73
- encode.state = :failed
74
- encode.errors = [error]
75
-
76
- return encode
77
- end
78
- end
79
-
80
- encode.errors = []
81
-
82
90
  encode.current_operations = []
83
91
  encode.created_at, encode.updated_at = get_times encode.id
84
-
85
- if running? pid
92
+ encode.errors = read_errors(id)
93
+ if encode.errors.present?
94
+ encode.state = :failed
95
+ elsif running? pid
86
96
  encode.state = :running
87
97
  encode.current_operations = ["transcoding"]
88
98
  elsif progress_ended?(encode.id) && encode.percent_complete == 100
89
99
  encode.state = :completed
90
- elsif encode.percent_complete < 100
100
+ elsif cancelled? encode.id
91
101
  encode.state = :cancelled
102
+ elsif encode.percent_complete < 100
103
+ encode.errors << "Encoding has completed but the output duration is shorter than the input"
104
+ encode.state = :failed
92
105
  end
93
106
 
94
107
  encode.output = build_outputs encode if encode.completed?
@@ -98,10 +111,30 @@ module ActiveEncode
98
111
 
99
112
  # Cancel ongoing encode using pid file
100
113
  def cancel(id)
101
- pid = get_pid(id)
102
- Process.kill 'SIGTERM', pid.to_i
114
+ encode = find id
115
+ if encode.running?
116
+ pid = get_pid(id)
117
+
118
+ IO.popen("ps -ef | grep #{pid}") do |pipe|
119
+ child_pids = pipe.readlines.map do |line|
120
+ parts = line.split(/\s+/)
121
+ parts[1] if parts[2] == pid.to_s && parts[1] != pipe.pid.to_s
122
+ end.compact
123
+
124
+ child_pids.each do |cpid|
125
+ Process.kill 'SIGTERM', cpid.to_i
126
+ end
127
+ end
103
128
 
104
- find id
129
+ Process.kill 'SIGTERM', pid.to_i
130
+ File.write(working_path("cancelled", id), "")
131
+ encode = find id
132
+ end
133
+ encode
134
+ rescue Errno::ESRCH
135
+ raise NotRunningError
136
+ rescue StandardError
137
+ raise CancelError
105
138
  end
106
139
 
107
140
  private
@@ -122,6 +155,16 @@ module ActiveEncode
122
155
  File.write(working_path("error.log", encode.id), encode.errors.join("\n"))
123
156
  end
124
157
 
158
+ def read_errors(id)
159
+ err_path = working_path("error.log", id)
160
+ error = File.read(err_path) if File.file? err_path
161
+ if error.present?
162
+ [error]
163
+ else
164
+ []
165
+ end
166
+ end
167
+
125
168
  def build_input(encode)
126
169
  input = ActiveEncode::Input.new
127
170
  metadata = get_tech_metadata(working_path("input_metadata", encode.id))
@@ -140,16 +183,15 @@ module ActiveEncode
140
183
  Dir["#{File.absolute_path(working_path('outputs', id))}/*"].each do |file_path|
141
184
  output = ActiveEncode::Output.new
142
185
  output.url = "file://#{file_path}"
143
- original_extension = File.extname(encode.input.url)
144
- original_filename = File.basename(encode.input.url).chomp(original_extension)
145
- output.label = file_path[/#{Regexp.quote(original_filename)}\-(.*?)#{Regexp.quote(File.extname(file_path))}$/, 1]
186
+ sanitized_filename = sanitize_base encode.input.url
187
+ output.label = file_path[/#{Regexp.quote(sanitized_filename)}\-(.*?)#{Regexp.quote(File.extname(file_path))}$/, 1]
146
188
  output.id = "#{encode.input.id}-#{output.label}"
147
189
  output.created_at = encode.created_at
148
190
  output.updated_at = File.mtime file_path
149
191
 
150
192
  # Extract technical metadata from output file
151
193
  metadata_path = working_path("output_metadata-#{output.label}", id)
152
- `mediainfo --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
194
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
153
195
  output.assign_tech_metadata(get_tech_metadata(metadata_path))
154
196
 
155
197
  outputs << output
@@ -160,11 +202,19 @@ module ActiveEncode
160
202
 
161
203
  def ffmpeg_command(input_url, id, opts)
162
204
  output_opt = opts[:outputs].collect do |output|
163
- file_name = "outputs/#{File.basename(input_url, File.extname(input_url))}-#{output[:label]}.#{output[:extension]}"
205
+ sanitized_filename = sanitize_base input_url
206
+ file_name = "outputs/#{sanitized_filename}-#{output[:label]}.#{output[:extension]}"
164
207
  " #{output[:ffmpeg_opt]} #{working_path(file_name, id)}"
165
208
  end.join(" ")
209
+ "#{FFMPEG_PATH} -y -loglevel error -progress #{working_path('progress', id)} -i \"#{input_url}\" #{output_opt}"
210
+ end
166
211
 
167
- "ffmpeg -y -loglevel error -progress #{working_path('progress', id)} -i #{input_url} #{output_opt} > #{working_path('error.log', id)} 2>&1"
212
+ def sanitize_base(input_url)
213
+ if input_url.is_a? URI::HTTP
214
+ File.basename(input_url.path, File.extname(input_url.path))
215
+ else
216
+ File.basename(input_url, File.extname(input_url)).gsub(/[^0-9A-Za-z.\-]/, '_')
217
+ end
168
218
  end
169
219
 
170
220
  def get_pid(id)
@@ -188,10 +238,16 @@ module ActiveEncode
188
238
  1
189
239
  else
190
240
  progress_in_milliseconds = progress_value("out_time_ms=", data).to_i / 1000.0
191
- (progress_in_milliseconds / encode.input.duration * 100).round
241
+ output = (progress_in_milliseconds / encode.input.duration * 100).ceil
242
+ return 100 if output > 100
243
+ output
192
244
  end
193
245
  end
194
246
 
247
+ def cancelled?(id)
248
+ File.exist? working_path("cancelled", id)
249
+ end
250
+
195
251
  def read_progress(id)
196
252
  File.read working_path("progress", id) if File.file? working_path("progress", id)
197
253
  end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+ require 'fileutils'
3
+ require 'nokogiri'
4
+ require 'shellwords'
5
+ require 'file_locator'
6
+
7
+ # PassThroughAdapter accepts an input file url and a number of derivative urls in the options
8
+ # E.g. `create(input, outputs: [{ label: 'low', url: 'file:///derivatives/low.mp4' }, { label: 'high', url: 'file:///derivatives/high.mp4' }])`
9
+ # This adapter mirrors the ffmpeg adapter but differs in a few ways:
10
+ # 1. It starts by copying the derivative files to the work directory
11
+ # 2. It runs Mediainfo on the input and output files and skips ffmpeg
12
+ # 3. All work is done in the create method so it's status is always completed or failed
13
+ module ActiveEncode
14
+ module EngineAdapters
15
+ class PassThroughAdapter
16
+ WORK_DIR = ENV["ENCODE_WORK_DIR"] || "encodes" # Should read from config
17
+ MEDIAINFO_PATH = ENV["MEDIAINFO_PATH"] || "mediainfo"
18
+
19
+ def create(input_url, options = {})
20
+ # Decode file uris for ffmpeg (mediainfo works either way)
21
+ input_url = URI.decode(input_url) if input_url.starts_with? "file:///"
22
+
23
+ new_encode = ActiveEncode::Base.new(input_url, options)
24
+ new_encode.id = SecureRandom.uuid
25
+ new_encode.current_operations = []
26
+ new_encode.output = []
27
+
28
+ # Create a working directory that holds all output files related to the encode
29
+ FileUtils.mkdir_p working_path("", new_encode.id)
30
+ FileUtils.mkdir_p working_path("outputs", new_encode.id)
31
+
32
+ # Extract technical metadata from input file
33
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{working_path("input_metadata", new_encode.id)} #{input_url.shellescape}`
34
+ new_encode.input = build_input new_encode
35
+ new_encode.input.id = new_encode.id
36
+ new_encode.created_at, new_encode.updated_at = get_times new_encode.id
37
+
38
+ if new_encode.input.duration.blank?
39
+ new_encode.state = :failed
40
+ new_encode.percent_complete = 1
41
+
42
+ new_encode.errors = if new_encode.input.file_size.blank?
43
+ ["#{input_url} does not exist or is not accessible"]
44
+ else
45
+ ["Error inspecting input: #{input_url}"]
46
+ end
47
+
48
+ write_errors new_encode
49
+ return new_encode
50
+ end
51
+
52
+ # For saving filename to label map used to find the label when building outputs
53
+ filename_label_hash = {}
54
+
55
+ # Copy derivatives to work directory
56
+ options[:outputs].each do |opt|
57
+ url = opt[:url]
58
+ output_path = working_path("outputs/#{sanitize_base opt[:url]}#{File.extname opt[:url]}", new_encode.id)
59
+ FileUtils.cp FileLocator.new(url).location, output_path
60
+ filename_label_hash[output_path] = opt[:label]
61
+ end
62
+
63
+ # Write filename-to-label map so we can retrieve them on build_output
64
+ File.write working_path("filename_label.yml", new_encode.id), filename_label_hash.to_yaml
65
+
66
+ new_encode.percent_complete = 1
67
+ new_encode.state = :running
68
+ new_encode.errors = []
69
+
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
+ end
78
+
79
+ # Return encode object from file system
80
+ def find(id, opts = {})
81
+ encode_class = opts[:cast]
82
+ encode_class ||= ActiveEncode::Base
83
+ encode = encode_class.new(nil, opts)
84
+ encode.id = id
85
+ encode.created_at, encode.updated_at = get_times encode.id
86
+ encode.input = build_input encode
87
+ encode.input.id = encode.id
88
+ encode.output = []
89
+ encode.current_operations = []
90
+
91
+ encode.errors = read_errors(id)
92
+ if encode.errors.present?
93
+ encode.state = :failed
94
+ encode.percent_complete = 1
95
+ elsif cancelled?(id)
96
+ encode.state = :cancelled
97
+ encode.percent_complete = 1
98
+ elsif completed?(id)
99
+ encode.state = :completed
100
+ encode.percent_complete = 100
101
+ else
102
+ encode.output = build_outputs encode
103
+ encode.state = :completed
104
+ encode.percent_complete = 100
105
+ end
106
+
107
+ encode
108
+ rescue StandardError => e
109
+ encode.state = :failed
110
+ encode.percent_complete = 1
111
+ encode.errors = [e.full_message]
112
+ write_errors encode
113
+ return encode
114
+ end
115
+
116
+ # Cancel ongoing encode using pid file
117
+ def cancel(id)
118
+ # Check for errors and if not then create cancelled file else raise CancelError?
119
+ if running?(id)
120
+ File.write(working_path("cancelled", id), "")
121
+ find id
122
+ else
123
+ raise CancelError
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def running?(id)
130
+ !cancelled?(id) || !failed?(id) || !completed?(id)
131
+ end
132
+
133
+ def cancelled?(id)
134
+ File.exist? working_path("cancelled", id)
135
+ end
136
+
137
+ def failed?(id)
138
+ read_errors(id).present?
139
+ end
140
+
141
+ def completed?(id)
142
+ File.exist? working_path("completed", id)
143
+ end
144
+
145
+ def get_times(id)
146
+ updated_at = if File.file? working_path("completed", id)
147
+ File.mtime(working_path("completed", id))
148
+ elsif File.file? working_path("cancelled", id)
149
+ File.mtime(working_path("cancelled", id))
150
+ elsif File.file? working_path("error.log", id)
151
+ File.mtime(working_path("error.log", id))
152
+ else
153
+ File.mtime(working_path("input_metadata", id))
154
+ end
155
+
156
+ [File.mtime(working_path("input_metadata", id)), updated_at]
157
+ end
158
+
159
+ def write_errors(encode)
160
+ File.write(working_path("error.log", encode.id), encode.errors.join("\n"))
161
+ end
162
+
163
+ def read_errors(id)
164
+ err_path = working_path("error.log", id)
165
+ error = File.read(err_path) if File.file? err_path
166
+ if error.present?
167
+ [error]
168
+ else
169
+ []
170
+ end
171
+ end
172
+
173
+ def build_input(encode)
174
+ input = ActiveEncode::Input.new
175
+ metadata = get_tech_metadata(working_path("input_metadata", encode.id))
176
+ input.url = metadata[:url]
177
+ input.assign_tech_metadata(metadata)
178
+ created_at = File.mtime(working_path("input_metadata", encode.id))
179
+ input.created_at = created_at
180
+ input.updated_at = created_at
181
+
182
+ input
183
+ end
184
+
185
+ def build_outputs(encode)
186
+ id = encode.id
187
+ outputs = []
188
+ filename_label_hash = YAML.safe_load(File.read(working_path("filename_label.yml", id))) if File.exist?(working_path("filename_label.yml", id))
189
+ Dir["#{File.absolute_path(working_path('outputs', id))}/*"].each do |file_path|
190
+ output = ActiveEncode::Output.new
191
+ output.url = "file://#{file_path}"
192
+ output.label = filename_label_hash[file_path] if filename_label_hash
193
+ output.id = "#{encode.input.id}-#{output.label}"
194
+ output.created_at = encode.created_at
195
+ output.updated_at = File.mtime file_path
196
+
197
+ # Extract technical metadata from output file
198
+ metadata_path = working_path("output_metadata-#{output.label}", id)
199
+ `#{MEDIAINFO_PATH} --Output=XML --LogFile=#{metadata_path} #{output.url}` unless File.file? metadata_path
200
+ output.assign_tech_metadata(get_tech_metadata(metadata_path))
201
+
202
+ outputs << output
203
+ end
204
+ File.write(working_path("completed", id), "")
205
+
206
+ outputs
207
+ end
208
+
209
+ def sanitize_base(input_url)
210
+ File.basename(input_url, File.extname(input_url)).gsub(/[^0-9A-Za-z.\-]/, '_')
211
+ end
212
+
213
+ def working_path(path, id)
214
+ File.join(WORK_DIR, id, path)
215
+ end
216
+
217
+ def get_tech_metadata(file_path)
218
+ doc = Nokogiri::XML File.read(file_path)
219
+ doc.remove_namespaces!
220
+ duration = get_xpath_text(doc, '//Duration/text()', :to_f)
221
+ duration *= 1000 unless duration.nil? # Convert to milliseconds
222
+ { url: get_xpath_text(doc, '//media/@ref', :to_s),
223
+ width: get_xpath_text(doc, '//Width/text()', :to_f),
224
+ height: get_xpath_text(doc, '//Height/text()', :to_f),
225
+ frame_rate: get_xpath_text(doc, '//FrameRate/text()', :to_f),
226
+ duration: duration,
227
+ file_size: get_xpath_text(doc, '//FileSize/text()', :to_i),
228
+ audio_codec: get_xpath_text(doc, '//track[@type="Audio"]/CodecID/text()', :to_s),
229
+ audio_bitrate: get_xpath_text(doc, '//track[@type="Audio"]/BitRate/text()', :to_i),
230
+ video_codec: get_xpath_text(doc, '//track[@type="Video"]/CodecID/text()', :to_s),
231
+ video_bitrate: get_xpath_text(doc, '//track[@type="Video"]/BitRate/text()', :to_i) }
232
+ end
233
+
234
+ def get_xpath_text(doc, xpath, cast_method)
235
+ doc.xpath(xpath).first&.text&.send(cast_method)
236
+ end
237
+ end
238
+ end
239
+ end