Commit 5a6b8528 authored by Rafael Reggiani Manzo's avatar Rafael Reggiani Manzo

Merge branch 'zencoder' into 'master'

Zencoder

This MR adds integration with the Zencoder API. It uses delayed jobs to schedule videos for encoding. A new `EncondedContent` model was created to store the data of Zencoder's notifications.

For testing, we are using the `zencoder_fetcher` gem to retrieve and post notifications on our callback url.

See merge request !18
parents 3525ff0a 3e12b2b7
Pipeline #3642582 passed with stage
in 10 minutes and 30 seconds
......@@ -24,6 +24,7 @@
config/database.yml
config/secrets.yml
config/aws.yml
config/zencoder.yml
# Coverage folder
coverage
......@@ -65,6 +65,12 @@ gem 'settingslogic'
# Make links open in lightbox (colorbox) a breeze
gem 'colorbox-rails'
# Zencoder integration: Cloud video encoding
gem 'zencoder'
# Job queue adapter for Application Job
gem 'delayed_job_active_record'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platform: :mri
......@@ -78,6 +84,10 @@ group :development, :test do
# Fixtures creation and management
gem 'factory_girl'
# Test zencoder notifications
# PR address https://github.com/zencoder/zencoder-fetcher/pull/11
gem 'zencoder-fetcher', git: 'https://github.com/marcheing/zencoder-fetcher.git'
end
group :development do
......
GIT
remote: https://github.com/marcheing/zencoder-fetcher.git
revision: b2cc853ceb5923e88963a8af73201b0373abcf76
specs:
zencoder-fetcher (0.2.8)
activesupport
httparty
i18n
json
trollop
GEM
remote: https://rubygems.org/
specs:
......@@ -97,6 +108,11 @@ GEM
cucumber-wire (0.0.1)
database_cleaner (1.5.3)
debug_inspector (0.0.2)
delayed_job (4.1.2)
activesupport (>= 3.0, < 5.1)
delayed_job_active_record (4.1.1)
activerecord (>= 3.0, < 5.1)
delayed_job (>= 3.0, < 5)
devise (4.1.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
......@@ -120,6 +136,9 @@ GEM
hover-rails (2.0.2)
railties (>= 3.2)
http_accept_language (2.0.5)
httparty (0.13.7)
json (~> 1.8)
multi_xml (>= 0.5.2)
i18n (0.7.0)
jbuilder (2.5.0)
activesupport (>= 3.0.0, < 5.1)
......@@ -149,6 +168,7 @@ GEM
minitest (5.9.0)
multi_json (1.12.1)
multi_test (0.1.2)
multi_xml (0.5.5)
nio4r (1.2.1)
nokogiri (1.6.8)
mini_portile2 (~> 2.1.0)
......@@ -262,6 +282,7 @@ GEM
thor (0.19.1)
thread_safe (0.3.5)
tilt (2.0.5)
trollop (2.1.2)
turbolinks (5.0.0.beta2)
turbolinks-source
turbolinks-source (5.0.0.beta5)
......@@ -280,6 +301,8 @@ GEM
websocket-extensions (0.1.2)
xpath (2.0.0)
nokogiri (~> 1.3)
zencoder (2.5.1)
multi_json
PLATFORMS
ruby
......@@ -292,6 +315,7 @@ DEPENDENCIES
colorbox-rails
cucumber-rails
database_cleaner
delayed_job_active_record
devise
factory_girl
foundation-rails
......@@ -318,6 +342,8 @@ DEPENDENCIES
tzinfo-data
uglifier (>= 1.3.0)
web-console
zencoder
zencoder-fetcher!
BUNDLED WITH
1.12.5
require 'open-uri'
class ContentsController < ApplicationController
before_action :set_content, only: [:show]
protect_from_forgery with: :null_session, only: :zencoder_callback
# GET /contents/1
# GET /contents/1.json
......@@ -26,6 +29,22 @@ class ContentsController < ApplicationController
end
end
def zencoder_callback
@encoded_content = EncodedContent.find_by(job_id: params[:job][:id])
unless @encoded_content.nil?
@encoded_content.output_id = params[:outputs].first[:id]
@encoded_content.state = params[:outputs].first[:state]
@encoded_content.width = params[:outputs].first[:width]
@encoded_content.height = params[:outputs].first[:height]
@encoded_content.duration = params[:outputs].first[:duration_in_ms]
@encoded_content.file_size = params[:outputs].first[:file_size_in_bytes]
@encoded_content.video = open(params[:outputs].first[:url])
@encoded_content.save
end
head :no_content
end
private
# Use callbacks to share common setup or constraints between actions.
def set_content
......
class RequestZencoderJob < ApplicationJob
queue_as :zencoder
def perform(*args)
content = args.first
response = Zencoder::Job.create(input: content.video.url, test: Rails.env.test?, notifications: notification_url)
EncodedContent.create(job_id: response.body['id'], content_id: content.id)
end
private
def notification_url
Rails.env.test? ? [url: 'http://zencoderfetcher/'] : [url: ZencoderSettings.notifications.url]
end
end
......@@ -9,5 +9,15 @@ class Content < ApplicationRecord
validates :terms_of_service, acceptance: true
validates :title, :user_id, :soundtrack, :director, presence: true
after_create :send_to_zencoder
has_many :encoded_contents
private
def send_to_zencoder
RequestZencoderJob.perform_later(self)
end
end
class EncodedContent < ApplicationRecord
has_attached_file :video,
storage: :s3,
s3_credentials: AWSSettings.to_h,
s3_region: AWSSettings.aws_region
validates_attachment_content_type :video, content_type: /\Avideo\/.*\Z/
validates :content_id, :job_id, presence: true
belongs_to :content
end
#!/usr/bin/env ruby
require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment'))
require 'delayed/command'
Delayed::Command.new(ARGV).daemonize
......@@ -35,6 +35,7 @@ chdir APP_ROOT do
system! 'cp -nv config/database.yml.sample config/database.yml'
system! 'cp -nv config/secrets.yml.sample config/secrets.yml'
system! 'cp -nv config/aws.yml.sample config/aws.yml'
system! 'cp -nv config/zencoder.yml.sample config/zencoder.yml'
green '== Preparing database =='
system! 'bin/rails db:setup'
......
......@@ -19,5 +19,7 @@ module MediaChoice
# Include settings folder
config.autoload_paths += %W( #{config.root}/lib/settings )
config.active_job.queue_adapter = :delayed_job
end
end
Zencoder.api_key = ZencoderSettings.api_key
......@@ -5,6 +5,10 @@ Rails.application.routes.draw do
resources :profiles, except: [:index]
resources :contents, only: [:new, :create, :show]
scope :contents do
post '/zencoder_callback', to: 'contents#zencoder_callback', as: :content_zencoder_callback
end
get 'states' => 'profiles#states', as: 'states'
get 'terms_of_use' => 'static_views#terms_of_use', as: 'terms_of_use'
......
default_outputs: &default_outputs
video:
format: 'mp4'
size: '640x480'
public: true
default_http_options: &default_http_options
http_default_options:
timeout: 30000
headers:
'Accept': 'application/json'
'Content-Type': 'application/json'
defaults: &defaults
api_key: a43dec7e11dea37d04f98050436dae62
notifications:
url: 'http://localhost:3000/contents/zencoder_callback'
development:
<<: *defaults
<<: *default_http_options
<<: *default_outputs
test:
<<: *defaults
<<: *default_http_options
<<: *default_outputs
production:
<<: *defaults
<<: *default_http_options
<<: *default_outputs
api_key: PRODUCTION_ZENCODE_KEY_INSERT_HERE
staging:
<<: *defaults
<<: *default_http_options
<<: *default_outputs
api_key: STAGING_ZENCODE_KEY_INSERT_HERE
class CreateDelayedJobs < ActiveRecord::Migration[5.0]
def self.up
create_table :delayed_jobs, force: true do |table|
table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue
table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually.
table.text :handler, null: false # YAML-encoded string of the object that will do work
table.text :last_error # reason for last failure (See Note below)
table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future.
table.datetime :locked_at # Set when a client is working on this object
table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead)
table.string :locked_by # Who is working on this object (if locked)
table.string :queue # The name of the queue this job is in
table.timestamps null: true
end
add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority"
end
def self.down
drop_table :delayed_jobs
end
end
class AddEncodedContent < ActiveRecord::Migration[5.0]
def change
create_table :encoded_contents do |t|
t.integer :content_id, null: false
t.integer :job_id, null: false
t.integer :output_id
t.integer :file_size
t.integer :width
t.integer :height
t.integer :duration
t.string :state
t.attachment :video
t.timestamps
end
end
end
......@@ -34,6 +34,38 @@ ActiveRecord::Schema.define(version: 20160701135238) do
t.datetime "video_updated_at"
end
create_table "delayed_jobs", force: :cascade do |t|
t.integer "priority", default: 0, null: false
t.integer "attempts", default: 0, null: false
t.text "handler", null: false
t.text "last_error"
t.datetime "run_at"
t.datetime "locked_at"
t.datetime "failed_at"
t.string "locked_by"
t.string "queue"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["priority", "run_at"], name: "delayed_jobs_priority"
end
create_table "encoded_contents", force: :cascade do |t|
t.integer "content_id", null: false
t.integer "job_id", null: false
t.integer "output_id"
t.integer "file_size"
t.integer "width"
t.integer "height"
t.integer "duration"
t.string "state"
t.string "video_file_name"
t.string "video_content_type"
t.integer "video_file_size"
t.datetime "video_updated_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "profiles", force: :cascade do |t|
t.string "first_name"
t.string "last_name"
......
Feature: Send uploaded video to zencoder
In order to be able to see video contents
As the zencoder client
I should be able to send a job for encoding and receive notifications
@javascript
Scenario: Sending a video to zencoder
Given I have a registered and confirmed user
And the user has a complete profile
When the user creates a video content
Then the video should have an url
And a zencoder job should have been created
When the zencoder job finishes
Then an encoded content should have been created
And I should receive a successful notification
And the encoded content should have been updated
And the original media file is still accessible
......@@ -11,7 +11,7 @@ Given(/^I see my name on the Author field$/) do
end
Given(/^I have a video to be uploaded$/) do
@video_attributes = attributes_for :video, :cucumber
@video_attributes = attributes_for :video, :with_file_name
end
When(/^I fill the title field with the video's title$/) do
......@@ -35,7 +35,7 @@ When(/^I fill the zip code field with my zip code$/) do
end
When(/^I send a video file$/) do
attach_file 'video-upload', @video_attributes[:video]
attach_file 'video-upload', @video_attributes[:video_file_name]
end
When(/^I click on the register button$/) do
......
Given(/^the user has a complete profile$/) do
@profile = create(:profile, user_id: @user.id)
end
When(/^the user creates a video content$/) do
@content = create(:video, :zencoder, user_id: @user.id)
@original_content_url = @content.video.url
end
When(/^the zencoder job finishes$/) do
Delayed::Job.where(queue: 'zencoder').last.invoke_job
end
Then(/^the video should have an url$/) do
expect(@content.video.url).not_to be_nil
end
Then(/^a zencoder job should have been created$/) do
expect(Delayed::Job.any? { |job| job.queue == 'zencoder' }).to eq true
end
Then(/^an encoded content should have been created$/) do
expect(EncodedContent.where(content_id: @content.id).size).to eq 1
end
Then(/^I should receive a successful notification$/) do
`zencoder_fetcher -u #{ZencoderSettings.notifications.url} #{ZencoderSettings.api_key} -n 1 -x`
expect($?.success?).to eq true
end
Then(/^the encoded content should have been updated$/) do
expect(EncodedContent.find_by(content_id: @content.id).output_id).to_not be_nil
end
Then(/^the original media file is still accessible$/) do
encoded_content = @content.encoded_contents.first
expect(@content.video.url).to eq @original_content_url
expect(@original_content_url).to_not eq(encoded_content.video.url)
expect(@content.video.hash).to_not eq(encoded_content.video.hash)
end
......@@ -78,3 +78,8 @@ Cucumber::Rails::Database.javascript_strategy = :truncation
FactoryGirl.find_definitions
World(FactoryGirl::Syntax::Methods)
# Set default host and port for zencoder-fetcher's notifications based on the
# url set on zencoder.yml
Capybara.default_host = 'http://localhost'
Capybara.server_port = 3000
class ZencoderSettings < Settingslogic
source "#{Rails.root}/config/zencoder.yml"
namespace Rails.env
end
......@@ -32,7 +32,7 @@ RSpec.describe ContentsController, type: :controller do
end
describe 'GET #show' do
let(:video) { build(:video, :rspec, id: 1) }
let(:video) { build(:video, :with_file_name, id: 1) }
context 'video found' do
before do
expect(Content).to receive(:find).with(video.id.to_s).and_return(video)
......@@ -62,7 +62,7 @@ RSpec.describe ContentsController, type: :controller do
end
describe 'POST #create' do
let(:video) { build(:video, with: :rspec) }
let(:video) { build(:video, with: :with_file_name) }
context 'with valid params' do
before do
expect_any_instance_of(Content).to receive(:save).and_return true
......@@ -83,4 +83,41 @@ RSpec.describe ContentsController, type: :controller do
it { is_expected.to respond_with(:ok) }
end
end
describe 'POST #zencoder_callback' do
let!(:job_id) { '1' }
let!(:output) { { id: '2', state: 'finished', width: '640', height: '320', duration_in_ms: '5000',
file_size_in_bytes: '1024', url: 'http://test.com' } }
let!(:outputs) { [output] }
let!(:video_file_mock) { instance_double('video_file') }
context 'when the content is found' do
let(:encoded_video) { build(:encoded_video) }
before do
expect(EncodedContent).to receive(:find_by).with(job_id: job_id).and_return(encoded_video)
expect(subject).to receive(:open).with(outputs.first[:url]).and_return(video_file_mock)
expect(encoded_video).to receive(:save).and_return(true)
expect(encoded_video).to receive(:output_id=).with(output[:id])
expect(encoded_video).to receive(:state=).with(output[:state])
expect(encoded_video).to receive(:width=).with(output[:width])
expect(encoded_video).to receive(:height=).with(output[:height])
expect(encoded_video).to receive(:duration=).with(output[:duration_in_ms])
expect(encoded_video).to receive(:file_size=).with(output[:file_size_in_bytes])
expect(encoded_video).to receive(:video=).with(video_file_mock)
post :zencoder_callback, params: { job: { id: job_id}, outputs: outputs }
end
it { is_expected.to respond_with(:no_content) }
end
context 'when the content is not found' do
before do
expect(EncodedContent).to receive(:find_by).with(job_id: job_id).and_return(nil)
post :zencoder_callback, params: { job: { id: job_id}, outputs: outputs }
end
it { is_expected.to respond_with(:no_content) }
end
end
end
......@@ -3,16 +3,13 @@ FactoryGirl.define do
title 'Video Title'
zip_code '00000-000'
soundtrack 'soundtrack'
director 'Director'
end
trait :rspec do
trait :with_file_name do
video_file_name "#{Rails.root}/public/cucumber/videos/video.mp4"
end
trait :cucumber do
video "#{Rails.root}/public/cucumber/videos/video.mp4"
end
trait :zencoder do
video { File.new "#{Rails.root}/public/cucumber/videos/video.mp4" }
end
......
FactoryGirl.define do
factory :encoded_video, class: EncodedContent do
output_id 1
state 'finished'
width 640
height 320
duration 5000
file_size 1024
end
end
require 'rails_helper'
RSpec.describe RequestZencoderJob, type: :job do
describe 'perform' do
let(:video) { build(:video, id: 1 ) }
let(:response_body) { { 'id' => 12 } }
let(:response) { instance_double('response') }
it 'creates a zencoder job with the video' do
expect(Zencoder::Job).to receive(:create).and_return response
expect(response).to receive(:body).and_return response_body
expect(EncodedContent).to receive(:create).with(job_id: response_body['id'], content_id: video.id)
subject.perform video
end
end
end
......@@ -15,4 +15,18 @@ RSpec.describe Content, type: :model do
allowing('video/mp4', 'video/x-flv', 'video/MP2T', 'video/3gpp', 'video/quicktime', 'video/x-msvideo', 'video/x-ms-wmv').
rejecting('text/plain', 'text/xml', 'image/png', 'image/gif') }
end
describe 'relations' do
it { should have_many(:encoded_contents) }
end
describe 'hooks' do
context 'after create' do
subject { build(:video) }
it 'creates an Upload to Zencoder Job' do
expect(RequestZencoderJob).to receive(:perform_later).with(subject)
subject.run_callbacks :create
end
end
end
end
require 'rails_helper'
RSpec.describe EncodedContent, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of :content_id }
it { is_expected.to validate_presence_of :job_id }
end
describe 'relations' do
it { should belong_to(:content) }
end
end
......@@ -35,5 +35,8 @@ RSpec.describe ContentsController, type: :routing do
expect(:delete => "/contents/1").to_not route_to("contents#destroy", :id => "1")
end
it 'routes to #callback' do
expect(:post => '/contents/zencoder_callback').to route_to('contents#zencoder_callback')
end
end
end
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment