active_encode 0.6.0 → 0.7.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.
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