active_encode 0.8.0 → 1.0.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +26 -17
  3. data/.rubocop.yml +7 -3
  4. data/.rubocop_todo.yml +8 -1
  5. data/CONTRIBUTING.md +42 -12
  6. data/Gemfile +11 -11
  7. data/README.md +64 -10
  8. data/active_encode.gemspec +2 -4
  9. data/app/controllers/active_encode/encode_record_controller.rb +1 -1
  10. data/app/jobs/active_encode/polling_job.rb +1 -1
  11. data/app/models/active_encode/encode_record.rb +1 -1
  12. data/guides/media_convert_adapter.md +208 -0
  13. data/lib/active_encode/base.rb +1 -1
  14. data/lib/active_encode/core.rb +14 -14
  15. data/lib/active_encode/engine_adapter.rb +13 -13
  16. data/lib/active_encode/engine_adapters/elastic_transcoder_adapter.rb +158 -158
  17. data/lib/active_encode/engine_adapters/ffmpeg_adapter.rb +14 -3
  18. data/lib/active_encode/engine_adapters/matterhorn_adapter.rb +204 -202
  19. data/lib/active_encode/engine_adapters/media_convert_adapter.rb +435 -203
  20. data/lib/active_encode/engine_adapters/media_convert_output.rb +67 -5
  21. data/lib/active_encode/engine_adapters/pass_through_adapter.rb +3 -3
  22. data/lib/active_encode/engine_adapters/zencoder_adapter.rb +114 -114
  23. data/lib/active_encode/errors.rb +1 -1
  24. data/lib/active_encode/persistence.rb +19 -19
  25. data/lib/active_encode/version.rb +1 -1
  26. data/lib/file_locator.rb +6 -6
  27. data/spec/fixtures/ffmpeg/cancelled-id/exit_status.code +1 -0
  28. data/spec/fixtures/ffmpeg/completed-id/exit_status.code +1 -0
  29. data/spec/fixtures/ffmpeg/completed-with-warnings-id/error.log +3 -0
  30. data/spec/fixtures/ffmpeg/completed-with-warnings-id/exit_status.code +1 -0
  31. data/spec/fixtures/ffmpeg/completed-with-warnings-id/input_metadata +102 -0
  32. data/spec/fixtures/ffmpeg/completed-with-warnings-id/output_metadata-high +90 -0
  33. data/spec/fixtures/ffmpeg/completed-with-warnings-id/output_metadata-low +90 -0
  34. data/spec/fixtures/ffmpeg/completed-with-warnings-id/pid +1 -0
  35. data/spec/fixtures/ffmpeg/completed-with-warnings-id/progress +11 -0
  36. data/spec/fixtures/ffmpeg/completed-with-warnings-id/video-high.mp4 +0 -0
  37. data/spec/fixtures/ffmpeg/completed-with-warnings-id/video-low.mp4 +0 -0
  38. data/spec/fixtures/ffmpeg/failed-id/exit_status.code +1 -0
  39. data/spec/fixtures/media_convert/job_completed_empty_detail.json +1 -0
  40. data/spec/integration/ffmpeg_adapter_spec.rb +50 -1
  41. data/spec/integration/matterhorn_adapter_spec.rb +1 -2
  42. data/spec/integration/media_convert_adapter_spec.rb +144 -0
  43. data/spec/integration/pass_through_adapter_spec.rb +2 -2
  44. data/spec/integration/zencoder_adapter_spec.rb +3 -3
  45. data/spec/units/core_spec.rb +1 -1
  46. data/spec/units/file_locator_spec.rb +3 -3
  47. data/spec/units/status_spec.rb +1 -1
  48. metadata +52 -19
@@ -12,32 +12,72 @@ require 'active_support/time'
12
12
 
13
13
  module ActiveEncode
14
14
  module EngineAdapters
15
+ # An adapter for using [AWS Elemental MediaConvert](https://aws.amazon.com/mediaconvert/) to
16
+ # encode.
17
+ #
18
+ # Note: this adapter does not perform input characterization, does not provide technical
19
+ # metadata on inputs.
20
+ #
21
+ # ## Configuration
22
+ #
23
+ # ActiveEncode::Base.engine_adapter = :media_convert
24
+ #
25
+ # ActiveEncode::Base.engine_adapter.role = 'arn:aws:iam::123456789012:role/service-role/MediaConvert_Default_Role'
26
+ # ActiveEncode::Base.engine_adapter.output_bucket = 'output-bucket'
27
+ #
28
+ # # optionally and probably not needed
29
+ #
30
+ # ActiveEncode::Base.engine_adapter.queue = my_mediaconvert_queue_name
31
+ # ActiveEncode::Base.engine_adapter.log_group = my_log_group_name
32
+ #
33
+ # ## Capturing output information
34
+ #
35
+ # [AWS Elemental MediaConvert](https://aws.amazon.com/mediaconvert/) doesn't provide detailed
36
+ # output information in the job description that can be pulled directly from the service.
37
+ # Instead, it provides that information along with the job status notification when the job
38
+ # status changes to `COMPLETE`. The only way to capture that notification is through an [Amazon
39
+ # Eventbridge](https://aws.amazon.com/eventbridge/) rule that forwards the a MediaWatch job
40
+ # status change on `COMPLETE` to another service, such as [CloudWatch Logs]
41
+ # (https://aws.amazon.com/cloudwatch/) log group
42
+ #
43
+ # This adapter is written to get output information from a CloudWatch log group that has had
44
+ # MediaWatch complete events forwarded to it by an EventBridge group. The `setup!` method
45
+ # can be used to create these for you, at conventional names the adapter will be default use.
46
+ #
47
+ # ActiveEncode::Base.engine_adapter.setup!
48
+ #
49
+ # **OR**, there is experimental functionality to get what we can directly from the job without
50
+ # requiring a CloudWatch log -- this is expected to be complete only for HLS output at present.
51
+ # It seems to work well for HLS output. To opt-in, and not require CloudWatch logs:
52
+ #
53
+ # ActiveEncode::Base.engine_adapter.direct_output_lookup = true
54
+ #
55
+ # ## Example
56
+ #
57
+ # ActiveEncode::Base.engine_adapter = :media_convert
58
+ # ActiveEncode::Base.engine_adapter.role = 'arn:aws:iam::123456789012:role/service-role/MediaConvert_Default_Role'
59
+ # ActiveEncode::Base.engine_adapter.output_bucket = 'output-bucket'
60
+ #
61
+ # ActiveEncode::Base.engine_adapter.setup!
62
+ #
63
+ # encode = ActiveEncode::Base.create(
64
+ # "file://path/to/file.mp4",
65
+ # {
66
+ # masterfile_bucket: "name-of-my-masterfile_bucket"
67
+ # output_prefix: "path/to/output/base_name_of_outputs",
68
+ # use_original_url: true,
69
+ # outputs: [
70
+ # { preset: "my-hls-preset-high", modifier: "_high" },
71
+ # { preset: "my-hls-preset-medium", modifier: "_medium" },
72
+ # { preset: "my-hls-preset-low", modifier: "_low" },
73
+ # ]
74
+ # }
75
+ # )
76
+ #
77
+ # ## More info
78
+ #
79
+ # A more detailed guide is available in the repo at [guides/media_convert_adapter.md](../../../guides/media_convert_adapter.md)
15
80
  class MediaConvertAdapter
16
- # [AWS Elemental MediaConvert](https://aws.amazon.com/mediaconvert/) doesn't provide detailed
17
- # output information in the job description that can be pulled directly from the service.
18
- # Instead, it provides that information along with the job status notification when the job
19
- # status changes to `COMPLETE`. The only way to capture that notification is through an [Amazon
20
- # Eventbridge](https://aws.amazon.com/eventbridge/) rule that forwards the status change
21
- # notification to another service for capture and/or handling.
22
- #
23
- # `ActiveEncode::EngineAdapters::MediaConvert` does this by creating a [CloudWatch Logs]
24
- # (https://aws.amazon.com/cloudwatch/) log group and an EventBridge rule to forward status
25
- # change notifications to the log group. It can then find the log entry containing the output
26
- # details later when the job is complete. This is accomplished by calling the idempotent
27
- # `#setup!` method.
28
- #
29
- # The AWS user/role calling the `#setup!` method will require permissions to create the
30
- # necessary CloudWatch and EventBridge resources, and the role passed to the engine adapter
31
- # will need access to any S3 buckets where files will be read from or written to during
32
- # transcoding.
33
- #
34
- # Configuration example:
35
- #
36
- # ActiveEncode::Base.engine_adapter = :media_convert
37
- # ActiveEncode::Base.engine_adapter.role = 'arn:aws:iam::123456789012:role/service-role/MediaConvert_Default_Role'
38
- # ActiveEncode::Base.engine_adapter.output_bucket = 'output-bucket'
39
- # ActiveEncode::Base.engine_adapter.setup!
40
-
41
81
  JOB_STATES = {
42
82
  "SUBMITTED" => :running, "PROGRESSING" => :running, "CANCELED" => :cancelled,
43
83
  "ERROR" => :failed, "COMPLETE" => :completed
@@ -51,9 +91,40 @@ module ActiveEncode
51
91
  cmaf: { fragment_length: 2, segment_control: "SEGMENTED_FILES", segment_length: 10 }
52
92
  }.freeze
53
93
 
54
- attr_accessor :role, :output_bucket
94
+ SETUP_LOG_GROUP_RETENTION_DAYS = 3
95
+
96
+ class ResultsNotAvailable < RuntimeError
97
+ attr_reader :encode
98
+
99
+ def initialize(msg = nil, encode = nil)
100
+ @encode = encode
101
+ super(msg)
102
+ end
103
+ end
104
+
105
+ # @!attribute [rw] role simple name of AWS role to pass to MediaConvert, eg `my-role-name`
106
+ # @!attribute [rw] output_bucket simple bucket name to write output to
107
+ # @!attribute [rw] direct_output_lookup if true, do NOT get output information from cloudwatch,
108
+ # instead retrieve and construct it only from job itself. Currently
109
+ # working only for HLS output. default false.
110
+ attr_accessor :role, :output_bucket, :direct_output_lookup
111
+
112
+ # @!attribute [w] log_group log_group_name that is being used to capture output
113
+ # @!attribute [w] queue name of MediaConvert queue to use.
55
114
  attr_writer :log_group, :queue
56
115
 
116
+ # Creates a [CloudWatch Logs]
117
+ # (https://aws.amazon.com/cloudwatch/) log group and an EventBridge rule to forward status
118
+ # change notifications to the log group, to catch result information from MediaConvert jobs.
119
+ #
120
+ # Will use the configured `queue` and `log_group` values.
121
+ #
122
+ # The active AWS user/role when calling the `#setup!` method will require permissions to create the
123
+ # necessary CloudWatch and EventBridge resources
124
+ #
125
+ # This method chooses a conventional name for the EventBridge rule, if a rule by that
126
+ # name already exists, it will silently exit. So this method can be called in a boot process,
127
+ # to check if this infrastructure already exists, and create it only if it does not.
57
128
  def setup!
58
129
  rule_name = "active-encode-mediaconvert-#{queue}"
59
130
  return true if event_rule_exists?(rule_name)
@@ -64,17 +135,20 @@ module ActiveEncode
64
135
  source: ["aws.mediaconvert"],
65
136
  "detail-type": ["MediaConvert Job State Change"],
66
137
  detail: {
67
- queue: [queue_arn]
138
+ queue: [queue_arn],
139
+ status: ["COMPLETE"]
68
140
  }
69
141
  }
70
142
 
71
- log_group_arn = create_log_group(log_group).arn
143
+ # AWS is inconsistent about whether a cloudwatch ARN has :* appended
144
+ # to the end, and we need to make sure it doesn't in the rule target.
145
+ log_group_arn = create_log_group(log_group).arn.chomp(":*")
72
146
 
73
147
  cloudwatch_events.put_rule(
74
148
  name: rule_name,
75
149
  event_pattern: event_pattern.to_json,
76
150
  state: "ENABLED",
77
- description: "Forward MediaConvert job state changes from queue #{queue} to #{log_group}"
151
+ description: "Forward MediaConvert job state changes on COMPLETE from queue #{queue} to #{log_group}"
78
152
  )
79
153
 
80
154
  cloudwatch_events.put_targets(
@@ -91,20 +165,48 @@ module ActiveEncode
91
165
 
92
166
  # Required options:
93
167
  #
94
- # * `output_prefix`: The S3 key prefix to use as the base for all outputs.
168
+ # * `output_prefix`: The S3 key prefix to use as the base for all outputs. Will be
169
+ # combined with configured `output_bucket` to be passed to MediaConvert
170
+ # `destination`. Alternately see `destination` arg; one or the other
171
+ # is required.
95
172
  #
96
- # * `outputs`: An array of `{preset, modifier}` options defining how to transcode and name the outputs.
173
+ # * `destination`: The full s3:// URL to be passed to MediaConvert `destination` as output
174
+ # location an filename base. `output_bucket` config is ignored if you
175
+ # pass `destination`. Alternately see `output_prefix` arg; one or the
176
+ # other is required.
177
+ #
178
+ #
179
+ # * `outputs`: An array of `{preset, modifier}` options defining how to transcode and
180
+ # name the outputs. The "modifier" option will be passed as `name_modifier`
181
+ # to AWS, to be added as a suffix on to `output_prefix` to create the
182
+ # filenames for each output.
97
183
  #
98
184
  # Optional options:
99
185
  #
100
- # * `masterfile_bucket`: The bucket to which file-based inputs will be copied before
101
- # being passed to MediaConvert. Also used for S3-based inputs
102
- # unless `use_original_url` is specified.
186
+ # * `masterfile_bucket`: All input will first be copied to this bucket, before being passed
187
+ # to MediaConvert. You can skip this copy by passing `use_original_url`
188
+ # option, and an S3-based input. `masterfile_bucket` **is** required
189
+ # unless use_original_url is true and an S3 input source.
103
190
  #
104
191
  # * `use_original_url`: If `true`, any S3 URL passed in as input will be passed directly to
105
192
  # MediaConvert as the file input instead of copying the source to
106
193
  # the `masterfile_bucket`.
107
194
  #
195
+ # * `media_type`: `audio` or `video`. Default `video`. Triggers use of a correspoinding
196
+ # template for arguments sent to AWS create_job API.
197
+ #
198
+ #
199
+ # * `output_type`: One of: `hls`, `dash_iso`, `file`, `ms_smooth`, `cmaf`. Default `hls`.
200
+ # Triggers use of a corresponding template for arguments sent to AWS
201
+ # create_job API.
202
+ #
203
+ #
204
+ # * `output_group_destination_settings`: A hash of additional `destination_settings` to be
205
+ # sent to MediaConvert with the output_group. Can include `s3_settings` key
206
+ # with `access_control` and `encryption` settings. See examples at:
207
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/MediaConvert/Client.html#create_job-instance_method
208
+ #
209
+ #
108
210
  # Example:
109
211
  # {
110
212
  # output_prefix: "path/to/output/files",
@@ -121,6 +223,7 @@ module ActiveEncode
121
223
  input = options[:media_type] == :audio ? make_audio_input(input_url) : make_video_input(input_url)
122
224
 
123
225
  create_job_params = {
226
+ queue: queue,
124
227
  role: role,
125
228
  settings: {
126
229
  inputs: [input],
@@ -156,217 +259,346 @@ module ActiveEncode
156
259
 
157
260
  private
158
261
 
159
- def build_encode(job)
160
- return nil if job.nil?
161
- encode = ActiveEncode::Base.new(job.settings.inputs.first.file_input, {})
162
- encode.id = job.id
163
- encode.input.id = job.id
164
- encode.state = JOB_STATES[job.status]
165
- encode.current_operations = [job.current_phase].compact
166
- encode.created_at = job.timing.submit_time
167
- encode.updated_at = job.timing.finish_time || job.timing.start_time || encode.created_at
168
- encode.percent_complete = convert_percent_complete(job)
169
- encode.errors = [job.error_message].compact
170
-
171
- encode.input.created_at = encode.created_at
172
- encode.input.updated_at = encode.updated_at
173
-
174
- encode.output = encode.state == :completed ? convert_output(job) : []
175
- encode
262
+ def build_encode(job)
263
+ return nil if job.nil?
264
+ encode = ActiveEncode::Base.new(job.settings.inputs.first.file_input, {})
265
+ encode.id = job.id
266
+ encode.input.id = job.id
267
+ encode.state = JOB_STATES[job.status]
268
+ encode.current_operations = [job.current_phase].compact
269
+ encode.created_at = job.timing.submit_time
270
+ encode.updated_at = job.timing.finish_time || job.timing.start_time || encode.created_at
271
+ encode.percent_complete = convert_percent_complete(job)
272
+ encode.errors = [job.error_message].compact
273
+ encode.output = []
274
+
275
+ encode.input.created_at = encode.created_at
276
+ encode.input.updated_at = encode.updated_at
277
+
278
+ encode = complete_encode(encode, job) if encode.state == :completed
279
+ encode
280
+ end
281
+
282
+ # Called when job is complete to add output details, will mutate the encode object
283
+ # passed in to add #output details, an array of `ActiveEncode::Output` objects.
284
+ #
285
+ # @param encode [ActiveEncode::Output] encode object to mutate
286
+ # @param job [Aws::MediaConvert::Types::Job] corresponding MediaConvert Job object already looked up
287
+ #
288
+ # @return ActiveEncode::Output the same encode object passed in.
289
+ def complete_encode(encode, job)
290
+ output_result = convert_output(job)
291
+ if output_result.nil?
292
+ raise ResultsNotAvailable.new("Unable to load progress for job #{job.id}", encode) if job.timing.finish_time < 10.minutes.ago
293
+ encode.state = :running
294
+ else
295
+ encode.output = output_result
176
296
  end
297
+ encode
298
+ end
177
299
 
178
- def convert_percent_complete(job)
179
- case job.status
180
- when "SUBMITTED"
181
- 5
182
- when "PROGRESSING"
183
- job.job_percent_complete
184
- when "CANCELED", "ERROR"
185
- 50
186
- when "COMPLETE"
187
- 100
188
- else
189
- 0
190
- end
300
+ def convert_percent_complete(job)
301
+ case job.status
302
+ when "SUBMITTED"
303
+ 0
304
+ when "PROGRESSING"
305
+ job.job_percent_complete
306
+ when "CANCELED", "ERROR"
307
+ 50
308
+ when "COMPLETE"
309
+ 100
310
+ else
311
+ 0
191
312
  end
313
+ end
192
314
 
193
- def convert_output(job)
194
- results = get_encode_results(job)
195
- settings = job.settings.output_groups.first.outputs
315
+ # extracts and looks up output information from an AWS MediaConvert job.
316
+ # Will also lookup corresponding CloudWatch log entry unless
317
+ # direct_output_lookup config is true.
318
+ #
319
+ # @param job [Aws::MediaConvert::Types::Job]
320
+ #
321
+ # @return [Array<ActiveEncode::Output>,nil]
322
+ def convert_output(job)
323
+ if direct_output_lookup
324
+ build_output_from_only_job(job)
325
+ else
326
+ logged_results = get_encode_results(job)
327
+ return nil if logged_results.nil?
328
+ build_output_from_logged_results(job, logged_results)
329
+ end
330
+ end
196
331
 
197
- outputs = results.dig('detail', 'outputGroupDetails', 0, 'outputDetails').map.with_index do |detail, index|
198
- tech_md = MediaConvertOutput.tech_metadata(settings[index], detail)
199
- output = ActiveEncode::Output.new
332
+ def build_output_from_only_job(job)
333
+ # we need to compile info from two places in job output, two arrays of things,
334
+ # that correspond.
335
+ output_group = job.dig("settings", "output_groups", 0)
336
+ output_group_settings = output_group.dig("output_group_settings")
337
+ output_settings = output_group.dig("outputs")
338
+
339
+ output_group_details = job.dig("output_group_details", 0, "output_details")
340
+ file_input_url = job.dig("settings", "inputs", 0, "file_input")
341
+
342
+ outputs = output_group_details.map.with_index do |output_group_detail, index|
343
+ # Right now we only know how to get a URL for hls output, although
344
+ # the others should be possible and very analagous, just not familiar with them.
345
+ if output_group_settings.type == "HLS_GROUP_SETTINGS"
346
+ output_url = MediaConvertOutput.construct_output_url(
347
+ destination: output_group_settings.hls_group_settings.destination,
348
+ file_input_url: file_input_url,
349
+ name_modifier: output_settings[index].name_modifier,
350
+ file_suffix: "m3u8"
351
+ )
352
+ end
200
353
 
201
- output.created_at = job.timing.submit_time
202
- output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
354
+ tech_md = MediaConvertOutput.tech_metadata_from_settings(
355
+ output_url: output_url,
356
+ output_settings: output_settings[index],
357
+ output_detail_settings: output_group_detail
358
+ )
203
359
 
204
- [:width, :height, :frame_rate, :duration, :checksum, :audio_codec, :video_codec,
205
- :audio_bitrate, :video_bitrate, :file_size, :label, :url, :id].each do |field|
206
- output.send("#{field}=", tech_md[field])
207
- end
208
- output.id ||= "#{job.id}-output#{tech_md[:suffix]}"
209
- output
210
- end
360
+ output = ActiveEncode::Output.new
211
361
 
212
- adaptive_playlist = results.dig('detail', 'outputGroupDetails', 0, 'playlistFilePaths', 0)
213
- unless adaptive_playlist.nil?
214
- output = ActiveEncode::Output.new
215
- output.created_at = job.timing.submit_time
216
- output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
217
- output.id = "#{job.id}-output-auto"
218
-
219
- [:duration, :audio_codec, :video_codec].each do |field|
220
- output.send("#{field}=", outputs.first.send(field))
221
- end
222
- output.label = File.basename(adaptive_playlist)
223
- output.url = adaptive_playlist
224
- outputs << output
362
+ output.created_at = job.timing.submit_time
363
+ output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
364
+
365
+ [:width, :height, :frame_rate, :duration, :checksum, :audio_codec, :video_codec,
366
+ :audio_bitrate, :video_bitrate, :file_size, :label, :url, :id].each do |field|
367
+ output.send("#{field}=", tech_md[field])
225
368
  end
226
- outputs
369
+ output.id ||= "#{job.id}-output#{tech_md[:suffix]}"
370
+ output
227
371
  end
228
372
 
229
- def get_encode_results(job)
230
- start_time = job.timing.submit_time
231
- end_time = job.timing.finish_time || Time.now.utc
232
-
233
- response = cloudwatch_logs.start_query(
234
- log_group_name: log_group,
235
- start_time: start_time.to_i,
236
- end_time: end_time.to_i,
237
- limit: 1,
238
- query_string: "fields @message | filter detail.jobId = '#{job.id}' | filter detail.status = 'COMPLETE' | sort @ingestionTime desc"
373
+ # For HLS, we need to add on the single master adaptive playlist URL, which
374
+ # we can predict what it will be. At the moment, we don't know what to do
375
+ # for other types.
376
+ if output_group_settings.type == "HLS_GROUP_SETTINGS"
377
+ adaptive_playlist_url = MediaConvertOutput.construct_output_url(
378
+ destination: output_group_settings.hls_group_settings.destination,
379
+ file_input_url: file_input_url,
380
+ name_modifier: nil,
381
+ file_suffix: "m3u8"
239
382
  )
240
- query_id = response.query_id
241
- response = cloudwatch_logs.get_query_results(query_id: query_id)
242
- until response.status == "Complete"
243
- sleep(0.5)
244
- response = cloudwatch_logs.get_query_results(query_id: query_id)
245
- end
246
- raise ActiveEncode::NotFound, "Unable to load progress for job #{job.id}" if response.results.empty?
247
383
 
248
- JSON.parse(response.results.first.first.value)
249
- end
384
+ output = ActiveEncode::Output.new
385
+ output.created_at = job.timing.submit_time
386
+ output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
387
+ output.id = "#{job.id}-output-auto"
250
388
 
251
- def cloudwatch_events
252
- @cloudwatch_events ||= Aws::CloudWatchEvents::Client.new
389
+ [:duration, :audio_codec, :video_codec].each do |field|
390
+ output.send("#{field}=", outputs.first.send(field))
391
+ end
392
+ output.label = File.basename(adaptive_playlist_url)
393
+ output.url = adaptive_playlist_url
394
+ outputs << output
253
395
  end
254
396
 
255
- def cloudwatch_logs
256
- @cloudwatch_logs ||= Aws::CloudWatchLogs::Client.new
257
- end
397
+ outputs
398
+ end
258
399
 
259
- def mediaconvert
260
- endpoint = Aws::MediaConvert::Client.new.describe_endpoints.endpoints.first.url
261
- @mediaconvert ||= Aws::MediaConvert::Client.new(endpoint: endpoint)
262
- end
400
+ # Takes an AWS MediaConvert job object, and the fetched CloudWatch log results
401
+ # of MediaConvert completion event, and builds and returns ActiveEncode output
402
+ # from extracted data.
403
+ #
404
+ # @param job [Aws::MediaConvert::Types::Job]
405
+ # @param results [Hash] relevant AWS MediaConvert completion event, fetched from CloudWatch.
406
+ #
407
+ # @return [Array<ActiveEncode::Output>,nil]
408
+ def build_output_from_logged_results(job, logged_results)
409
+ output_settings = job.settings.output_groups.first.outputs
263
410
 
264
- def s3_uri(url, options = {})
265
- bucket = options[:masterfile_bucket]
266
-
267
- case Addressable::URI.parse(url).scheme
268
- when nil, 'file'
269
- upload_to_s3 url, bucket
270
- when 's3'
271
- return url if options[:use_original_url]
272
- check_s3_bucket url, bucket
273
- else
274
- raise ArgumentError, "Cannot handle source URL: #{url}"
411
+ outputs = logged_results.dig('detail', 'outputGroupDetails', 0, 'outputDetails').map.with_index do |logged_detail, index|
412
+ tech_md = MediaConvertOutput.tech_metadata_from_logged(output_settings[index], logged_detail)
413
+ output = ActiveEncode::Output.new
414
+
415
+ output.created_at = job.timing.submit_time
416
+ output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
417
+
418
+ [:width, :height, :frame_rate, :duration, :checksum, :audio_codec, :video_codec,
419
+ :audio_bitrate, :video_bitrate, :file_size, :label, :url, :id].each do |field|
420
+ output.send("#{field}=", tech_md[field])
275
421
  end
422
+ output.id ||= "#{job.id}-output#{tech_md[:suffix]}"
423
+ output
276
424
  end
277
425
 
278
- def check_s3_bucket(input_url, source_bucket)
279
- # logger.info("Checking `#{input_url}'")
280
- s3_object = FileLocator::S3File.new(input_url).object
281
- if s3_object.bucket_name == source_bucket
282
- # logger.info("Already in bucket `#{source_bucket}'")
283
- s3_object.key
284
- else
285
- s3_key = File.join(SecureRandom.uuid, s3_object.key)
286
- # logger.info("Copying to `#{source_bucket}/#{input_url}'")
287
- target = Aws::S3::Object.new(bucket_name: source_bucket, key: input_url)
288
- target.copy_from(s3_object, multipart_copy: s3_object.size > 15_728_640) # 15.megabytes
289
- s3_key
426
+ adaptive_playlist = logged_results.dig('detail', 'outputGroupDetails', 0, 'playlistFilePaths', 0)
427
+ unless adaptive_playlist.nil?
428
+ output = ActiveEncode::Output.new
429
+ output.created_at = job.timing.submit_time
430
+ output.updated_at = job.timing.finish_time || job.timing.start_time || output.created_at
431
+ output.id = "#{job.id}-output-auto"
432
+
433
+ [:duration, :audio_codec, :video_codec].each do |field|
434
+ output.send("#{field}=", outputs.first.send(field))
290
435
  end
436
+ output.label = File.basename(adaptive_playlist)
437
+ output.url = adaptive_playlist
438
+ outputs << output
291
439
  end
440
+ outputs
441
+ end
292
442
 
293
- def upload_to_s3(input_url, source_bucket)
294
- # original_input = input_url
295
- bucket = Aws::S3::Resource.new(client: s3client).bucket(source_bucket)
296
- filename = FileLocator.new(input_url).location
297
- s3_key = File.join(SecureRandom.uuid, File.basename(filename))
298
- # logger.info("Copying `#{original_input}' to `#{source_bucket}/#{input_url}'")
299
- obj = bucket.object(s3_key)
300
- obj.upload_file filename
443
+ # gets complete notification data from CloudWatch logs, returns the CloudWatch
444
+ # log value as a parsed hash.
445
+ #
446
+ # @return [Hash] parsed AWS Cloudwatch data from MediaConvert COMPLETE event.
447
+ def get_encode_results(job)
448
+ start_time = job.timing.submit_time
449
+ end_time = (job.timing.finish_time || Time.now.utc) + 10.minutes
450
+
451
+ response = cloudwatch_logs.start_query(
452
+ log_group_name: log_group,
453
+ start_time: start_time.to_i,
454
+ end_time: end_time.to_i,
455
+ limit: 1,
456
+ query_string: "fields @message | filter detail.jobId = '#{job.id}' | filter detail.status = 'COMPLETE' | sort @ingestionTime desc"
457
+ )
458
+ query_id = response.query_id
459
+ response = cloudwatch_logs.get_query_results(query_id: query_id)
460
+ until response.status == "Complete"
461
+ sleep(0.5)
462
+ response = cloudwatch_logs.get_query_results(query_id: query_id)
463
+ end
301
464
 
302
- s3_key
465
+ return nil if response.results.empty?
466
+
467
+ JSON.parse(response.results.first.first.value)
468
+ end
469
+
470
+ def cloudwatch_events
471
+ @cloudwatch_events ||= Aws::CloudWatchEvents::Client.new
472
+ end
473
+
474
+ def cloudwatch_logs
475
+ @cloudwatch_logs ||= Aws::CloudWatchLogs::Client.new
476
+ end
477
+
478
+ def mediaconvert
479
+ @mediaconvert ||= begin
480
+ endpoint = Aws::MediaConvert::Client.new.describe_endpoints.endpoints.first.url
481
+ Aws::MediaConvert::Client.new(endpoint: endpoint)
303
482
  end
483
+ end
304
484
 
305
- def event_rule_exists?(rule_name)
306
- rule = cloudwatch_events.list_rules(name_prefix: rule_name).rules.find do |existing_rule|
307
- existing_rule.name == rule_name
308
- end
309
- !rule.nil?
485
+ def s3_uri(url, options = {})
486
+ bucket = options[:masterfile_bucket]
487
+
488
+ case Addressable::URI.parse(url).scheme
489
+ when nil, 'file'
490
+ upload_to_s3 url, bucket
491
+ when 's3'
492
+ return url if options[:use_original_url]
493
+ check_s3_bucket url, bucket
494
+ else
495
+ raise ArgumentError, "Cannot handle source URL: #{url}"
310
496
  end
497
+ end
311
498
 
312
- def find_log_group(name)
313
- cloudwatch_logs.describe_log_groups(log_group_name_prefix: name).log_groups.find do |group|
314
- group.log_group_name == name
315
- end
499
+ def check_s3_bucket(input_url, source_bucket)
500
+ # logger.info("Checking `#{input_url}'")
501
+ s3_object = FileLocator::S3File.new(input_url).object
502
+ if s3_object.bucket_name == source_bucket
503
+ # logger.info("Already in bucket `#{source_bucket}'")
504
+ s3_object.key
505
+ else
506
+ s3_key = File.join(SecureRandom.uuid, s3_object.key)
507
+ # logger.info("Copying to `#{source_bucket}/#{input_url}'")
508
+ target = Aws::S3::Object.new(bucket_name: source_bucket, key: input_url)
509
+ target.copy_from(s3_object, multipart_copy: s3_object.size > 15_728_640) # 15.megabytes
510
+ s3_key
316
511
  end
512
+ end
317
513
 
318
- def create_log_group(name)
319
- result = find_log_group(name)
514
+ def upload_to_s3(input_url, source_bucket)
515
+ # original_input = input_url
516
+ bucket = Aws::S3::Resource.new(client: s3client).bucket(source_bucket)
517
+ filename = FileLocator.new(input_url).location
518
+ s3_key = File.join(SecureRandom.uuid, File.basename(filename))
519
+ # logger.info("Copying `#{original_input}' to `#{source_bucket}/#{input_url}'")
520
+ obj = bucket.object(s3_key)
521
+ obj.upload_file filename
320
522
 
321
- return result unless result.nil?
523
+ s3_key
524
+ end
322
525
 
323
- cloudwatch_logs.create_log_group(log_group_name: name)
324
- find_log_group(name)
526
+ def event_rule_exists?(rule_name)
527
+ rule = cloudwatch_events.list_rules(name_prefix: rule_name).rules.find do |existing_rule|
528
+ existing_rule.name == rule_name
325
529
  end
530
+ !rule.nil?
531
+ end
326
532
 
327
- def make_audio_input(input_url)
328
- {
329
- audio_selectors: { "Audio Selector 1" => { default_selection: "DEFAULT" } },
330
- audio_selector_groups: {
331
- "Audio Selector Group 1" => {
332
- audio_selector_names: ["Audio Selector 1"]
333
- }
334
- },
335
- file_input: input_url,
336
- timecode_source: "ZEROBASED"
337
- }
533
+ def find_log_group(name)
534
+ cloudwatch_logs.describe_log_groups(log_group_name_prefix: name).log_groups.find do |group|
535
+ group.log_group_name == name
338
536
  end
537
+ end
339
538
 
340
- def make_video_input(input_url)
341
- {
342
- audio_selectors: { "Audio Selector 1" => { default_selection: "DEFAULT" } },
343
- file_input: input_url,
344
- timecode_source: "ZEROBASED",
345
- video_selector: {}
346
- }
347
- end
539
+ def create_log_group(name)
540
+ result = find_log_group(name)
348
541
 
349
- def make_output_groups(options)
350
- output_type = options[:output_type] || :hls
351
- raise ArgumentError, "Unknown output type: #{output_type.inspect}" unless OUTPUT_GROUP_TEMPLATES.keys.include?(output_type)
352
- output_group_settings_key = "#{output_type}_group_settings".to_sym
353
- output_group_settings = OUTPUT_GROUP_TEMPLATES[output_type].merge(destination: "s3://#{output_bucket}/#{options[:output_prefix]}")
542
+ return result unless result.nil?
354
543
 
355
- outputs = options[:outputs].map do |output|
356
- {
357
- preset: output[:preset],
358
- name_modifier: output[:modifier]
544
+ cloudwatch_logs.create_log_group(log_group_name: name)
545
+ cloudwatch_logs.put_retention_policy(
546
+ log_group_name: name,
547
+ retention_in_days: SETUP_LOG_GROUP_RETENTION_DAYS
548
+ )
549
+
550
+ find_log_group(name)
551
+ end
552
+
553
+ def make_audio_input(input_url)
554
+ {
555
+ audio_selectors: { "Audio Selector 1" => { default_selection: "DEFAULT" } },
556
+ audio_selector_groups: {
557
+ "Audio Selector Group 1" => {
558
+ audio_selector_names: ["Audio Selector 1"]
359
559
  }
360
- end
560
+ },
561
+ file_input: input_url,
562
+ timecode_source: "ZEROBASED"
563
+ }
564
+ end
565
+
566
+ def make_video_input(input_url)
567
+ {
568
+ audio_selectors: { "Audio Selector 1" => { default_selection: "DEFAULT" } },
569
+ file_input: input_url,
570
+ timecode_source: "ZEROBASED",
571
+ video_selector: {}
572
+ }
573
+ end
574
+
575
+ def make_output_groups(options)
576
+ output_type = options[:output_type] || :hls
577
+ raise ArgumentError, "Unknown output type: #{output_type.inspect}" unless OUTPUT_GROUP_TEMPLATES.keys.include?(output_type)
578
+ output_group_settings_key = "#{output_type}_group_settings".to_sym
579
+
580
+ destination = options[:destination] || "s3://#{output_bucket}/#{options[:output_prefix]}"
581
+ output_group_settings = OUTPUT_GROUP_TEMPLATES[output_type].merge(destination: destination)
361
582
 
362
- [{
363
- output_group_settings: {
364
- type: output_group_settings_key.upcase,
365
- output_group_settings_key => output_group_settings
366
- },
367
- outputs: outputs
368
- }]
583
+ if options[:output_group_destination_settings]
584
+ output_group_settings[:destination_settings] = options[:output_group_destination_settings]
369
585
  end
586
+
587
+ outputs = options[:outputs].map do |output|
588
+ {
589
+ preset: output[:preset],
590
+ name_modifier: output[:modifier]
591
+ }
592
+ end
593
+
594
+ [{
595
+ output_group_settings: {
596
+ type: output_group_settings_key.upcase,
597
+ output_group_settings_key => output_group_settings
598
+ },
599
+ outputs: outputs
600
+ }]
601
+ end
370
602
  end
371
603
  end
372
604
  end