diff --git a/lib/yt/actions/list.rb b/lib/yt/actions/list.rb index 1b81accf..2538179b 100644 --- a/lib/yt/actions/list.rb +++ b/lib/yt/actions/list.rb @@ -12,15 +12,22 @@ def first! first.tap{|item| raise Errors::NoItems, error_message unless item} end + def etag + @etag ||= fetch_etag + end + private def list + owner = self @last_index, @page_token = 0, nil Enumerator.new(-> {total_results}) do |items| while next_item = find_next items << next_item end @where_params = {} + end.tap do |enum| + enum.define_singleton_method(:etag) { owner.instance_variable_get(:@etag) } end end @@ -63,7 +70,7 @@ def resource_class # Can be overwritten by subclasses that initialize instance with # a different set of parameters. def new_item(data) - resource_class.new attributes_for_new_item(data) + resource_class.new attributes_for_new_item(data).merge(etag: data['etag']) end # @private @@ -91,11 +98,17 @@ def eager_load_items_from(items) def fetch_page(params = {}) @last_response = list_request(params).run + @etag = @last_response.body['etag'] token = @last_response.body['nextPageToken'] items = extract_items @last_response.body {items: items, token: token} end + def fetch_etag + response = list_request(list_params).run + response.body['etag'] + end + def list_request(params = {}) @list_request = Yt::Request.new(params).tap do |request| print "#{request.as_curl}\n" if Yt.configuration.developing? diff --git a/lib/yt/collections/assets.rb b/lib/yt/collections/assets.rb index 28108771..83ebf4bf 100644 --- a/lib/yt/collections/assets.rb +++ b/lib/yt/collections/assets.rb @@ -17,7 +17,7 @@ def insert(attributes = {}) def new_item(data) klass = (data["kind"] == "youtubePartner#assetSnippet") ? Yt::AssetSnippet : Yt::Asset - klass.new attributes_for_new_item(data) + klass.new attributes_for_new_item(data).merge(etag: data['etag']) end # @return [Hash] the parameters to submit to YouTube to list assets diff --git a/lib/yt/collections/playlist_items.rb b/lib/yt/collections/playlist_items.rb index e2231665..d8dcbc7d 100644 --- a/lib/yt/collections/playlist_items.rb +++ b/lib/yt/collections/playlist_items.rb @@ -42,7 +42,9 @@ def list_params end def playlist_items_params - resources_params.merge playlist_id: @parent.id + params = resources_params + params.merge!(playlist_id: @parent.id) if @parent + apply_where_params! params end def insert_parts diff --git a/lib/yt/collections/resources.rb b/lib/yt/collections/resources.rb index 4c9a6d34..11a1a7ba 100644 --- a/lib/yt/collections/resources.rb +++ b/lib/yt/collections/resources.rb @@ -19,7 +19,7 @@ def insert(attributes = {}, options = {}) # private def attributes_for_new_item(data) - {id: data['id'], snippet: data['snippet'], status: data['status'], auth: @auth} + {id: data['id'], snippet: data['snippet'], status: data['status'], auth: @auth, etag: data['etag']} end def resources_params diff --git a/lib/yt/models/playlist.rb b/lib/yt/models/playlist.rb index 68e545ca..d0a5e249 100644 --- a/lib/yt/models/playlist.rb +++ b/lib/yt/models/playlist.rb @@ -209,11 +209,6 @@ def reports_params end end - # @private - def exists? - !@id.nil? - end - private # @see https://developers.google.com/youtube/v3/docs/playlists/update diff --git a/lib/yt/models/playlist_item.rb b/lib/yt/models/playlist_item.rb index 3624731e..fec7ba44 100644 --- a/lib/yt/models/playlist_item.rb +++ b/lib/yt/models/playlist_item.rb @@ -83,11 +83,6 @@ def video ### PRIVATE API ### - # @private - def exists? - !@id.nil? - end - # @private # Override Resource's new to set video if the response includes it def initialize(options = {}) diff --git a/lib/yt/models/resource.rb b/lib/yt/models/resource.rb index b075891a..24dfdf9c 100644 --- a/lib/yt/models/resource.rb +++ b/lib/yt/models/resource.rb @@ -20,6 +20,19 @@ def id end end + ### ETAG ### + def etag + return nil unless exists? + + @etag ||= fetch_etag + end + + ### EXISTS? ### + + def exists? + !@id.nil? + end + ### STATUS ### has_one :status @@ -56,6 +69,7 @@ def initialize(options = {}) @id = options[:id] end @auth = options[:auth] + @etag = options[:etag] @snippet = Snippet.new(data: options[:snippet]) if options[:snippet] @status = Status.new(data: options[:status]) if options[:status] end @@ -139,6 +153,19 @@ def fetch_channel_id end end + def fetch_etag + return nil if @id.nil? + + collection = resource_collection.new(auth: @auth).where(id: @id)&.first + collection&.etag + end + + def resource_collection + name = self.class.to_s.demodulize.pluralize + require "yt/collections/#{name.underscore}" + "Yt::Collections::#{name}".constantize + end + # Since YouTube API only returns tags on Videos#list, the memoized # `@snippet` is erased if the video was instantiated through Video#search # (e.g., by calling account.videos or channel.videos), so that the full diff --git a/lib/yt/models/video.rb b/lib/yt/models/video.rb index 630ef9d2..a162cae5 100644 --- a/lib/yt/models/video.rb +++ b/lib/yt/models/video.rb @@ -635,11 +635,6 @@ def initialize(options = {}) end end - # @private - def exists? - !@id.nil? - end - # @private # Tells `has_reports` to retrieve the reports from YouTube Analytics API # either as a Channel or as a Content Owner. diff --git a/spec/collections/channels_spec.rb b/spec/collections/channels_spec.rb new file mode 100644 index 00000000..2f0429f2 --- /dev/null +++ b/spec/collections/channels_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' +require 'yt/collections/channels' + +describe Yt::Collections::Channels do + subject(:collection) { Yt::Collections::Channels.new } + + describe '#etag' do + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/comment_threads_spec.rb b/spec/collections/comment_threads_spec.rb index ea67be48..93cd4d04 100644 --- a/spec/collections/comment_threads_spec.rb +++ b/spec/collections/comment_threads_spec.rb @@ -43,4 +43,19 @@ end end end + + describe '#etag' do + let(:parent) { Yt::Video.new id: 'any-id' } + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end end diff --git a/spec/collections/playlist_items_spec.rb b/spec/collections/playlist_items_spec.rb index d7f6ed58..f77c82eb 100644 --- a/spec/collections/playlist_items_spec.rb +++ b/spec/collections/playlist_items_spec.rb @@ -41,4 +41,19 @@ it { expect(collection.delete_all).to eq [true] } end -end \ No newline at end of file + + describe '#etag' do + let(:etag) { 'etag123' } + let(:behave) { receive(:fetch_etag).and_call_original } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/playlists_spec.rb b/spec/collections/playlists_spec.rb index 53a7d21b..9ba28c8a 100644 --- a/spec/collections/playlists_spec.rb +++ b/spec/collections/playlists_spec.rb @@ -24,4 +24,19 @@ it { expect(collection.delete_all).to eq [true] } end -end \ No newline at end of file + + describe '#etag' do + let(:etag) { 'etag123' } + let(:behave) { receive(:fetch_etag).and_call_original } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/subscriptions_spec.rb b/spec/collections/subscriptions_spec.rb index 23c36c2d..a23c643c 100644 --- a/spec/collections/subscriptions_spec.rb +++ b/spec/collections/subscriptions_spec.rb @@ -2,7 +2,7 @@ require 'yt/collections/subscriptions' describe Yt::Collections::Subscriptions do - subject(:collection) { Yt::Collections::Subscriptions.new } + subject(:collection) { Yt::Collections::Subscriptions.new parent: Yt::Channel.new(id: 'any-id') } let(:msg) { {response_body: {error: {errors: [{reason: reason}]}}}.to_json } before { expect(collection).to behave } @@ -22,4 +22,19 @@ it { expect{collection.insert ignore_errors: true}.not_to fail } end end -end \ No newline at end of file + + describe '#etag' do + let(:etag) { 'etag123' } + let(:behave) { receive(:fetch_etag).and_call_original } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + expect(collection.etag).to eq etag + end + end +end diff --git a/spec/collections/videos_spec.rb b/spec/collections/videos_spec.rb index f7f0ab54..000ef096 100644 --- a/spec/collections/videos_spec.rb +++ b/spec/collections/videos_spec.rb @@ -40,4 +40,19 @@ end end end + + describe '#etag' do + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag'=> etag, 'items'=> [], 'pageInfo'=> {'totalResults'=>0}}) + end + end + + it 'returns the etag from the list response' do + collection.count + expect(collection.etag).to eq etag + end + end end diff --git a/spec/models/channel_spec.rb b/spec/models/channel_spec.rb index e0c3ae06..1dac7174 100644 --- a/spec/models/channel_spec.rb +++ b/spec/models/channel_spec.rb @@ -4,6 +4,13 @@ describe Yt::Channel do subject(:channel) { Yt::Channel.new attrs } + describe '#etag' do + context 'given the API response includes an etag' do + let(:attrs) { {id: 'any-id', etag: '12345'} } + it { expect(channel.etag).to eq '12345' } + end + end + describe '#title' do context 'given a snippet with a title' do let(:attrs) { {snippet: {"title"=>"Fullscreen"}} } diff --git a/spec/models/playlist_spec.rb b/spec/models/playlist_spec.rb index 0435566e..60e033bc 100644 --- a/spec/models/playlist_spec.rb +++ b/spec/models/playlist_spec.rb @@ -4,6 +4,47 @@ describe Yt::Playlist do subject(:playlist) { Yt::Playlist.new attrs } + describe '#etag' do + context 'given the API response includes an etag' do + let(:attrs) { {id: 'any-id', etag: 'etag123'} } + it { expect(playlist.etag).to eq 'etag123' } + end + + context 'given only an ID is provided' do + let(:attrs) { {id: 'any-id', auth: double('auth')} } + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => etag, 'items'=> [{'id'=> 'any-id', 'etag'=> etag}], 'pageInfo'=> {'totalResults'=> 1}}) + end + end + + it 'fetches the etag from YouTube' do + expect(playlist.etag).to eq etag + end + end + + context 'given an ID is provided but no auth' do + let(:attrs) { {id: 'any-id'} } + + it 'returns the etag from the API response even if no items are found' do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => 'mockedEtag', 'items'=> [], 'pageInfo'=> {'totalResults'=> 0}}) + end + expect(playlist.etag).to eq 'mockedEtag' + end + end + + context 'given no ID is provided' do + let(:attrs) { {} } + + it 'returns nil' do + expect_any_instance_of(Yt::Request).not_to receive(:run) + expect(playlist.etag).to be_nil + end + end + end describe '#title' do context 'given a snippet with a title' do diff --git a/spec/models/video_spec.rb b/spec/models/video_spec.rb index 537940c5..f4cf4476 100644 --- a/spec/models/video_spec.rb +++ b/spec/models/video_spec.rb @@ -4,6 +4,48 @@ describe Yt::Video do subject(:video) { Yt::Video.new attrs } + describe '#etag' do + context 'given the API response includes an etag' do + let(:attrs) { {id: 'any-id', etag: '12345'} } + it { expect(video.etag).to eq '12345' } + end + + context 'given only an ID is provided' do + let(:attrs) { {id: 'any-id', auth: double('auth')} } + let(:etag) { 'etag123' } + + before do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => etag, 'items'=> [{'id'=> 'any-id', 'etag'=> etag}], 'pageInfo'=> {'totalResults'=> 1}}) + end + end + + it 'fetches the etag from YouTube' do + expect(video.etag).to eq etag + end + end + + context 'given an ID is provided but no auth' do + let(:attrs) { {id: 'any-id'} } + + it 'returns the etag from the API response even if no items are found' do + expect_any_instance_of(Yt::Request).to receive(:run).once do + double(body: {'etag' => 'mockedEtag', 'items'=> [], 'pageInfo'=> {'totalResults'=> 0}}) + end + expect(video.etag).to eq 'mockedEtag' + end + end + + context 'given no ID is provided' do + let(:attrs) { {} } + + it 'returns nil' do + expect_any_instance_of(Yt::Request).not_to receive(:run) + expect(video.etag).to be_nil + end + end + end + describe '#snippet' do context 'given fetching a video returns a snippet' do let(:attrs) { {snippet: {"title"=>"Fullscreen Creator Platform"}} } diff --git a/spec/requests/as_account/etag_spec.rb b/spec/requests/as_account/etag_spec.rb new file mode 100644 index 00000000..9ca64919 --- /dev/null +++ b/spec/requests/as_account/etag_spec.rb @@ -0,0 +1,130 @@ +# encoding: UTF-8 + +require 'spec_helper' +require 'yt/models/video' +require 'yt/models/playlist' +require 'yt/models/channel' + +describe 'Etag Integration Tests', :device_app, :vcr do + describe 'Video etag' do + context 'given a real YouTube video' do + let(:video) { Yt::Video.new id: '9bZkp7q19f0', auth: test_account } + + it 'returns a valid etag' do + expect(video.etag).to be_a String + expect(video.etag).not_to be_empty + expect(video.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = video.etag + second_etag = video.etag + expect(first_etag).to eq second_etag + end + end + + context 'given a video from my account' do + let(:video) { test_account.videos.first } + + it 'returns a valid etag' do + expect(video.etag).to be_a String + expect(video.etag).not_to be_empty + expect(video.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end + + describe 'Playlist etag' do + context 'given a real YouTube playlist' do + let(:playlist) { Yt::Playlist.new id: 'PLSWYkYzOr', auth: test_account } + + it 'returns a valid etag' do + expect(playlist.etag).to be_a String + expect(playlist.etag).not_to be_empty + expect(playlist.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = playlist.etag + second_etag = playlist.etag + expect(first_etag).to eq second_etag + end + end + + context 'given a playlist from my account' do + let(:playlist) { test_account.playlists.first } + + it 'returns a valid etag' do + expect(playlist.etag).to be_a String + expect(playlist.etag).not_to be_empty + expect(playlist.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end + + describe 'Channel etag' do + context 'given a real YouTube channel' do + let(:channel) { Yt::Channel.new id: 'UCxO1tY8h1AhOz0T4ENwmpow', auth: test_account } + + it 'returns a valid etag' do + expect(channel.etag).to be_a String + expect(channel.etag).not_to be_empty + expect(channel.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = channel.etag + second_etag = channel.etag + expect(first_etag).to eq second_etag + end + end + + context 'given my own channel' do + let(:channel) { test_account.channel } + + it 'returns a valid etag' do + expect(channel.etag).to be_a String + expect(channel.etag).not_to be_empty + expect(channel.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end + + describe 'Collection etags' do + context 'given videos collection' do + let(:videos) { test_account.videos } + + it 'returns a valid etag' do + expect(videos.etag).to be_a String + expect(videos.etag).not_to be_empty + expect(videos.etag).to match(/^[A-Za-z0-9_-]+$/) + end + + it 'returns the same etag on subsequent calls' do + first_etag = videos.etag + second_etag = videos.etag + expect(first_etag).to eq second_etag + end + end + + context 'given playlists collection' do + let(:playlists) { test_account.playlists } + + it 'returns a valid etag' do + expect(playlists.etag).to be_a String + expect(playlists.etag).not_to be_empty + expect(playlists.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + + context 'given subscribed channels collection' do + let(:subscribed_channels) { test_account.subscribed_channels } + + it 'returns a valid etag' do + expect(subscribed_channels.etag).to be_a String + expect(subscribed_channels.etag).not_to be_empty + expect(subscribed_channels.etag).to match(/^[A-Za-z0-9_-]+$/) + end + end + end +end \ No newline at end of file diff --git a/spec/requests/as_server_app/etag_spec.rb b/spec/requests/as_server_app/etag_spec.rb new file mode 100644 index 00000000..821b21b1 --- /dev/null +++ b/spec/requests/as_server_app/etag_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'yt/models/channel' +require 'yt/models/playlist' +require 'yt/models/video' + +describe 'Etag support', server_app: true do + context 'when making API requests' do + it 'returns etag for public channel' do + VCR.use_cassette('etag_public_channel') do + channel = Yt::Channel.new(id: 'UCJkWoS4RsldA1coEIot5yDA') # MotherGooseClub + expect(channel.etag).to be_present + end + end + + it 'returns etag for public playlist' do + VCR.use_cassette('etag_public_playlist') do + playlist = Yt::Playlist.new(id: 'PLgnDMw6xI5plOXaKs5zNDB3zRWYEa8Zi-') # Valid public playlist from existing tests + expect(playlist.etag).to be_present + end + end + + it 'returns etag for public video' do + VCR.use_cassette('etag_public_video') do + video = Yt::Video.new(id: '9bZkp7q19f0') # PSY - GANGNAM STYLE + expect(video.etag).to be_present + end + end + end +end \ No newline at end of file