Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions lib/yt/actions/upload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
require 'net/http'
require 'yt/actions/base'

module Yt
module Actions
module Upload
include Base

private

def do_upload(extra_upload_params = {})
params = upload_params.merge(extra_upload_params)
uri = params[:uri]
http = params[:http] || new_upload_http(uri)

req = Net::HTTP::Put.new(uri.request_uri)
params.fetch(:headers, {}).each { |k, v| req[k] = v }
req['Authorization'] = "Bearer #{params[:token]}"

body = params[:body]
if body.nil?
# no body (e.g. status check)
elsif body.respond_to?(:read)
req.body_stream = body
req['Transfer-Encoding'] = 'chunked'
else
req.body = body
end

response = http.request(req)
block_given? ? yield(response) : response
end

def upload_params
{}
end

def new_upload_http(uri)
Net::HTTP.new(uri.host, uri.port).tap do |http|
http.use_ssl = uri.scheme == 'https'
http.open_timeout = 30
http.read_timeout = 300
http.start
end
end
end
end
end
82 changes: 82 additions & 0 deletions lib/yt/collections/resumable_upload_sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
require 'net/http'
require 'uri'
require 'yt/collections/base'
require 'yt/models/resumable_upload_session'

module Yt
module Collections
class ResumableUploadSessions < Base

def insert(body = {}, options = {})
@remote_url_auth = options[:remote_url_auth]
content_length = resolve_file_size(options)

@insert_options = options.merge(file_size: content_length)
@headers = headers_for content_length

@remote_auth = options[:remote_auth]
do_insert body: body, headers: @headers
end

private

def attributes_for_new_item(data)
@insert_options.slice(:file_path, :remote_url, :file_size).tap do |attributes|
attributes[:url] = data['Location']
attributes[:content_type] = @parent.upload_content_type
attributes[:chunk_size] = @insert_options.fetch(:chunk_size, 0)
attributes[:max_retries] = @insert_options.fetch(:max_retries, 10)
attributes[:auth] = @auth
attributes[:remote_url_auth] = @remote_url_auth
attributes[:remote_auth] = @remote_auth
attributes[:remote_url_refresh] = @insert_options[:remote_url_refresh]
end
end

def insert_params
super.tap do |params|
params[:response_format] = nil
params[:path] = @parent.resumable_upload_path
options = @insert_options.slice(:on_behalf_of_content_owner_channel)
params[:params] = @parent.resumable_upload_params(options).merge uploadType: 'resumable'
end
end

def resolve_file_size(options)
return options[:file_size] if options[:file_size]

if options[:file_path]
File.size(options[:file_path])
elsif options[:remote_url]
uri = URI.parse(options[:remote_url])

Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
request = Net::HTTP::Head.new(uri)
token = remote_url_auth_token
request['Authorization'] = "Bearer #{token}" if token.present?
response = http.request(request)
raise "Cannot determine remote file size: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
response['Content-Length'].to_i
end
end
end

def headers_for(content_length)
{}.tap do |headers|
headers['X-Upload-Content-Length'] = content_length
headers['X-Upload-Content-Type'] = @parent.upload_content_type
end
end

# The result is not in the body but in the headers
def extract_data_from(response)
response.header
end

def remote_url_auth_token
return @remote_url_auth.call if @remote_url_auth
@auth.access_token
end
end
end
end
69 changes: 64 additions & 5 deletions lib/yt/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def upload_video(path_or_url, params = {})
file = URI.open(path_or_url)
session = resumable_sessions.insert file.size, upload_body(params)

session.update(body: file) do |data|
session.upload(body: file) do |data|
Yt::Video.new(
id: data['id'],
snippet: data['snippet'],
Expand All @@ -87,6 +87,25 @@ def upload_video(path_or_url, params = {})
end
end

# Uploads a video using the resumable upload protocol with chunked
# uploads. Returns a {ResumableUploadSession} that is already
# initiated and ready for +next_chunk+.
#
# @param path_or_url [String] local path or remote URL to the video file.
# @param params [Hash] video metadata and upload options.
# @option params [String] :title The video's title.
# @option params [String] :description The video's description.
# @option params [Array<String>] :tags The video's tags.
# @option params [Integer] :category_id The video's category ID.
# @option params [String] :privacy_status The video's privacy status.
# @option params [Boolean] :self_declared_made_for_kids The video's made for kids self-declaration.
# @option params [Integer] :chunk_size Bytes per chunk (0 = whole file).
# @option params [Integer] :max_retries Max retries per chunk (default: 10).
# @return [Yt::Models::ResumableUploadSession] initiated session ready for next_chunk.
def resumable_upload_video(path_or_url, params = {})
resumable_upload_sessions.insert upload_body(params), upload_options(path_or_url, params)
end

# Creates a playlist in the account’s channel.
# @return [Yt::Models::Playlist] the newly created playlist.
# @param [Hash] params the attributes of the playlist.
Expand Down Expand Up @@ -164,16 +183,22 @@ def create_playlist(params = {})
# the account’s channel.
has_many :subscribers

# @!attribute [r] video_groups
# @return [Yt::Collections::VideoGroups] the video-groups created by the
# account.
has_many :video_groups

# @!attribute [r] resumable_sessions
# @private
# @return [Yt::Collections::ResumableSessions] the sessions used to
# upload videos using the resumable upload protocol.
has_many :resumable_sessions

# @!attribute [r] video_groups
# @return [Yt::Collections::VideoGroups] the video-groups created by the
# account.
has_many :video_groups
# @!attribute [r] resumable_upload_sessions
# @private
# @return [Yt::Collections::ResumableUploadSessions] the sessions used to
# upload videos using the resumable upload protocol.
has_many :resumable_upload_sessions

### PRIVATE API ###

Expand Down Expand Up @@ -211,13 +236,29 @@ def playlist_items_params
def upload_path
'/upload/youtube/v3/videos'
end

# @private
# Tells `has_many :resumable_sessions` what params are set for the object
# associated to the uploaded file.
def upload_params
{part: 'snippet,status'}
end

# @private
# Tells `has_many :resumable_upload_sessions` what path to hit to upload
# a file. Separate from `upload_path` so ContentOwner can override
# `upload_path` for references without affecting chunked video uploads.
def resumable_upload_path
'/upload/youtube/v3/videos'
end

# @private
# Tells `has_many :resumable_upload_sessions` what params are set for the
# object associated to the uploaded file.
def resumable_upload_params(_options = {})
{part: 'snippet,status'}
end

# @private
# Tells `has_many :resumable_sessions` what metadata to set in the object
# associated to the uploaded file.
Expand All @@ -236,6 +277,24 @@ def upload_body(params = {})
end
end

# @private
# Tells `has_many :resumable_upload_sessions` how to read the file —
# locally from disk or by ranged GETs against a remote URL.
def upload_options(path_or_url, params = {})
remote_url_auth = params.delete(:remote_url_auth)
remote_auth = params.delete(:remote_auth)

params.slice(:file_size, :chunk_size, :on_behalf_of_content_owner_channel).tap do |options|
if path_or_url.match?(%r{\Ahttps?://})
options[:remote_url] = path_or_url
options[:remote_url_auth] = remote_url_auth if remote_url_auth
options[:remote_auth] = remote_auth if remote_auth
else
options[:file_path] = path_or_url
end
end
end

# @private
# Tells `has_many :resumable_sessions` what type of file can be uploaded.
def upload_content_type
Expand Down
2 changes: 1 addition & 1 deletion lib/yt/models/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def expiration_date(options = {})
if options['expires_in']
Time.now + options['expires_in'].seconds
else
Time.parse options['expires_at'] rescue nil
Time.parse options['expires_at'].to_s rescue nil
end
end
end
Expand Down
14 changes: 13 additions & 1 deletion lib/yt/models/content_owner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def upload_reference_file(path_or_url, params = {})
file = URI.open(path_or_url)
session = resumable_sessions.insert file.size, params

session.update(body: file) do |data|
session.upload(body: file) do |data|
Yt::Reference.new id: data['id'], data: data, auth: self
end
end
Expand Down Expand Up @@ -100,6 +100,18 @@ def upload_params
{part: 'snippet,status', on_behalf_of_content_owner: self.owner_name}
end

# @private
# YouTube requires `onBehalfOfContentOwnerChannel` alongside
# `onBehalfOfContentOwner` on `videos.insert`; the caller passes it
# through `:on_behalf_of_content_owner_channel`.
def resumable_upload_params(options = {})
params = {part: 'snippet,status', on_behalf_of_content_owner: owner_name}
if (channel = options[:on_behalf_of_content_owner_channel])
params[:on_behalf_of_content_owner_channel] = channel
end
params
end

# @private
# Tells `has_many :video_groups` that content_owner.video_groups should
# return all the video-groups *on behalf of* the content owner
Expand Down
39 changes: 19 additions & 20 deletions lib/yt/models/resumable_session.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
require 'json'
require 'yt/models/base'
require 'yt/actions/upload'

module Yt
module Models
# @private
# Provides methods to upload videos with the resumable upload protocol.
# @see https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol
class ResumableSession < Base
include Actions::Upload

# Sets up a resumable session using the URI returned by YouTube
def initialize(options = {})
@uri = URI.parse options[:url]
@auth = options[:auth]
@headers = options[:headers]
end

def update(params = {})
do_update(params) {|data| yield data}
def upload(params = {})
body = params[:body]
do_upload headers: upload_headers(body), body: body do |response|
yield JSON.parse(response.body)
end
end

# Uploads a thumbnail using the current resumable session
Expand All @@ -23,29 +30,21 @@ def update(params = {})
# @return the new thumbnail resource for the given image.
# @see https://developers.google.com/youtube/v3/docs/thumbnails#resource
def upload_thumbnail(file)
do_update(body: file) {|data| data['items'].first}
do_upload headers: upload_headers(file), body: file do |response|
data = JSON.parse(response.body)
data['items'].first
end
end

private
private

def session_params
URI.decode_www_form(@uri.query || "").to_h
def upload_params
{ uri: @uri, token: @auth.access_token }
end

# @note: YouTube documentation states that a valid upload returns an HTTP
# code of 201 Created -- however it looks like the actual code is 200.
# To be sure to include both cases, HTTPSuccess is used
def update_params
super.tap do |params|
params[:request_format] = :file
params[:host] = @uri.host
params[:path] = @uri.path
params[:expected_response] = Net::HTTPSuccess
params[:headers] = @headers
params[:camelize_params] = false
params[:params] = session_params
end
def upload_headers(body)
@headers.merge('Content-Length' => body.size.to_s)
end
end
end
end
end
Loading
Loading