diff --git a/pb/src/client_container.proto b/pb/src/client_container.proto index 901797515..7be007f4d 100644 --- a/pb/src/client_container.proto +++ b/pb/src/client_container.proto @@ -484,6 +484,7 @@ message RecordingMasterMixComplete { optional string notification_id = 4; optional string created_at = 5; optional string claimed_recording_id = 6; +<<<<<<< HEAD } message RecordingStreamMixComplete { @@ -493,6 +494,8 @@ message RecordingStreamMixComplete { optional string notification_id = 4; optional string created_at = 5; optional string claimed_recording_id = 6; +======= +>>>>>>> feature/video_mvp } message DownloadAvailable { diff --git a/ruby/lib/jam_ruby/models/recording.rb b/ruby/lib/jam_ruby/models/recording.rb index 663622fff..cd5612cc9 100644 --- a/ruby/lib/jam_ruby/models/recording.rb +++ b/ruby/lib/jam_ruby/models/recording.rb @@ -76,6 +76,17 @@ module JamRuby quick_mixes.find{|quick_mix| quick_mix.completed && !quick_mix.cleaned } end + def mix_state + mix.state if mix + end + + def mix_error + mix.error if mix + end + + def stream_mix + quick_mixes.find{|quick_mix| quick_mix.completed && !quick_mix.cleaned } + end # this can probably be done more efficiently, but David needs this asap for a video def grouped_tracks @@ -121,8 +132,6 @@ module JamRuby unless claimed_recordings.length > 0 destroy end - - end def not_still_finalizing_previous @@ -446,7 +455,6 @@ module JamRuby # Further joining and criteria for the unioned object: - arel = arel.joins("INNER JOIN recordings ON recordings.id=recorded_items_all.recording_id") \ .where('recorded_items_all.user_id' => user.id) \ .where('recorded_items_all.fully_uploaded = ?', false) \ diff --git a/ruby/lib/jam_ruby/models/user_authorization.rb b/ruby/lib/jam_ruby/models/user_authorization.rb index fe1a3e5f5..e83bb8db4 100644 --- a/ruby/lib/jam_ruby/models/user_authorization.rb +++ b/ruby/lib/jam_ruby/models/user_authorization.rb @@ -12,7 +12,7 @@ module JamRuby validates_uniqueness_of :uid, scope: :provider # token, secret, token_expiration can be missing - def self.goog_auth(user) + def self.google_auth(user) self .where(:user_id => user.id) .where(:provider => 'google_login') diff --git a/web/Gemfile b/web/Gemfile index fccbcc672..e2b8e8a44 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -37,13 +37,16 @@ gem 'compass-rails', '1.1.3' # 1.1.4 throws an exception on startup about !init gem 'rabl', '0.11.0' # for JSON API development gem 'gon', '~>4.1.0' # for passthrough of Ruby variables to Javascript variables gem 'eventmachine', '1.0.3' +gem 'faraday', '~>0.9.0' gem 'amqp', '0.9.8' gem 'logging-rails', :require => 'logging/rails' gem 'omniauth', '1.1.1' gem 'omniauth-facebook', '1.4.1' gem 'omniauth-twitter' gem 'omniauth-google-oauth2', '0.2.1' -gem 'google-api-client' +gem 'google-api-client', '0.7.1' +gem 'google-api-omniauth', '0.1.1' +gem 'signet', '0.5.0' gem 'twitter' gem 'fb_graph', '2.5.9' gem 'sendgrid', '1.2.0' @@ -107,7 +110,7 @@ end group :test, :cucumber do gem 'simplecov', '~> 0.7.1' gem 'simplecov-rcov' - gem 'capybara' + gem 'capybara', '2.4.4' #if ENV['JAMWEB_QT5'] == '1' # # necessary on platforms such as arch linux, where pacman -S qt5-webkit is your easiet option # gem "capybara-webkit", :git => 'git://github.com/thoughtbot/capybara-webkit.git' @@ -115,15 +118,17 @@ group :test, :cucumber do gem "capybara-webkit" #end gem 'capybara-screenshot', '0.3.22' # 1.0.0 broke compat with rspec. maybe we need newer rspec + gem 'selenium-webdriver' gem 'cucumber-rails', :require => false #, '1.3.0', :require => false gem 'guard-spork', '0.3.2' gem 'spork', '0.9.0' - gem 'launchy', '2.1.0' + gem 'launchy', '2.1.1' gem 'rack-test' # gem 'rb-fsevent', '0.9.1', :require => false # gem 'growl', '1.0.3' gem 'poltergeist' gem 'resque_spec' + #gem 'thin' end diff --git a/web/app/assets/javascripts/dialog/recordingFinishedDialog.js b/web/app/assets/javascripts/dialog/recordingFinishedDialog.js index 5863d54e8..f0d24a27d 100644 --- a/web/app/assets/javascripts/dialog/recordingFinishedDialog.js +++ b/web/app/assets/javascripts/dialog/recordingFinishedDialog.js @@ -122,13 +122,17 @@ var description = $('#recording-finished-dialog form textarea[name=description]').val(); var genre = $('#recording-finished-dialog form select[name=genre]').val(); var is_public = $('#recording-finished-dialog form input[name=is_public]').is(':checked') + var save_video = $('#recording-finished-dialog form input[name=save_video]').is(':checked') + var upload_to_youtube = $('#recording-finished-dialog form input[name=upload_to_youtube]').is(':checked') rest.claimRecording({ id: recording.id, name: name, description: description, genre: genre, - is_public: is_public + is_public: is_public, + save_video: save_video, + upload_to_youtube: upload_to_youtube }) .done(function () { $dialog.data('result', {keep:true}); @@ -151,6 +155,12 @@ var $is_public_errors = context.JK.format_errors('is_public', errors); if ($is_public_errors) $('#recording-finished-dialog form input[name=is_public]').closest('div.field').addClass('error').end().after($is_public_errors); + var $save_video_errors = context.JK.format_errors('save_video', errors); + if ($save_video_errors) $('#recording-finished-dialog form input[name=save_video]').closest('div.field').addClass('error').end().after($save_video_errors); + + var $upload_to_youtube_errors = context.JK.format_errors('upload_to_youtube', errors); + if ($upload_to_youtube_errors) $('#recording-finished-dialog form input[name=upload_to_youtube]').closest('div.field').addClass('error').end().after($upload_to_youtube_errors); + var recording_error = context.JK.get_first_error('recording_id', errors); if (recording_error) context.JK.showErrorDialog(app, "Unable to claim recording.", recording_error); @@ -223,8 +233,9 @@ function initializeButtons() { var isPublic = $('#recording-finished-dialog input[name="is_public"]'); - context.JK.checkbox(isPublic); + context.JK.checkbox($('#recording-finished-dialog input[name="save_video"]')); + context.JK.checkbox($('#recording-finished-dialog input[name="upload_to_youtube"]')); } function initialize() { diff --git a/web/app/assets/stylesheets/dialogs/recordingFinishedDialog.css.scss b/web/app/assets/stylesheets/dialogs/recordingFinishedDialog.css.scss index a738fa57b..3eb894abe 100644 --- a/web/app/assets/stylesheets/dialogs/recordingFinishedDialog.css.scss +++ b/web/app/assets/stylesheets/dialogs/recordingFinishedDialog.css.scss @@ -1,8 +1,9 @@ #recording-finished-dialog { width:1000px; height:auto; - div[purpose=description], div[purpose=is_public] { + div[purpose=description] { margin-top:20px; + margin-bottom: 10px; } label[for=is_public], label[for=playback-mode-preview-all], label[for=playback-mode-preview-me] { @@ -23,11 +24,21 @@ margin-top:20px; } - div[purpose=is_public] .icheckbox_minimal { - display:inline-block; - position:relative; - top:3px; - margin-right:3px; + div[purpose=is_public], div[purpose=upload_to_youtube], div[purpose=save_video] { + .icheckbox_minimal { + display:inline-block; + position:relative; + top:1px; + margin-top:3px; + margin-bottom:1px; + margin-right:2px; + } + label { + display: inline-block; + margin-bottom:4px; + margin-right:2px; + } + clear: left; } } diff --git a/web/app/controllers/api_recordings_controller.rb b/web/app/controllers/api_recordings_controller.rb index ce6cf9d42..adda59d2e 100644 --- a/web/app/controllers/api_recordings_controller.rb +++ b/web/app/controllers/api_recordings_controller.rb @@ -1,8 +1,8 @@ class ApiRecordingsController < ApiController - before_filter :api_signed_in_user, :except => [ :add_like ] - before_filter :look_up_recording, :only => [ :show, :stop, :claim, :discard, :keep ] - before_filter :parse_filename, :only => [ :download, :upload_next_part, :upload_sign, :upload_part_complete, :upload_complete ] + before_filter :lookup_recording, :only => [ :show, :stop, :claim, :discard, :keep ] + before_filter :lookup_recorded_track, :only => [ :download, :upload_next_part, :upload_sign, :upload_part_complete, :upload_complete ] + before_filter :lookup_recorded_video, :only => [ :video_upload_sign, :video_upload_start, :video_upload_complete ] before_filter :lookup_stream_mix, :only => [ :upload_next_part_stream_mix, :upload_sign_stream_mix, :upload_part_complete_stream_mix, :upload_complete_stream_mix ] respond_to :json @@ -210,6 +210,135 @@ class ApiRecordingsController < ApiController end end + # POST /api/recordings/:id/videos/:video_id/upload_sign + def video_upload_sign + length = params[:length] + @youtube_client.upload_sign(current_user, @recorded_video.url, length) + end + + # POST /api/recordings/:id/videos/:video_id/upload_complete + def video_upload_start + length = params[:length] + @youtube_client.get_upload_status(current_user, @recorded_video.url, length) + end + + # POST /api/recordings/:id/videos/:video_id/upload_complete + def video_upload_complete + if @youtube_client.complete_upload(@recorded_video) + render :status => 200 + else + render :status => 422 + end + end + + def upload_next_part_stream_mix + length = params[:length] + md5 = params[:md5] + + @quick_mix.upload_next_part(length, md5) + + if @quick_mix.errors.any? + + response.status = :unprocessable_entity + # this is not typical, but please don't change this line unless you are sure it won't break anything + # this is needed because after_rollback in the RecordedTrackObserver touches the model and something about it's + # state doesn't cause errors to shoot out like normal. + render :json => { :errors => @quick_mix.errors }, :status => 422 + else + result = { + :part => @quick_mix.next_part_to_upload, + :offset => @quick_mix.file_offset.to_s + } + + render :json => result, :status => 200 + end + + end + + def upload_sign_stream_mix + render :json => @quick_mix.upload_sign(params[:md5]), :status => 200 + end + + def upload_part_complete_stream_mix + part = params[:part] + offset = params[:offset] + + @quick_mix.upload_part_complete(part, offset) + + if @quick_mix.errors.any? + response.status = :unprocessable_entity + respond_with @quick_mix + else + render :json => {}, :status => 200 + end + end + + def upload_complete_stream_mix + @quick_mix.upload_complete + + if @quick_mix.errors.any? + response.status = :unprocessable_entity + respond_with @quick_mix + return + else + render :json => {}, :status => 200 + end + end + + + def upload_next_part_stream_mix + length = params[:length] + md5 = params[:md5] + + @quick_mix.upload_next_part(length, md5) + + if @quick_mix.errors.any? + + response.status = :unprocessable_entity + # this is not typical, but please don't change this line unless you are sure it won't break anything + # this is needed because after_rollback in the RecordedTrackObserver touches the model and something about it's + # state doesn't cause errors to shoot out like normal. + render :json => { :errors => @quick_mix.errors }, :status => 422 + else + result = { + :part => @quick_mix.next_part_to_upload, + :offset => @quick_mix.file_offset.to_s + } + + render :json => result, :status => 200 + end + + end + + def upload_sign_stream_mix + render :json => @quick_mix.upload_sign(params[:md5]), :status => 200 + end + + def upload_part_complete_stream_mix + part = params[:part] + offset = params[:offset] + + @quick_mix.upload_part_complete(part, offset) + + if @quick_mix.errors.any? + response.status = :unprocessable_entity + respond_with @quick_mix + else + render :json => {}, :status => 200 + end + end + + def upload_complete_stream_mix + @quick_mix.upload_complete + + if @quick_mix.errors.any? + response.status = :unprocessable_entity + respond_with @quick_mix + return + else + render :json => {}, :status => 200 + end + end def upload_next_part_stream_mix @@ -267,7 +396,13 @@ class ApiRecordingsController < ApiController end private - def parse_filename + + def lookup_recording + @recording = Recording.find(params[:id]) + raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR unless @recording.has_access?(current_user) + end + + def lookup_recorded_track @recorded_track = RecordedTrack.find_by_recording_id_and_client_track_id!(params[:id], params[:track_id]) raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR unless @recorded_track.recording.has_access?(current_user) end @@ -277,9 +412,9 @@ class ApiRecordingsController < ApiController raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR unless @quick_mix.recording.has_access?(current_user) end - def look_up_recording - @recording = Recording.find(params[:id]) - raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR unless @recording.has_access?(current_user) + def lookup_recorded_video + @recorded_video = RecordedVideo.find_by_recording_id_and_client_video_source_id!(params[:id], params[:video_id]) + raise PermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR unless @recorded_video.recording.has_access?(current_user) end -end +end # class diff --git a/web/app/controllers/gmail_controller.rb b/web/app/controllers/gmail_controller.rb index e4f1e1906..16c18cad9 100644 --- a/web/app/controllers/gmail_controller.rb +++ b/web/app/controllers/gmail_controller.rb @@ -6,7 +6,7 @@ class GmailController < ApplicationController render :nothing => true, :status => 404 return end - authorization = UserAuthorization.goog_auth(current_user) + authorization = UserAuthorization.google_auth(current_user) if authorization.empty? render :nothing => true, :status => 404 return diff --git a/web/app/views/dialogs/_recordingFinishedDialog.html.erb b/web/app/views/dialogs/_recordingFinishedDialog.html.erb deleted file mode 100644 index 989e7243f..000000000 --- a/web/app/views/dialogs/_recordingFinishedDialog.html.erb +++ /dev/null @@ -1,58 +0,0 @@ - -
- -
- <%= image_tag "content/recordbutton-off.png", {:height => 20, :width => 20, :class => 'content-icon'} %> -

recording finished

-
- -
- Fill out the fields below and click the "SAVE" button to save this recording to your library. If you do not want to - keep the recording, click the "DISCARD" button. -
-
- -
-
-
-
- -
-
-
-
- -
- -
-
-
- - -
-
- -
-
- -
- Preview Recording: - - <%= render "clients/play_controls" %> - -
-
-
- - -

- -
- DISCARD SAVE - -
- -
- -
-
diff --git a/web/app/views/dialogs/_recordingFinishedDialog.html.haml b/web/app/views/dialogs/_recordingFinishedDialog.html.haml new file mode 100644 index 000000000..ae4c58afa --- /dev/null +++ b/web/app/views/dialogs/_recordingFinishedDialog.html.haml @@ -0,0 +1,48 @@ +/ Invitation Dialog +#recording-finished-dialog.dialog.recordingFinished-overlay.ftue-overlay.tall{:layout => "dialog", "layout-id" => "recordingFinished"} + .content-head + = image_tag "content/recordbutton-off.png", {:height => 20, :width => 20, :class => 'content-icon'} + %h1 recording finished + .dialog-inner + Fill out the fields below and click the "SAVE" button to save this recording to your library. If you do not want to + keep the recording, click the "DISCARD" button. + %br/ + %br/ + %form.left.w40.mr20 + .left.w50.mr20 + .field.w100 + %label{:for => "name"} Recording name: + %br/ + %input#claim-recording-name.w100{:name => "name", :type => "text"}/ + .right.w40.genre-selector + .field + / genre box + %label{:for => "genre"} Genre: + %br/ + %select{:name => "genre"} + .field.w100.left{:purpose => "description"} + %label{:for => "description"} Description: + %textarea#claim-recording-description.w100{:name => "description"} + .field.left{:purpose => "save_video"} + %input{:checked => "checked", :name => "save_video", :type => "checkbox"}/ + %label{:for => "save_video"} Save Video to Computer + .field.left{:purpose => "upload_to_youtube"} + %input{:checked => "checked", :name => "upload_to_youtube", :type => "checkbox"}/ + %label{:for => "upload_to_youtube"} Upload Video to YouTube + .field.left{:purpose => "is_public"} + %input{:checked => "checked", :name => "is_public", :type => "checkbox"}/ + %label{:for => "is_public"} Public Recording + / < + .left.w50.ml30 + Preview Recording: + + \#{render "clients/play_controls"} + %br/ + %br/ + %br{:clear => "left"}/ + %br/ + .right + %a#discard-session-recording.button-grey{:href => "#"}> DISCARD + \  + %a#keep-session-recording.button-orange{:href => "#"} SAVE + %br{:clear => "all"}/ \ No newline at end of file diff --git a/web/config/application.rb b/web/config/application.rb index b15e13426..7ef9314e1 100644 --- a/web/config/application.rb +++ b/web/config/application.rb @@ -143,6 +143,7 @@ if defined?(Bundler) # google api keys config.google_client_id = '785931784279-gd0g8on6sc0tuesj7cu763pitaiv2la8.apps.googleusercontent.com' config.google_secret = 'UwzIcvtErv9c2-GIsNfIo7bA' + config.google_email = '785931784279-gd0g8on6sc0tuesj7cu763pitaiv2la8@developer.gserviceaccount.com' if Rails.env == 'production' config.desk_url = 'https://jamkazam.desk.com' diff --git a/web/config/routes.rb b/web/config/routes.rb index 5c4bf42c7..e42189fc6 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -401,6 +401,8 @@ SampleApp::Application.routes.draw do match '/recordings/:id/comments' => 'api_recordings#add_comment', :via => :post, :as => 'api_recordings_add_comment' match '/recordings/:id/likes' => 'api_recordings#add_like', :via => :post, :as => 'api_recordings_add_like' match '/recordings/:id/discard' => 'api_recordings#discard', :via => :post, :as => 'api_recordings_discard' + + # Recordings - recorded_tracks match '/recordings/:id/tracks/:track_id' => 'api_recordings#show_recorded_track', :via => :get, :as => 'api_recordings_show_recorded_track' match '/recordings/:id/tracks/:track_id/download' => 'api_recordings#download', :via => :get, :as => 'api_recordings_download' match '/recordings/:id/tracks/:track_id/upload_next_part' => 'api_recordings#upload_next_part', :via => :get @@ -408,9 +410,16 @@ SampleApp::Application.routes.draw do match '/recordings/:id/tracks/:track_id/upload_part_complete' => 'api_recordings#upload_part_complete', :via => :post match '/recordings/:id/tracks/:track_id/upload_complete' => 'api_recordings#upload_complete', :via => :post match '/recordings/:id/stream_mix/upload_next_part' => 'api_recordings#upload_next_part_stream_mix', :via => :get + + # Recordings - stream_mix match '/recordings/:id/stream_mix/upload_sign' => 'api_recordings#upload_sign_stream_mix', :via => :get match '/recordings/:id/stream_mix/upload_part_complete' => 'api_recordings#upload_part_complete_stream_mix', :via => :post match '/recordings/:id/stream_mix/upload_complete' => 'api_recordings#upload_complete_stream_mix', :via => :post + + # Recordings - recorded_videos + match '/recordings/:id/tracks/:video_id/upload_sign' => 'api_recordings#video_upload_sign', :via => :get + match '/recordings/:id/videos/:video_id/upload_start' => 'api_recordings#video_upload_start', :via => :post + match '/recordings/:id/videos/:video_id/upload_complete' => 'api_recordings#video_upload_complete', :via => :post # Claimed Recordings match '/claimed_recordings' => 'api_claimed_recordings#index', :via => :get diff --git a/web/lib/youtube_client.rb b/web/lib/youtube_client.rb new file mode 100644 index 000000000..635aa1a63 --- /dev/null +++ b/web/lib/youtube_client.rb @@ -0,0 +1,387 @@ +require 'faraday' +#require 'thin' +require 'launchy' +require 'cgi' +require 'json' +require 'google/api_client' +require 'google/api_client/client_secrets' +require 'google/api_client/auth/installed_app' +require 'socket' # Provides TCPServer and TCPSocket classes +# require 'youtube_client'; c = YouTubeClient.new +# Youtube API functionality: +module JamRuby + class YouTubeClient + attr_accessor :client + attr_accessor :api + attr_accessor :request + attr_accessor :server + attr_accessor :socket + attr_accessor :config + attr_accessor :redirect_uri + + def initialize() + Rails.logger.info("Initializing client...") + self.config = Rails.application.config + self.redirect_uri='http://localhost:2112/auth/google_login/callback' + self.client = Google::APIClient.new( + :application_name => 'JamKazam', + :application_version => '1.0.0' + ) + + youtube = client.discovered_api('youtube', 'v3') + # client.authorization = nil + # result = client.execute + # :key => config.youtube_developer_key, + # :api_method => youtube.videos.list, + # :parameters => {:id => '', :part => 'snippet'} + # result = JSON.parse(result.data.to_json) + end + + # Return a login URL that will show a web page with + def get_login_url(username=nil) + uri = "https://accounts.google.com/o/oauth2/auth" + uri << "?scope=#{CGI.escape('https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload https://gdata.youtube.com email profile ')}" # # https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.upload + uri << "&client_id=#{CGI.escape(self.config.google_email)}" + #uri << "&client_secret=#{CGI.escape(self.config.google_secret)}" + uri << "&response_type=code" + uri << "&access_type=online" + uri << "&prompt=consent" + uri << "&state=4242" + uri << "&redirect_uri=#{redirect_uri}" + if username.present? + uri << "&login_hint=#{(username)}" + end + uri + end + + # Contacts youtube and prepares an upload to youtube. This + # process is somewhat painful, even in ruby, so we do the preparation + # and the client does the actual upload using the URL returned: + + # https://developers.google.com/youtube/v3/docs/videos/insert + # https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol + def upload_sign(user, filename, length) + raise ArgumentError, "Length is required and should be > 0" if length.to_i.zero? + # Something like this: + # POST /upload/youtube/v3/videos?uploadType=resumable&part=snippet,status,contentDetails HTTP/1.1 + # Host: www.googleapis.com + # Authorization: Bearer AUTH_TOKEN + # Content-Length: 278 + # Content-Type: application/json; charset=UTF-8 + # X-Upload-Content-Length: 3000000 + # X-Upload-Content-Type: video/* + + # { + # "snippet": { + # "title": "My video title", + # "description": "This is a description of my video", + # "tags": ["cool", "video", "more keywords"], + # "categoryId": 22 + # }, + # "status": { + # "privacyStatus": "public", + # "embeddable": True, + # "license": "youtube" + # } + # } + auth = UserAuthorization.google_auth(user).first + if auth.nil? || auth.token.nil? + raise SecurityError, "No current google token found for user #{user}" + end + + video_data = { + "snippet"=> { + "title"=> filename, + "description"=> filename, + "tags"=> ["cool", "video", "more keywords"], + "categoryId"=>1 + }, + "status"=> { + "privacyStatus"=> "public", + "embeddable"=> true, + "license"=> "youtube" + } + } + + conn = Faraday.new(:url =>"https://www.googleapis.com",:ssl => {:verify => false}) do |faraday| + faraday.request :url_encoded + faraday.response :logger + faraday.adapter Faraday.default_adapter + end + + video_json=video_data.to_json + # result = conn.post do |req| + # req.url('/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status') + # req.headers['authorization']="bearer #{(auth.token)}" + # req.headers['content-type']='application/json;charset=utf-8' + # req.headers['x-Upload-Content-Length']="#{length}" + # req.headers['x-upload-content-type']="video/*" + # req.body = video_json + # end + # access_token=#{CGI.escape(auth.token)} + result = conn.post("/upload/youtube/v3/videos?access_token=#{CGI.escape(auth.token)}&uploadType=resumable&part=snippet,status,contentDetails", + video_json, + { + # 'client_id'=>"#{(self.config.google_email)}", + # 'client_secret'=>config.google_secret, + #'Authorization'=>"bearer #{(auth.token)}", + 'content-type'=>'application/json;charset=utf-8', + 'x-Upload-Content-Length'=>"#{length}", + 'x-upload-content-type'=>"video/*" + } + ) + + #puts result.inspect + # Response should something look like: + # HTTP/1.1 200 OK + # Location: https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&upload_id=xa298sd_f&part=snippet,status,contentDetails + # Content-Length: 0 + + if (result.nil? || result.status!=200 || result.headers['location'].blank?) + msg = "Failed signing with status=#{result.status} #{result.inspect}: " + if result.body.present? && result.body.length > 2 + msg << result.body.inspect# JSON.parse(result.body).inspect + end + + # TODO: how to test for this: + # If reason is "youtubeSignupRequired" + # If the user's youtube account is unlinked, they'll have to go here. + # http://m.youtube.com/create_channel. With v3, there is no automated way to do this. + raise msg + else + # This has everything one needs to start the upload to youtube: + { + "method" => "PUT", + "url" => result.headers['location'], + "Authorization" => "Bearer #{auth.token}", + "Content-Length" => length, + "Content-Type" => "video/*" + } + end + + # This has everything one needs to start the upload to youtube: + end + + # https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol#Check_Upload_Status + def get_upload_status(user, upload_url, length) + auth = UserAuthorization.google_auth(user).first + if auth.nil? || auth.token.nil? + raise SecurityError, "No current google token found for user #{user}" + end + + # PUT UPLOAD_URL HTTP/1.1 + # Authorization: Bearer AUTH_TOKEN + # Content-Length: 0 + # Content-Range: bytes */CONTENT_LENGTH + RestClient.put(upload_url, nil, { + 'Authorization' => "Bearer #{auth.token}", + 'Content-Length'=> "0", + 'Content-Range' => "bytes */#{length}" + }) do |response, request, result| + # puts "response: #{response.class}: #{response.code} / #{response.headers} / #{response.body}" + # Result looks like this: + # 308 Resume Incomplete + # Content-Length: 0 + # Range: bytes=0-999999 + case(response.code) + when 200..207 + result_hash = { + "offset" => 0, + "length" => length, + "status" => response.code + } + when 308 + range_str = response.headers['Range'] + if range_str.nil? + range = 0..length + else + range = range_str.split("-") + end + result_hash = { + "offset" => range.first.to_i, + "length" => range.last.to_i, + "status" => response.code + } + else + raise "Unexpected status from youtube: [#{response.code}] with headers: #{response.headers.inspect}" + end + + result_hash + end + end + + # @return true if file specified by URL uploaded, false otherwise + def verify_upload(user, upload_url, length) + status_hash=get_upload_status(user, upload_url, length) + (status_hash['status']>=200 && status_hash['status']<300) + end + + def complete_upload(recorded_video) + if (verify_upload(recorded_video.user, recorded_video.url, recorded_video.length)) + recorded_video.update_attribute(:fully_uploaded, true) + else + false + end + end + + # This will also sign in and prompt for login as necessary; + # currently requires the server to be running at localhost:3000 + def signin_flow() + config = Rails.application.config + + self.client = Google::APIClient.new( + :application_name => 'JamKazam', + :application_version => '1.0.0' + ) + + flow = Google::APIClient::InstalledAppFlow.new( + :client_id => config.google_client_id, + :client_secret => config.google_secret, + :redirect_uri=>redirect_uri, + :scope => 'email profile' + ) + + self.client.authorization = flow.authorize + end + + # Must manually confirm to obtain refresh token: + # 4/ZwtU8nNgiEiu2JlJMrmnnw.Qo7Zys7XjRoZPm8kb2vw2M2j2ZEskgI + def get_refresh_token + config = Rails.application.config + conn = Faraday.new(:url => 'https://accounts.google.com',:ssl => {:verify => false}) do |faraday| + faraday.request :url_encoded + faraday.response :logger + faraday.adapter Faraday.default_adapter + end + + wait_for_callback do |refresh_token| + Rails.logger.info("The refresh_token is #{refresh_token}") + end + + + result = conn.get '/o/oauth2/auth', { + 'scope'=>'email profile', + 'client_id'=>config.google_client_id, + 'response_type'=>"code", + 'access_type'=>"offline", + 'redirect_uri'=>redirect_uri + } + end + + def get_access_token(refresh_token) + refresh_token = "4/g9uZ8S4lq2Bj1J8PPIkgOFKhTKmCHSmRe68iHA75hRg.gj8Nt5bpVYQdPm8kb2vw2M23tnRnkgI" + #refresh_token = "4/ZwtU8nNgiEiu2JlJMrmnnw.Qo7Zys7XjRoZPm8kb2vw2M2j2ZEskgI" + config = Rails.application.config + conn = Faraday.new(:url => 'https://accounts.google.com',:ssl => {:verify => false}) do |faraday| + faraday.request :url_encoded + faraday.response :logger + faraday.adapter Faraday.default_adapter + end + + wait_for_callback do |access_token| + Rails.logger.info("The access_token is #{access_token}") + #self.server.stop() + end + + result = conn.post '/o/oauth2/token', nil, { + 'scope'=>'email profile', + 'client_id'=>config.google_client_id, + 'client_secret'=>config.google_secret, + 'refresh_token'=>refresh_token, + #'response_type'=>"code", + 'grant_type'=>"refresh_token", + #'access_type'=>"offline", + 'redirect_uri'=>redirect_uri + } + + Rails.logger.info("REsult: #{result.inspect}\n\n") + end + + def wait_for_callback(port=3000) + shutdown() + self.server = Thread.new { + Rails.logger.info("STARTING SERVER THREAD...") + tcp_server = TCPServer.new('localhost', port) + + self.socket = tcp_server.accept + if self.socket + request = self.socket.gets + Rails.logger.info("REQUEST: #{request}") + + params=CGI.parse(request) + code = params['code'].first + # Whack the end part: + access_code = code ? code.split(" ").first : "" + + status = (access_code.present?) ? 'OK' : 'EMPTY' + Rails.logger.info("access_code is #{status}") + token=exchange_for_token(access_code) + yield(token) + + response = "#{status}\n" + self.socket.print "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/plain\r\n" + + "Content-Length: #{response.bytesize}\r\n" + + "Connection: close\r\n" + + self.socket.print "\r\n" + self.socket.print response + self.socket.close + socket=nil + end + } + end + + + def exchange_for_token(access_code) + #puts "EXCHANGING token for code: [#{access_code}] #{access_code.class}" + + conn = Faraday.new(:url =>"https://accounts.google.com",:ssl => {:verify => false}) do |faraday| + faraday.request :url_encoded + # faraday.request :multipart + faraday.response :logger + faraday.adapter Faraday.default_adapter + end + + exchange_parms={ + 'grant_type'=>'authorization_code', + 'code'=>(access_code), + 'client_id'=>(config.google_email),#CGI.escape(config.google_client_id), + 'client_secret'=>(config.google_secret), + 'redirect_uri'=>(redirect_uri), + } + + result = conn.post('/o/oauth2/token', exchange_parms) + if result.body.nil? || result.body.blank? + raise "Result not in correct form: [#{result.body}]" + end + + body_hash = JSON.parse(result.body) + + #puts "RESULT #{result.body.class}: #{result.body}" + #puts "EXCHANGING for token: [#{body_hash['access_token']}]" + body_hash['access_token'] + end + + # shutdown + def shutdown() + Rails.logger.info("Stopping oauth server...") + #Thread.kill(self.server) + if (self.socket) + begin + self.socket.close + rescue IOError + # Expected for most cases: + Rails.logger.info("Socket already closed.") + end + self.socket = nil + end + + # if (self.server) + # Thread.kill(self.server) + # self.server = nil + # end + end + end # class +end # module + \ No newline at end of file diff --git a/web/spec/factories.rb b/web/spec/factories.rb index bf81273e7..b130450b6 100644 --- a/web/spec/factories.rb +++ b/web/spec/factories.rb @@ -272,9 +272,23 @@ FactoryGirl.define do factory :track, :class => JamRuby::Track do sound "mono" sequence(:client_track_id) { |n| "client_track_id_seq_#{n}"} - end + factory :video_source, :class => JamRuby::VideoSource do + #client_video_source_id "test_source_id" + sequence(:client_video_source_id) { |n| "client_video_source_id#{n}"} + end + + factory :recording, :class => JamRuby::Recording do + association :owner, factory: :user + association :music_session, factory: :active_music_session + + factory :recording_with_track do + before(:create) { |recording| + recording.recorded_tracks << FactoryGirl.create(:recorded_track, recording: recording, user: recording.owner) + } + end + end factory :recorded_track, :class => JamRuby::RecordedTrack do instrument JamRuby::Instrument.first @@ -289,16 +303,13 @@ FactoryGirl.define do association :recording, factory: :recording end - factory :recording, :class => JamRuby::Recording do - - association :owner, factory: :user - association :music_session, factory: :active_music_session - - factory :recording_with_track do - before(:create) { |recording| - recording.recorded_tracks << FactoryGirl.create(:recorded_track, recording: recording, user: recording.owner) - } - end + factory :recorded_video, :class => JamRuby::RecordedVideo do + sequence(:recording_id) { |n| "recording_id-#{n}"} + sequence(:client_video_source_id) { |n| "client_video_source_id-#{n}"} + fully_uploaded true + length 1 + association :user, factory: :user + association :recording, factory: :recording end factory :claimed_recording, :class => JamRuby::ClaimedRecording do diff --git a/web/spec/features/oauth_spec.rb b/web/spec/features/oauth_spec.rb new file mode 100644 index 000000000..e6c5af5a2 --- /dev/null +++ b/web/spec/features/oauth_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' +require 'youtube_client' + +describe "OAuth", :slow=>true, :js=>true, :type=>:feature, :capybara_feature=>true do + + subject { page } + + before(:all) do + Capybara.javascript_driver = :poltergeist + Capybara.current_driver = Capybara.javascript_driver + Capybara.default_wait_time = 10 + @previous_run_server = Capybara.run_server + Capybara.run_server = false + @user=FactoryGirl.create(:user, :email=>"jamkazamtest@gmail.com") + end + + before(:each) do + @youtube_client = YouTubeClient.new() + end + + after(:each) do + @youtube_client.shutdown if @youtube_client + @youtube_client=nil + @user.user_authorizations.destroy_all + #page.driver.remove_cookie(:remember_token) + end + + after(:all) do + @user.destroy + Capybara.run_server = @previous_run_server + end + + it "client should not authorize a wrong password" do + expect { + authorize_google_user(@youtube_client, @user, "f00bar") + }.to raise_error + + @user.reload + @user.user_authorizations.count.should eq(0) + end + + it "client should authorize a google user" do + authorize_google_user(@youtube_client, @user, "stinkyblueberryjam") + save_screenshot("working.png") + @user.reload + @user.user_authorizations.count.should eq(1) + + google_auth = UserAuthorization.google_auth(@user).first + google_auth.should_not be_nil + google_auth.token.should_not be_nil + end + +end diff --git a/web/spec/features/youtube_spec.rb b/web/spec/features/youtube_spec.rb new file mode 100644 index 000000000..41883cd80 --- /dev/null +++ b/web/spec/features/youtube_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' +require 'youtube_client' +require 'rest_client' + +describe "YouTube", :slow=>true, :js=>true, :type => :feature, :capybara_feature => true do + subject { page } + + before(:all) do + Capybara.javascript_driver = :poltergeist + Capybara.current_driver = Capybara.javascript_driver + Capybara.default_wait_time = 10 + @previous_run_server = Capybara.run_server + Capybara.run_server = false + @user=FactoryGirl.create(:user, :email => "jamkazamtest@gmail.com") + @youtube_client = YouTubeClient.new() + authorize_google_user(@youtube_client, @user, "stinkyblueberryjam") + google_auth = UserAuthorization.google_auth(@user).first # Consider returning this from above now that it is reliable + end + + after(:all) do + @user.destroy + @youtube_client.shutdown + Capybara.run_server = @previous_run_server + end + + it "should retrieve upload url" do + length = 3276 + upload_hash=@youtube_client.upload_sign(@user, "test_video.mp4", length) + upload_hash.should_not be_nil + upload_hash.length.should be >=1 + upload_hash['method'].should eq("PUT") + upload_hash['url'].should_not be_nil + upload_hash['Authorization'].should_not be_nil + upload_hash['Content-Length'].should_not be_nil + upload_hash['Content-Length'].should eq(length) + upload_hash['Content-Type'].should_not be_nil + + @youtube_client.verify_upload(@user, upload_hash['url'], length).should be_false + end + + it "upload url should allow uploading" do + vid_path = Rails.root.join('spec', 'files', 'test_video.mp4') + length = File.size?(vid_path) + upload_hash=@youtube_client.upload_sign(@user, "test_video.mp4", length) + #puts upload_hash.inspect + upload_hash.should_not be_nil + upload_hash.length.should be >=1 + upload_hash['method'].should eq("PUT") + upload_hash['url'].should_not be_nil + upload_hash['Authorization'].should_not be_nil + upload_hash['Content-Length'].should_not be_nil + upload_hash['Content-Length'].should eq(length) + upload_hash['Content-Type'].should_not be_nil + + # Upload this file as the client would: + RestClient.put(upload_hash['url'], File.read(vid_path)) + @youtube_client.verify_upload(@user, upload_hash['url'], length).should be_true + #@youtube_client.get_upload_status(@user, upload_hash['url'], length) + end + + it "sets upload flag when complete" do + @music_session = FactoryGirl.create(:active_music_session, :creator => @user, :musician_access => true) + @connection = FactoryGirl.create(:connection, :user => @user, :music_session => @music_session) + @video_source = FactoryGirl.create(:video_source, :connection => @connection) + @recording = FactoryGirl.create(:recording, owner: @user, band: nil, duration:1) + + + vid_path = Rails.root.join('spec', 'files', 'test_video.mp4') + length = File.size?(vid_path) + upload_hash=@youtube_client.upload_sign(@user, "test_video.mp4", length) + upload_hash.should_not be_nil + upload_hash['url'].should_not be_nil + RestClient.put(upload_hash['url'], File.read(vid_path)) + + recorded_video = FactoryGirl.create(:recorded_video, + recording: @recording, + user: @recording.owner, + fully_uploaded: false, + url: upload_hash['url'], + length: length + ) + + @recording.recorded_videos << recorded_video + + @youtube_client.verify_upload(@user, upload_hash['url'], length).should be_true + @youtube_client.complete_upload(recorded_video).should be_true + recorded_video.fully_uploaded.should be_true + end +end diff --git a/web/spec/files/test_video.mp4 b/web/spec/files/test_video.mp4 new file mode 100644 index 000000000..45b460bfd Binary files /dev/null and b/web/spec/files/test_video.mp4 differ diff --git a/web/spec/spec_helper.rb b/web/spec/spec_helper.rb index 95b6f42e1..483a30851 100644 --- a/web/spec/spec_helper.rb +++ b/web/spec/spec_helper.rb @@ -192,10 +192,11 @@ bputs "before register capybara" end config.before(:each) do - if example.metadata[:js] + if example.metadata[:js] && (Capybara.current_driver.nil? || Capybara.current_driver.empty? || Capybara.current_driver==:poltergeist) page.driver.resize(1920, 1080) page.driver.headers = { 'User-Agent' => 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:25.0) Gecko/20100101 Firefox/25.0' } end + end config.before(:each, :js => true) do @@ -212,7 +213,6 @@ bputs "before register capybara" Capybara.reset_sessions! reset_session_mapper - end config.after(:each) do diff --git a/web/spec/support/utilities.rb b/web/spec/support/utilities.rb index e459ac8b0..d70b273c6 100644 --- a/web/spec/support/utilities.rb +++ b/web/spec/support/utilities.rb @@ -73,6 +73,45 @@ def wipe_s3_test_bucket end end +def authorize_google_user(youtube_client, user, google_password) + youtube_client.wait_for_callback(2112) do |access_token| + #puts("Authorizing with token #{access_token}") + user_auth_hash = { + :provider => "google_login", + :uid => user.email, + :token => access_token, + :token_expiration => nil, + :secret => "" + } + authorization = user.user_authorizations.build(user_auth_hash) + authorization.save + end + + url = youtube_client.get_login_url(user.email) + #puts("Login URL: #{url}") + visit url + sleep(1) + # save_screenshot("initial.png") + + # Fill in password (the username is filled in with a hint in URL): + # fill_in "Usernm", with: user.email + fill_in "Passwd", with: google_password + #save_screenshot("password.png") + + find('#signIn').trigger(:click) + # Wait for submit to enable and then click it: + sleep(5) + + #save_screenshot("signin.png") + + #save_screenshot("submit.png") + find('#submit_approve_access').trigger(:click) + #save_screenshot("log2.png") + sleep(5) + #save_screenshot("submitted.png") + + youtube_client +end def sign_in(user) visit signin_path @@ -116,7 +155,8 @@ def sign_in_poltergeist(user, options = {}) should_be_at_root end - visit signin_path + visit signin_path + page.should have_selector('#landing-inner form.signin-form') within('#landing-inner form.signin-form') do fill_in "Email Address:", with: user.email @@ -148,7 +188,7 @@ end def set_login_cookie(user) page.driver.set_cookie(:remember_token, user.remember_token) end - + def sign_out() if Capybara.javascript_driver == :poltergeist page.driver.remove_cookie(:remember_token) diff --git a/web/spec/testing_oauth.txt b/web/spec/testing_oauth.txt new file mode 100644 index 000000000..db139b81f --- /dev/null +++ b/web/spec/testing_oauth.txt @@ -0,0 +1,28 @@ +For access to the youtube and google APIs, we need an access_token + +To obtain an access token, one must actually log into google using a browser running javascript. This redirects to the URL specified, as long as it is specified in the oauth configuration. + +Getting an access token for the purposes of automated testing is tricky, but possible using Capybara with a javascript-enabled driver. (Note, web/spec/support/utilities.rb utilizes the JK youtube client to perform the intricate bits): + +1) Obtain the login URL. It's ugly, but we can get it from the YouTubeClient. It contains the callback URL, as well as a "hint" that will fill in the username for us. +2) Start a web server on an enabled callback server, such as localhost:3000 +3) Obtain the URL using a known test user +4) Visit the URL in a capybara test +4a) Fill in password with the right value +4b) Click the login button +4c) The approve page should load. Wait for the approve button to be enabled. This is usually a second or two after the page loads, but not immediately. +4d) Click the approve button +5) After google approves, some javascript will redirect to our test web server, which contains a code. This is not the access_token, but a one-time code that can be exchanged for an access_token, again POSTing to google's auth server. You can see it in gory detail in YouTubeClient.exchange_for_token. +6) If all goes well, the test web server will call back the invoker with a real access token. +7) For testing purposes, stick the access token in the user.user_authorizations table for the user for which we are testing. + +Notes: +* When authenticating, client_id is required by several of the APIs. However, this doesn't work for /o/oauth2/token. What actually works is the "email" value from the developer console. This is now saved in the app as well. + +The tests in question use the following credentials: +u: jamkazamtest@gmail.com +p: stinkyblueberryjam + +Also, a server is started on port 2112, as 3000 was already being used on the build server. + + diff --git a/web/submit.png b/web/submit.png new file mode 100644 index 000000000..87b844bc9 Binary files /dev/null and b/web/submit.png differ diff --git a/web/submitted.png b/web/submitted.png new file mode 100644 index 000000000..6e17b49a1 Binary files /dev/null and b/web/submitted.png differ diff --git a/web/working.png b/web/working.png new file mode 100644 index 000000000..969f241e9 Binary files /dev/null and b/web/working.png differ