Skip to content

Commit

Permalink
Merge pull request #35 from claudiob/add-playlist-videos
Browse files Browse the repository at this point in the history
Add playlist videos
  • Loading branch information
claudiob committed Jan 5, 2017
2 parents 1c707ed + 0b681cc commit 5ace72d
Show file tree
Hide file tree
Showing 18 changed files with 230 additions and 46 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Expand Up @@ -7,4 +7,5 @@
* Change statistics_set to statistics
* Removed Yt::URL
* Change video.duration to video.seconds
* Change Playlist#playlist_items to Playlist#items
* Change Playlist#playlist_items to Playlist#items
* Add Playlist#videos
8 changes: 6 additions & 2 deletions docs/channels.html
Expand Up @@ -94,13 +94,14 @@ <h4>List of <code>Yt::Channel</code> data methods</h4>
<dd><a class="anchor" id="select"></a><div class="highlight"><pre>
{% include example.html object='slow = channel' result='without select: 2 HTTP requests' %}
{% include example.html object='slow' method='title' result='one HTTP request to fetch the channel’s snippet' %}
{% include example.html object='slow' method='privacy_status' result='=> another HTTP request to fetch the channel’s status' %}
{% include example.html object='slow' method='privacy_status' result='another HTTP request to fetch the channel’s status' %}

{% include doc.html instance="Channel#select" %}{% include example.html object='fast = channel' method='select' params=' <span class="ss">:snippet</span><span class="p">,</span> <span class="ss">:status</span>' result='with select: 1 HTTP request' %}
{% include example.html object='fast' method='title' result='one HTTP request to fetch both the channel’s snippet and status' %}
{% include example.html object='fast' method='privacy_status' result='=> no extra HTTP requests' %}</pre>
{% include example.html object='fast' method='privacy_status' result='no extra HTTP requests' %}</pre>
</div></dd>
</dl>

<dl>
{% include dt.html title="Channel’s (public) videos" label="success" auth="any authentication works" %}
<dd><a class="anchor" id="videos"></a><div class="highlight"><pre>
Expand Down Expand Up @@ -134,6 +135,9 @@ <h4>List of <code>Yt::Channel</code> data methods</h4>
{% include example.html object='videos' method='map <span class="ss">&amp;:id</span>' result='["gknzFj_0vvY", "oO6WawhsxTA"]' %}</pre>
</div></dd>
</dl>
<p>
Note that, due to <a href="https://developers.google.com/youtube/v3/docs/search/list#channelId">YouTube API limitations</a>, only a maximum of 500 videos can be fetched for an unauthenticated channel.
</p>

<dl>
{% include dt.html title="Collection of channels" label="success" auth="any authentication works" %}
Expand Down
34 changes: 34 additions & 0 deletions docs/playlists.html
Expand Up @@ -66,3 +66,37 @@ <h4>List of <code>Yt::Playlist</code> data methods</h4>
{% include example.html object='fast' method='privacy_status' result='=> no extra HTTP requests' %}</pre>
</div></dd>
</dl>

<dl>
{% include dt.html title="Playlist’s items" label="success" auth="any authentication works" %}
<dd><a class="anchor" id="items"></a><div class="highlight"><pre>
{% include doc.html instance="Playlist#items" %}{% include example.html object='playlist' method='items' %}
{% include example.html result='#&lt;Yt::Relation [#&lt;Yt::PlaylistItem @id=U...&gt;, #&lt;Yt::PlaylistItem @id=T...&gt;, ...]&gt;' %}</pre>
</div></dd>
</dl>
<dl>
{% include dt.html title="Playlist’s videos" label="success" auth="any authentication works" %}
<dd><a class="anchor" id="videos"></a><div class="highlight"><pre>
{% include doc.html instance="Playlist#videos" %}{% include example.html object='playlist' method='videos' %}
{% include example.html result='#&lt;Yt::Relation [#&lt;Yt::Video @id=gknz...&gt;, #&lt;Yt::Playlist @id=32Gc...&gt;, ...]&gt;' %}</pre>
</div></dd>
</dl>
<p>
Before iterating through items or videos, use <code>select</code> to specify which <a href="https://developers.google.com/youtube/v3/docs/videos/list#part">parts</a> to load:
</p>
<dl>
<dd><a class="anchor" id="select"></a><div class="highlight"><pre>
{% include doc.html instance="Relation#select" %}{% include example.html object='items = playlist.items' method='select' params=' <span class="ss">:snippet</span>, <span class="ss">:status</span>' %}
{% include example.html object='items' method='map <span class="ss">&amp;:title</span>' result='["First public video", "Second public video", ...]' %}
{% include example.html object='items' method='map <span class="ss">&amp;:privacy_status</span>' result='["public", "public", ...]' %}</pre>
</div></dd>
</dl>
<p>
You can also use <code>limit</code> to only fetch a certain number of items or videos:
</p>
<dl>
<dd><a class="anchor" id="limit"></a><div class="highlight"><pre>
{% include doc.html instance="Relation#limit" %}{% include example.html object='videos = playlist.videos' method='limit' params=' <span class="mi">2</span>' %}
{% include example.html object='videos' method='map <span class="ss">&amp;:id</span>' result='["gknzFj_0vvY", "oO6WawhsxTA"]' %}</pre>
</div></dd>
</dl>
2 changes: 1 addition & 1 deletion lib/yt/account.rb
Expand Up @@ -134,7 +134,7 @@ def videos_search_response(limit, offset)
end

def videos_search_request(limit, offset)
query = {forMine: true, type: :video, part: :id, maxResults: [limit, 50].min, pageToken: offset}.to_param
query = {forMine: true, type: :video, part: :id, maxResults: 50, pageToken: offset}.to_param

Net::HTTP::Get.new("/youtube/v3/search?#{query}").tap do |request|
request.initialize_http_header 'Content-Type' => 'application/json'
Expand Down
8 changes: 5 additions & 3 deletions lib/yt/authentication.rb
Expand Up @@ -85,9 +85,11 @@ def url_params
end

def authentication_scope
@scopes.map do |scope|
"https://www.googleapis.com/auth/#{scope}"
end.join(' ') if @scopes.is_a?(Array)
if @scopes.is_a?(Array)
@scopes.map do |scope|
"https://www.googleapis.com/auth/#{scope}"
end.join(' ')
end
end

### TOKENS
Expand Down
21 changes: 15 additions & 6 deletions lib/yt/channel.rb
Expand Up @@ -8,9 +8,15 @@ def initialize(options = {})
@id = options[:id]
@auth = options[:auth]
@data = HashWithIndifferentAccess.new
@data[:snippet] = options[:snippet] if options[:snippet]
@data[:statistics] = options[:statistics] if options[:statistics]
@data[:status] = options[:status] if options[:status]
if options[:snippet]
@data[:snippet] = options[:snippet]
end
if options[:statistics]
@data[:statistics] = options[:statistics]
end
if options[:status]
@data[:status] = options[:status]
end
end

### COLLECTION
Expand Down Expand Up @@ -117,8 +123,11 @@ def video_count
### ASSOCIATIONS

# @return [Yt::Relation<Yt::Video>] the public videos of the channel.
# @note For unauthenticated channels, results are constrained to a maximum
# of 500 videos.
# @see https://developers.google.com/youtube/v3/docs/search/list#channelId
def videos
@videos ||= Relation.new(Video) {|options| videos_response options}
@videos ||= Relation.new(Video, limit: 500) {|options| videos_response options}
end

# @return [Yt::Relation<Yt::Playlist>] the public playlists of the channel.
Expand Down Expand Up @@ -244,7 +253,7 @@ def videos_search_response(limit, offset)
end

def videos_search_request(limit, offset)
query = {key: Yt.configuration.api_key, type: :video, channelId: @id, part: :id, maxResults: [limit, 50].min, pageToken: offset}.to_param
query = {key: Yt.configuration.api_key, type: :video, channelId: @id, part: :id, maxResults: 50, pageToken: offset}.to_param

Net::HTTP::Get.new("/youtube/v3/search?#{query}").tap do |request|
request.initialize_http_header 'Content-Type' => 'application/json'
Expand Down Expand Up @@ -276,7 +285,7 @@ def playlists_response(options = {})

def playlists_request(options = {})
part = options[:parts].join ','
query = {key: Yt.configuration.api_key, channelId: id, part: part, maxResults: [options[:limit], 50].min, pageToken: options[:offset]}.to_param
query = {key: Yt.configuration.api_key, channelId: id, part: part, maxResults: 50, pageToken: options[:offset]}.to_param
Net::HTTP::Get.new("/youtube/v3/playlists?#{query}").tap do |request|
request.initialize_http_header 'Content-Type' => 'application/json'
end
Expand Down
4 changes: 3 additions & 1 deletion lib/yt/configuration.rb
Expand Up @@ -63,7 +63,9 @@ module Config
#
# @yield [Yt::Configuration] The global configuration.
def configure
yield configuration if block_given?
if block_given?
yield configuration
end
end

# Returns the global {Yt::Configuration} object.
Expand Down
2 changes: 1 addition & 1 deletion lib/yt/content_owner.rb
Expand Up @@ -68,7 +68,7 @@ def partnered_channels_response(options = {})

def partnered_channels_request(parts, limit)
part = parts.join ','
query = {managedByMe: true, onBehalfOfContentOwner: @id, part: part, maxResults: [limit, 50].min}.to_param
query = {managedByMe: true, onBehalfOfContentOwner: @id, part: part, maxResults: 50}.to_param

Net::HTTP::Get.new("/youtube/v3/channels?#{query}").tap do |request|
request.initialize_http_header 'Content-Type' => 'application/json'
Expand Down
48 changes: 44 additions & 4 deletions lib/yt/playlist.rb
Expand Up @@ -7,9 +7,15 @@ class Playlist
def initialize(options = {})
@id = options[:id]
@data = HashWithIndifferentAccess.new
@data[:snippet] = options[:snippet] if options[:snippet]
@data[:status] = options[:status] if options[:status]
@data[:content_details] = options[:content_details] if options[:content_details]
if options[:snippet]
@data[:snippet] = options[:snippet]
end
if options[:status]
@data[:status] = options[:status]
end
if options[:content_details]
@data[:content_details] = options[:content_details]
end
end

### ID
Expand Down Expand Up @@ -84,6 +90,11 @@ def items
@items ||= Relation.new(PlaylistItem) {|options| items_response options}
end

# @return [Yt::Relation<Yt::Video>] the videos of the playlist.
def videos
@videos ||= Relation.new(Video) {|options| videos_response options}
end

### OTHERS

# Specifies which parts of the video to fetch when hitting the data API.
Expand Down Expand Up @@ -155,10 +166,39 @@ def items_response(options = {})

def items_request(options = {})
part = options[:parts].join ','
query = {key: Yt.configuration.api_key, playlistId: id, part: part, maxResults: [options[:limit], 50].min, pageToken: options[:offset]}.to_param
query = {key: Yt.configuration.api_key, playlistId: id, part: part, maxResults: 50, pageToken: options[:offset]}.to_param
Net::HTTP::Get.new("/youtube/v3/playlistItems?#{query}").tap do |request|
request.initialize_http_header 'Content-Type' => 'application/json'
end
end

def videos_response(options = {})
items = items_response options.merge(parts: [:content_details])

if options[:parts] == [:id]
items.tap do |response|
response.body['items'].map{|item| item['id'] = item['contentDetails']['videoId']}
end
else
videos_list_response(options[:parts], items.body['items'].map{|item| item['contentDetails']['videoId']}).tap do |response|
response.body['nextPageToken'] = items.body['nextPageToken']
end
end
end

def videos_list_response(parts, video_ids)
Net::HTTP.start 'www.googleapis.com', 443, use_ssl: true do |http|
http.request videos_list_request(parts, video_ids)
end.tap{|response| response.body = JSON response.body}
end

def videos_list_request(parts, video_ids)
part = parts.join ','
ids = video_ids.join ','
query = {key: Yt.configuration.api_key, id: ids, part: part}.to_param
Net::HTTP::Get.new("/youtube/v3/videos?#{query}").tap do |request|
request.initialize_http_header 'Content-Type' => 'application/json'
end
end
end
end
13 changes: 8 additions & 5 deletions lib/yt/playlist_item.rb
Expand Up @@ -7,9 +7,12 @@ class PlaylistItem
def initialize(options = {})
@id = options[:id]
@data = HashWithIndifferentAccess.new
@data[:snippet] = options[:snippet] if options[:snippet]
@data[:status] = options[:status] if options[:status]
@data[:content_details] = options[:content_details] if options[:content_details]
if options[:snippet]
@data[:snippet] = options[:snippet]
end
if options[:status]
@data[:status] = options[:status]
end
end

### ID
Expand Down Expand Up @@ -58,12 +61,12 @@ def channel_title
snippet['channelTitle']
end

# @return [String] the ID of the playlist that the item belongs to.
# @return [String] the ID of the playlist that the item belongs to.
def playlist_id
snippet['playlistId']
end

# @return [Integer] the order in which the item appears in the playlist.
# @return [Integer] the order in which the item appears in the playlist.
# The value uses a zero-based index so the first item has a position of 0.
def position
snippet['position']
Expand Down
18 changes: 12 additions & 6 deletions lib/yt/relation.rb
Expand Up @@ -6,10 +6,10 @@ class Relation
# @param [Class] item_class the class of objects to initialize when
# iterating through a collection of YouTube resources.
# @yield [Hash] the options to change which items to iterate through.
def initialize(item_class, &item_block)
def initialize(item_class, options = {}, &item_block)
@item_class = item_class
@item_block = item_block
@options = {parts: [:id], limit: Float::INFINITY}
@options = {parts: [:id], limit: Float::INFINITY}.merge options
end

# Executes +item_block+ for each item of the collection.
Expand All @@ -18,8 +18,11 @@ def each(&block)
@items.each(&block)
else
@count = 0
@options[:offset] = nil
loop do
break if @count >= @options[:limit]
if @count >= @options[:limit]
break
end

@response = @item_block.call @options

Expand All @@ -32,8 +35,9 @@ def each(&block)
break if @count > @options[:limit]
block.call video
end

break if @response.body['nextPageToken'].nil? || (@items.size < 50 && @options[:limit] == Float::INFINITY)
if @response.body['nextPageToken'].nil?
break
end
@options[:offset] = @response.body['nextPageToken']
end
@last_options = @options.dup
Expand Down Expand Up @@ -67,7 +71,9 @@ def limit(max_results)
# @return [String] a representation of the Yt::Relation instance.
def inspect
entries = take(3).map!(&:inspect)
entries[2] = '...' if entries.size == 3
if entries.size == 3
entries[2] = '...'
end

"#<#{self.class.name} [#{entries.join(', ')}]>"
end
Expand Down
16 changes: 12 additions & 4 deletions lib/yt/video.rb
Expand Up @@ -8,10 +8,18 @@ def initialize(options = {})
@id = options[:id]
@auth = options[:auth]
@data = HashWithIndifferentAccess.new
@data[:snippet] = options[:snippet] if options[:snippet]
@data[:status] = options[:status] if options[:status]
@data[:statistics] = options[:statistics] if options[:statistics]
@data[:content_details] = options[:content_details] if options[:content_details]
if options[:snippet]
@data[:snippet] = options[:snippet]
end
if options[:status]
@data[:status] = options[:status]
end
if options[:statistics]
@data[:statistics] = options[:statistics]
end
if options[:content_details]
@data[:content_details] = options[:content_details]
end
end

### ID
Expand Down
12 changes: 8 additions & 4 deletions spec/account/videos_spec.rb
Expand Up @@ -15,19 +15,23 @@
account.videos
end

it 'makes as many HTTP requests as the number of videos divided by 50' do
expect(Net::HTTP).to receive(:start).once.and_call_original
# NOTE: `at_least` is due to the fact that sometimes YouTube returns a
# nextPageToken even if the next page is empty (e.g. if the account only
# has 3 videos). In this case, we don’t have a choice and we have to fetch
# another page because we have no way of knowing beforehand that it’s empty.
it 'makes at least as many HTTP requests as the number of videos divided by 50' do
expect(Net::HTTP).to receive(:start).at_least(1).times.and_call_original
account.videos.map &:id
end

it 'reuses the previous HTTP response if the request is the same' do
expect(Net::HTTP).to receive(:start).once.and_call_original
expect(Net::HTTP).to receive(:start).at_least(1).times.and_call_original
account.videos.map &:id
account.videos.map &:id
end

it 'makes a new HTTP request if the request has changed' do
expect(Net::HTTP).to receive(:start).exactly(3).times.and_call_original
expect(Net::HTTP).to receive(:start).at_least(3).times.and_call_original

account.videos.map &:id
account.videos.select(:id, :snippet).map &:title
Expand Down
8 changes: 8 additions & 0 deletions spec/channel/videos_spec.rb
Expand Up @@ -59,4 +59,12 @@
expect(channel.videos.select(:snippet).limit(3).count).to be 3
end
end

context 'given a channel with more than 500 public videos' do
let(:attrs) { {id: $gigantic_channel_id} }

it 'returns at most 500 videos' do
expect(channel.videos.count).to eq 500
end
end
end

0 comments on commit 5ace72d

Please sign in to comment.