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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/HISTORY.md +10 -0
  4. data/README.md +7 -3
  5. data/TODO.md +6 -2
  6. data/lib/yt/actions/delete.rb +1 -1
  7. data/lib/yt/actions/delete_all.rb +1 -1
  8. data/lib/yt/actions/insert.rb +1 -1
  9. data/lib/yt/actions/list.rb +8 -2
  10. data/lib/yt/actions/update.rb +1 -1
  11. data/lib/yt/associations.rb +1 -0
  12. data/lib/yt/associations/details_sets.rb +1 -1
  13. data/lib/yt/associations/ids.rb +20 -0
  14. data/lib/yt/associations/snippets.rb +1 -1
  15. data/lib/yt/associations/subscriptions.rb +2 -2
  16. data/lib/yt/associations/user_infos.rb +1 -1
  17. data/lib/yt/collections/base.rb +1 -0
  18. data/lib/yt/collections/details_sets.rb +0 -1
  19. data/lib/yt/collections/ids.rb +22 -0
  20. data/lib/yt/collections/playlist_items.rb +3 -3
  21. data/lib/yt/collections/playlists.rb +0 -1
  22. data/lib/yt/collections/snippets.rb +0 -1
  23. data/lib/yt/collections/subscriptions.rb +4 -3
  24. data/lib/yt/collections/user_infos.rb +0 -2
  25. data/lib/yt/collections/videos.rb +0 -1
  26. data/lib/yt/errors/base.rb +43 -0
  27. data/lib/yt/errors/error.rb +8 -0
  28. data/lib/yt/errors/failed.rb +17 -0
  29. data/lib/yt/errors/no_items.rb +17 -0
  30. data/lib/yt/errors/unauthenticated.rb +34 -0
  31. data/lib/yt/models/account.rb +1 -17
  32. data/lib/yt/models/base.rb +1 -0
  33. data/lib/yt/models/description.rb +11 -35
  34. data/lib/yt/models/id.rb +4 -0
  35. data/lib/yt/models/request.rb +102 -0
  36. data/lib/yt/models/resource.rb +13 -2
  37. data/lib/yt/models/subscription.rb +3 -2
  38. data/lib/yt/models/url.rb +88 -0
  39. data/lib/yt/version.rb +1 -1
  40. data/spec/associations/device_auth/details_sets_spec.rb +1 -1
  41. data/spec/associations/device_auth/ids_spec.rb +19 -0
  42. data/spec/associations/device_auth/playlist_items_spec.rb +3 -3
  43. data/spec/associations/device_auth/snippets_spec.rb +2 -2
  44. data/spec/associations/device_auth/user_infos_spec.rb +7 -2
  45. data/spec/associations/server_auth/details_sets_spec.rb +4 -4
  46. data/spec/associations/server_auth/ids_spec.rb +18 -0
  47. data/spec/associations/server_auth/snippets_spec.rb +2 -2
  48. data/spec/collections/playlist_items_spec.rb +25 -5
  49. data/spec/collections/subscriptions_spec.rb +6 -4
  50. data/spec/errors/failed_spec.rb +9 -0
  51. data/spec/errors/no_items_spec.rb +9 -0
  52. data/spec/errors/unauthenticated_spec.rb +9 -0
  53. data/spec/models/account_spec.rb +0 -0
  54. data/spec/models/description_spec.rb +2 -2
  55. data/spec/models/request_spec.rb +29 -0
  56. data/spec/models/resource_spec.rb +21 -0
  57. data/spec/models/subscription_spec.rb +6 -4
  58. data/spec/models/url_spec.rb +72 -0
  59. data/spec/support/fail_matcher.rb +14 -0
  60. metadata +32 -4
  61. data/lib/yt/actions/request.rb +0 -112
  62. data/lib/yt/actions/request_error.rb +0 -11
@@ -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 access_token_for(scope)
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
 
@@ -1,6 +1,7 @@
1
1
  require 'yt/associations'
2
2
  require 'yt/actions/delete'
3
3
  require 'yt/actions/update'
4
+ require 'yt/errors/error'
4
5
 
5
6
  module Yt
6
7
  class Base
@@ -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
- regex? :video_long_url, :video_short_url
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
- regex? :channel_long_url, :channel_short_url, :channel_user_url
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
- regex? :subscribe_center_url, :subscribe_widget_url, :subscribe_confirm_url
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
- regex? :playlist_long_url, :playlist_embed_url
65
+ links.any?{|link| link.kind == :playlist}
64
66
  end
65
67
 
66
68
  private
67
69
 
68
- def regex?(*keys)
69
- keys.find{|key| self =~ regex_for(key)}
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
@@ -0,0 +1,4 @@
1
+ module Yt
2
+ class Id < String
3
+ end
4
+ end
@@ -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
@@ -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 :id, :auth
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
- @id = options[:id]
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 Yt::RequestError => error
17
- raise error unless options[:ignore_not_found] && error.reasons.include?('subscriptionNotFound')
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
@@ -1,3 +1,3 @@
1
1
  module Yt
2
- VERSION = '0.4.10'
2
+ VERSION = '0.5.3'
3
3
  end
@@ -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(video.details_set).to be_nil }
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 raise_error Yt::RequestError }
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 raise_error Yt::RequestError }
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 raise_error Yt::RequestError }
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(video.snippet).to be_nil }
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(channel.snippet).to be_nil }
27
+ it { expect{channel.snippet}.to raise_error Yt::Errors::NoItems }
28
28
  end
29
29
  end
30
30
  end