yt 0.4.10 → 0.5.3
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.
- 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
|