yt 0.4.10 → 0.5.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/HISTORY.md +10 -0
- data/README.md +7 -3
- data/TODO.md +6 -2
- data/lib/yt/actions/delete.rb +1 -1
- data/lib/yt/actions/delete_all.rb +1 -1
- data/lib/yt/actions/insert.rb +1 -1
- data/lib/yt/actions/list.rb +8 -2
- data/lib/yt/actions/update.rb +1 -1
- data/lib/yt/associations.rb +1 -0
- data/lib/yt/associations/details_sets.rb +1 -1
- data/lib/yt/associations/ids.rb +20 -0
- data/lib/yt/associations/snippets.rb +1 -1
- data/lib/yt/associations/subscriptions.rb +2 -2
- data/lib/yt/associations/user_infos.rb +1 -1
- data/lib/yt/collections/base.rb +1 -0
- data/lib/yt/collections/details_sets.rb +0 -1
- data/lib/yt/collections/ids.rb +22 -0
- data/lib/yt/collections/playlist_items.rb +3 -3
- data/lib/yt/collections/playlists.rb +0 -1
- data/lib/yt/collections/snippets.rb +0 -1
- data/lib/yt/collections/subscriptions.rb +4 -3
- data/lib/yt/collections/user_infos.rb +0 -2
- data/lib/yt/collections/videos.rb +0 -1
- data/lib/yt/errors/base.rb +43 -0
- data/lib/yt/errors/error.rb +8 -0
- data/lib/yt/errors/failed.rb +17 -0
- data/lib/yt/errors/no_items.rb +17 -0
- data/lib/yt/errors/unauthenticated.rb +34 -0
- data/lib/yt/models/account.rb +1 -17
- data/lib/yt/models/base.rb +1 -0
- data/lib/yt/models/description.rb +11 -35
- data/lib/yt/models/id.rb +4 -0
- data/lib/yt/models/request.rb +102 -0
- data/lib/yt/models/resource.rb +13 -2
- data/lib/yt/models/subscription.rb +3 -2
- data/lib/yt/models/url.rb +88 -0
- data/lib/yt/version.rb +1 -1
- data/spec/associations/device_auth/details_sets_spec.rb +1 -1
- data/spec/associations/device_auth/ids_spec.rb +19 -0
- data/spec/associations/device_auth/playlist_items_spec.rb +3 -3
- data/spec/associations/device_auth/snippets_spec.rb +2 -2
- data/spec/associations/device_auth/user_infos_spec.rb +7 -2
- data/spec/associations/server_auth/details_sets_spec.rb +4 -4
- data/spec/associations/server_auth/ids_spec.rb +18 -0
- data/spec/associations/server_auth/snippets_spec.rb +2 -2
- data/spec/collections/playlist_items_spec.rb +25 -5
- data/spec/collections/subscriptions_spec.rb +6 -4
- data/spec/errors/failed_spec.rb +9 -0
- data/spec/errors/no_items_spec.rb +9 -0
- data/spec/errors/unauthenticated_spec.rb +9 -0
- data/spec/models/account_spec.rb +0 -0
- data/spec/models/description_spec.rb +2 -2
- data/spec/models/request_spec.rb +29 -0
- data/spec/models/resource_spec.rb +21 -0
- data/spec/models/subscription_spec.rb +6 -4
- data/spec/models/url_spec.rb +72 -0
- data/spec/support/fail_matcher.rb +14 -0
- metadata +32 -4
- data/lib/yt/actions/request.rb +0 -112
- data/lib/yt/actions/request_error.rb +0 -11
data/lib/yt/models/account.rb
CHANGED
@@ -10,28 +10,12 @@ module Yt
|
|
10
10
|
:name, :given_name, :family_name, :profile_url, :avatar_url, :locale, :hd]
|
11
11
|
|
12
12
|
def initialize(options = {})
|
13
|
-
# By default is someone passes a refresh_token but not a scope, we can assume it's a youtube one
|
14
|
-
@scope = options.fetch :scope, 'https://www.googleapis.com/auth/youtube'
|
15
13
|
@access_token = options[:access_token]
|
16
14
|
@refresh_token = options[:refresh_token]
|
17
15
|
@redirect_url = options[:redirect_url]
|
18
16
|
end
|
19
17
|
|
20
|
-
def
|
21
|
-
# TODO incremental scope
|
22
|
-
|
23
|
-
# HERE manage the fact that we must change some scope on device,
|
24
|
-
# like 'https://www.googleapis.com/auth/youtube.readonly' is not accepted
|
25
|
-
if Yt.configuration.scenario == :device_app && scope == 'https://www.googleapis.com/auth/youtube.readonly'
|
26
|
-
scope = 'https://www.googleapis.com/auth/youtube'
|
27
|
-
end
|
28
|
-
|
29
|
-
# TODO !! include? is not enough, because (for instance) 'youtube' also includes 'youtube.readonly'
|
30
|
-
|
31
|
-
# unless (@scope == scope) || (scope == 'https://www.googleapis.com/auth/youtube.readonly' && @scope =='https://www.googleapis.com/auth/youtube')
|
32
|
-
# @scope = scope
|
33
|
-
# @access_token = @refresh_token = nil
|
34
|
-
# end
|
18
|
+
def access_token
|
35
19
|
@access_token ||= refresh_access_token || get_access_token
|
36
20
|
end
|
37
21
|
|
data/lib/yt/models/base.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'yt/models/url'
|
2
|
+
|
1
3
|
module Yt
|
2
4
|
# Provides read-only access to the description of a YouTube resource.
|
3
5
|
# Resources with descriptions are: videos and channels.
|
@@ -20,79 +22,53 @@ module Yt
|
|
20
22
|
def has_link_to_video?
|
21
23
|
# TODO: might take as an option WHICH video to link to
|
22
24
|
# in order to check if it's my own video
|
23
|
-
|
25
|
+
links.any?{|link| link.kind == :video}
|
24
26
|
end
|
25
27
|
|
26
28
|
# Returns whether the description includes a YouTube channel URL
|
27
29
|
#
|
28
30
|
# @example
|
29
31
|
#
|
30
|
-
# description = Yt::Description.new Link to channel: youtube.com/fullscreen
|
32
|
+
# description = Yt::Description.new 'Link to channel: youtube.com/fullscreen'
|
31
33
|
# description.has_link_to_channel? #=> true
|
32
34
|
#
|
33
35
|
# @return [Boolean] Whether the description includes a link to a channel
|
34
36
|
def has_link_to_channel?(options = {}) # TODO: which channel
|
35
37
|
# TODO: might take as an option WHICH channel to link to
|
36
38
|
# in order to check if it's my own channel
|
37
|
-
|
39
|
+
links.any?{|link| link.kind == :channel}
|
38
40
|
end
|
39
41
|
|
40
42
|
# Returns whether the description includes a YouTube subscription URL
|
41
43
|
#
|
42
44
|
# @example
|
43
45
|
#
|
44
|
-
# description = Yt::Description.new Link to subscribe: youtube.com/subscription_center?add_user=fullscreen
|
46
|
+
# description = Yt::Description.new 'Link to subscribe: youtube.com/subscription_center?add_user=fullscreen'
|
45
47
|
# description.has_link_to_subscribe? #=> true
|
46
48
|
#
|
47
49
|
# @return [Boolean] Whether the description includes a link to subscribe
|
48
50
|
def has_link_to_subscribe?(options = {}) # TODO: which channel
|
49
51
|
# TODO: might take as an option WHICH channel to subscribe to
|
50
52
|
# in order to check if it's my own channel
|
51
|
-
|
53
|
+
links.any?{|link| link.kind == :subscription}
|
52
54
|
end
|
53
55
|
|
54
56
|
# Returns whether the description includes a YouTube playlist URL
|
55
57
|
#
|
56
58
|
# @example
|
57
59
|
#
|
58
|
-
# description = Yt::Description.new Link to playlist: youtube.com/playlist?list=LLxO1tY8h1AhOz0T4ENwmpow
|
60
|
+
# description = Yt::Description.new 'Link to playlist: youtube.com/playlist?list=LLxO1tY8h1AhOz0T4ENwmpow'
|
59
61
|
# description.has_link_to_playlist? #=> true
|
60
62
|
#
|
61
63
|
# @return [Boolean] Whether the description includes a link to a playlist
|
62
64
|
def has_link_to_playlist?
|
63
|
-
|
65
|
+
links.any?{|link| link.kind == :playlist}
|
64
66
|
end
|
65
67
|
|
66
68
|
private
|
67
69
|
|
68
|
-
def
|
69
|
-
|
70
|
-
end
|
71
|
-
|
72
|
-
def regex_for(key)
|
73
|
-
host, name = '(?:https?://)?(?:www\.)?', '([a-zA-Z0-9_-]+)'
|
74
|
-
case key
|
75
|
-
when :video_long_url
|
76
|
-
%r{#{host}youtube\.com/watch\?v=#{name}}
|
77
|
-
when :video_short_url
|
78
|
-
%r{#{host}youtu\.be/#{name}}
|
79
|
-
when :channel_long_url
|
80
|
-
%r{#{host}youtube\.com/channel/#{name}}
|
81
|
-
when :channel_short_url
|
82
|
-
%r{#{host}youtube\.com/#{name}}
|
83
|
-
when :channel_user_url
|
84
|
-
%r{#{host}youtube\.com/user/#{name}}
|
85
|
-
when :subscribe_center_url
|
86
|
-
%r{#{host}youtube\.com/subscription_center\?add_user=#{name}}
|
87
|
-
when :subscribe_widget_url
|
88
|
-
%r{#{host}youtube\.com/subscribe_widget\?p=#{name}}
|
89
|
-
when :subscribe_confirm_url
|
90
|
-
%r{#{host}youtube\.com/channel/(?:[a-zA-Z0-9&_=-]*)\?sub_confirmation=1}
|
91
|
-
when :playlist_long_url
|
92
|
-
%r{#{host}youtube\.com/playlist\?list=#{name}}
|
93
|
-
when :playlist_embed_url
|
94
|
-
%r{#{host}youtube\.com/watch\?v=(?:[a-zA-Z0-9&_=-]*)&list=#{name}}
|
95
|
-
end
|
70
|
+
def links
|
71
|
+
@links ||= self.split(' ').map{|word| URL.new word}
|
96
72
|
end
|
97
73
|
end
|
98
74
|
end
|
data/lib/yt/models/id.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'net/http' # for Net::HTTP.start
|
2
|
+
require 'uri' # for URI.json
|
3
|
+
require 'json' # for JSON.parse
|
4
|
+
require 'active_support/core_ext' # for Hash.from_xml, Hash.to_param
|
5
|
+
|
6
|
+
require 'yt/config'
|
7
|
+
require 'yt/errors/failed'
|
8
|
+
require 'yt/errors/unauthenticated'
|
9
|
+
|
10
|
+
module Yt
|
11
|
+
class Request
|
12
|
+
def initialize(options = {})
|
13
|
+
options[:query] ||= options[:params].to_param
|
14
|
+
@uri = URI::HTTPS.build options.slice(:host, :path, :query)
|
15
|
+
@method = options.fetch :method, :get
|
16
|
+
@format = options[:format]
|
17
|
+
@body = options[:body]
|
18
|
+
@body_type = options[:body_type]
|
19
|
+
@auth = options[:auth]
|
20
|
+
@headers = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
def run
|
24
|
+
add_authorization_to_request! if requires_authorization?
|
25
|
+
fetch_response.tap do |response|
|
26
|
+
if response.is_a? Net::HTTPSuccess
|
27
|
+
response.body = parse_format response.body
|
28
|
+
else
|
29
|
+
raise Errors::Failed, to_error(response)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.default_params
|
35
|
+
{}.tap do |params|
|
36
|
+
params[:format] = :json
|
37
|
+
params[:host] = 'www.googleapis.com'
|
38
|
+
params[:body_type] = :json
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def add_authorization_to_request!
|
45
|
+
if @auth.respond_to? :access_token
|
46
|
+
@headers['Authorization'] = "Bearer #{@auth.access_token}"
|
47
|
+
elsif Yt.configuration.api_key
|
48
|
+
params = URI.decode_www_form @uri.query || ''
|
49
|
+
params << [:key, Yt.configuration.api_key]
|
50
|
+
@uri.query = URI.encode_www_form params
|
51
|
+
else
|
52
|
+
raise Errors::Unauthenticated, to_error
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def requires_authorization?
|
57
|
+
@uri.host == Request.default_params[:host]
|
58
|
+
end
|
59
|
+
|
60
|
+
def fetch_response
|
61
|
+
klass = "Net::HTTP::#{@method.capitalize}".constantize
|
62
|
+
request = klass.new @uri.request_uri
|
63
|
+
case @body_type
|
64
|
+
when :json
|
65
|
+
request.initialize_http_header 'Content-Type' => 'application/json'
|
66
|
+
request.initialize_http_header 'Content-length' => '0' unless @body
|
67
|
+
request.body = @body.to_json if @body
|
68
|
+
when :form
|
69
|
+
request.set_form_data @body if @body
|
70
|
+
end
|
71
|
+
@headers.each{|k,v| request.add_field k, v}
|
72
|
+
|
73
|
+
Net::HTTP.start(@uri.host, @uri.port, use_ssl: true) do |http|
|
74
|
+
http.request request
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def parse_format(body)
|
79
|
+
case @format
|
80
|
+
when :xml then Hash.from_xml body
|
81
|
+
when :json then JSON body
|
82
|
+
end if body
|
83
|
+
end
|
84
|
+
|
85
|
+
def to_error(response = nil)
|
86
|
+
request_msg = {}.tap do |msg|
|
87
|
+
msg[:method] = @method
|
88
|
+
msg[:headers] = @headers
|
89
|
+
msg[:url] = @uri.to_s
|
90
|
+
msg[:body] = @body
|
91
|
+
end
|
92
|
+
|
93
|
+
response_msg = {}.tap do |msg|
|
94
|
+
msg[:code] = response.code
|
95
|
+
msg[:headers] = {}.tap{|h| response.each_header{|k,v| h[k] = v }}
|
96
|
+
msg[:body] = response.body
|
97
|
+
end if response
|
98
|
+
|
99
|
+
{request: request_msg, response: response_msg}.to_json
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
data/lib/yt/models/resource.rb
CHANGED
@@ -1,16 +1,27 @@
|
|
1
1
|
require 'yt/models/base'
|
2
|
+
require 'yt/models/url'
|
2
3
|
|
3
4
|
module Yt
|
4
5
|
class Resource < Base
|
5
|
-
attr_reader :
|
6
|
+
attr_reader :auth
|
7
|
+
has_one :id
|
6
8
|
has_one :snippet, delegate: [:title, :description, :thumbnail_url, :published_at, :tags]
|
7
9
|
has_one :status, delegate: [:privacy_status, :public?, :private?, :unlisted?]
|
8
10
|
|
9
11
|
def initialize(options = {})
|
10
|
-
@
|
12
|
+
@url = URL.new(options[:url]) if options[:url]
|
13
|
+
@id = options[:id] || (@url.id if @url)
|
11
14
|
@auth = options[:auth]
|
12
15
|
@snippet = Snippet.new(data: options[:snippet]) if options[:snippet]
|
13
16
|
@status = Status.new(data: options[:status]) if options[:status]
|
14
17
|
end
|
18
|
+
|
19
|
+
def kind
|
20
|
+
@url ? @url.kind.to_s : self.class.to_s.demodulize.underscore
|
21
|
+
end
|
22
|
+
|
23
|
+
def username
|
24
|
+
@url.username if @url
|
25
|
+
end
|
15
26
|
end
|
16
27
|
end
|
@@ -13,8 +13,9 @@ module Yt
|
|
13
13
|
def delete(options = {})
|
14
14
|
begin
|
15
15
|
do_delete {@id = nil}
|
16
|
-
rescue
|
17
|
-
|
16
|
+
rescue Errors::Base => error
|
17
|
+
ignorable_errors = error.reasons & ['subscriptionNotFound']
|
18
|
+
raise error unless options[:ignore_errors] && ignorable_errors.any?
|
18
19
|
end
|
19
20
|
!exists?
|
20
21
|
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Yt
|
2
|
+
class URL
|
3
|
+
attr_reader :kind
|
4
|
+
|
5
|
+
def initialize(url)
|
6
|
+
@url = url
|
7
|
+
@kind ||= parse url
|
8
|
+
@match_data ||= {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def id
|
12
|
+
@match_data[:id]
|
13
|
+
rescue IndexError
|
14
|
+
end
|
15
|
+
|
16
|
+
def username
|
17
|
+
@match_data[:username]
|
18
|
+
rescue IndexError
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def parse(url)
|
24
|
+
matching_pattern = patterns.find do |pattern|
|
25
|
+
@match_data = url.match pattern[:regex]
|
26
|
+
end
|
27
|
+
matching_pattern[:kind] if matching_pattern
|
28
|
+
end
|
29
|
+
|
30
|
+
def patterns
|
31
|
+
# @note: :video *must* be the last since one of its regex eats the
|
32
|
+
# remaining patterns. In short, don't change the following order
|
33
|
+
|
34
|
+
@patterns ||= patterns_for :playlist, :subscription, :channel, :video
|
35
|
+
end
|
36
|
+
|
37
|
+
def patterns_for(*kinds)
|
38
|
+
prefix = '^(?:https?://)?(?:www\.)?'
|
39
|
+
suffix = '(?:|/)$'
|
40
|
+
kinds.map do |kind|
|
41
|
+
patterns = send "#{kind}_patterns" # meta programming :/
|
42
|
+
patterns.map do |pattern|
|
43
|
+
{kind: kind, regex: %r{#{prefix}#{pattern}#{suffix}}}
|
44
|
+
end
|
45
|
+
end.flatten
|
46
|
+
end
|
47
|
+
|
48
|
+
def subscription_patterns
|
49
|
+
name = '(?:[a-zA-Z0-9&_=-]*)'
|
50
|
+
|
51
|
+
%W{
|
52
|
+
subscription_center\\?add_user=#{name}
|
53
|
+
subscribe_widget\\?p=#{name}
|
54
|
+
channel/#{name}\\?sub_confirmation=1
|
55
|
+
}.map{|path| "youtube\\.com/#{path}"}
|
56
|
+
end
|
57
|
+
|
58
|
+
def playlist_patterns
|
59
|
+
playlist_id = '(?<id>[a-zA-Z0-9_-]+)'
|
60
|
+
video_id = '(?:[a-zA-Z0-9&_=-]*)'
|
61
|
+
|
62
|
+
%W{
|
63
|
+
playlist\\?list=#{playlist_id}
|
64
|
+
watch\\?v=#{video_id}&list=#{playlist_id}
|
65
|
+
}.map{|path| "youtube\\.com/#{path}"}
|
66
|
+
end
|
67
|
+
|
68
|
+
def video_patterns
|
69
|
+
video_id = '(?<id>[a-zA-Z0-9_-]+)'
|
70
|
+
|
71
|
+
%W{
|
72
|
+
youtube\\.com/watch\\?v=#{video_id}
|
73
|
+
youtu\\.be/#{video_id}
|
74
|
+
}
|
75
|
+
end
|
76
|
+
|
77
|
+
def channel_patterns
|
78
|
+
channel_id = '(?<id>[a-zA-Z0-9_-]+)'
|
79
|
+
username = '(?<username>[a-zA-Z0-9_-]+)'
|
80
|
+
|
81
|
+
%W{
|
82
|
+
channel/#{channel_id}
|
83
|
+
user/#{username}
|
84
|
+
#{username}
|
85
|
+
}.map{|path| "youtube\\.com/#{path}"}
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
data/lib/yt/version.rb
CHANGED
@@ -13,7 +13,7 @@ describe Yt::Associations::DetailsSets, scenario: :device_app do
|
|
13
13
|
|
14
14
|
context 'given an unknown video' do
|
15
15
|
let(:video) { Yt::Video.new id: 'not-a-video-id', auth: account }
|
16
|
-
it { expect
|
16
|
+
it { expect{video.details_set}.to raise_error Yt::Errors::NoItems }
|
17
17
|
end
|
18
18
|
end
|
19
19
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'yt/associations/ids'
|
3
|
+
|
4
|
+
describe Yt::Associations::Ids, scenario: :device_app do
|
5
|
+
let(:account) { Yt.configuration.account }
|
6
|
+
subject(:resource) { Yt::Resource.new url: url, auth: account.auth }
|
7
|
+
|
8
|
+
describe '#id' do
|
9
|
+
context 'given a URL containing an existing username' do
|
10
|
+
let(:url) { 'youtube.com/fullscreen' }
|
11
|
+
it { expect(resource.id).to eq 'UCxO1tY8h1AhOz0T4ENwmpow' }
|
12
|
+
end
|
13
|
+
|
14
|
+
context 'given a URL containing an unknown username' do
|
15
|
+
let(:url) { 'youtube.com/--not--a--valid--username' }
|
16
|
+
it { expect{resource.id}.to raise_error Yt::Errors::NoItems }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -46,12 +46,12 @@ describe Yt::Associations::PlaylistItems, scenario: :device_app do
|
|
46
46
|
|
47
47
|
context 'given an unknown video' do
|
48
48
|
let(:video_id) { 'not-a-video' }
|
49
|
-
it { expect{@playlist.add_video! video_id}.to
|
49
|
+
it { expect{@playlist.add_video! video_id}.to fail.with 'videoNotFound' }
|
50
50
|
end
|
51
51
|
|
52
52
|
context 'given a video of a terminated account' do
|
53
53
|
let(:video_id) { 'kDCpdKeTe5g' }
|
54
|
-
it { expect{@playlist.add_video! video_id}.to
|
54
|
+
it { expect{@playlist.add_video! video_id}.to fail.with 'forbidden' }
|
55
55
|
end
|
56
56
|
end
|
57
57
|
|
@@ -66,7 +66,7 @@ describe Yt::Associations::PlaylistItems, scenario: :device_app do
|
|
66
66
|
describe '#add_videos!' do
|
67
67
|
context 'given one existing and one unknown video' do
|
68
68
|
let(:video_ids) { ['MESycYJytkU', 'not-a-video'] }
|
69
|
-
it { expect{@playlist.add_videos! video_ids}.to
|
69
|
+
it { expect{@playlist.add_videos! video_ids}.to fail.with 'videoNotFound'}
|
70
70
|
end
|
71
71
|
end
|
72
72
|
|
@@ -14,7 +14,7 @@ describe Yt::Associations::Snippets, scenario: :device_app do
|
|
14
14
|
|
15
15
|
context 'given an unknown video resource' do
|
16
16
|
let(:video) { Yt::Video.new id: 'not-a-video-id', auth: account }
|
17
|
-
it { expect
|
17
|
+
it { expect{video.snippet}.to raise_error Yt::Errors::NoItems }
|
18
18
|
end
|
19
19
|
|
20
20
|
context 'given an existing channel resource' do
|
@@ -24,7 +24,7 @@ describe Yt::Associations::Snippets, scenario: :device_app do
|
|
24
24
|
|
25
25
|
context 'given an unknown channel resource' do
|
26
26
|
let(:channel) { Yt::Channel.new id: 'not-a-channel-id', auth: account }
|
27
|
-
it { expect
|
27
|
+
it { expect{channel.snippet}.to raise_error Yt::Errors::NoItems }
|
28
28
|
end
|
29
29
|
end
|
30
30
|
end
|