yt 0.25.13 → 0.32.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (96) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +305 -1
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +86 -5
  5. data/YOUTUBE_IT.md +3 -3
  6. data/lib/yt.rb +5 -2
  7. data/lib/yt/actions/list.rb +3 -3
  8. data/lib/yt/associations/has_authentication.rb +33 -1
  9. data/lib/yt/associations/has_reports.rb +13 -18
  10. data/lib/yt/collections/assets.rb +2 -2
  11. data/lib/yt/collections/authentications.rb +9 -2
  12. data/lib/yt/collections/base.rb +3 -3
  13. data/lib/yt/collections/bulk_report_jobs.rb +28 -0
  14. data/lib/yt/collections/bulk_reports.rb +24 -0
  15. data/lib/yt/collections/claims.rb +22 -1
  16. data/lib/yt/collections/comment_threads.rb +41 -0
  17. data/lib/yt/collections/content_owners.rb +1 -1
  18. data/lib/yt/collections/group_infos.rb +27 -0
  19. data/lib/yt/collections/group_items.rb +45 -0
  20. data/lib/yt/collections/reports.rb +75 -13
  21. data/lib/yt/collections/revocations.rb +30 -0
  22. data/lib/yt/collections/video_groups.rb +29 -0
  23. data/lib/yt/collections/videos.rb +34 -9
  24. data/lib/yt/constants/geography.rb +326 -0
  25. data/lib/yt/errors/forbidden.rb +1 -3
  26. data/lib/yt/errors/no_items.rb +1 -3
  27. data/lib/yt/errors/request_error.rb +10 -7
  28. data/lib/yt/errors/server_error.rb +1 -3
  29. data/lib/yt/errors/unauthorized.rb +3 -3
  30. data/lib/yt/models/account.rb +12 -0
  31. data/lib/yt/models/advertising_options_set.rb +4 -4
  32. data/lib/yt/models/bulk_report.rb +23 -0
  33. data/lib/yt/models/bulk_report_job.rb +23 -0
  34. data/lib/yt/models/channel.rb +21 -12
  35. data/lib/yt/models/claim.rb +13 -2
  36. data/lib/yt/models/comment.rb +37 -0
  37. data/lib/yt/models/comment_thread.rb +50 -0
  38. data/lib/yt/models/content_detail.rb +6 -0
  39. data/lib/yt/models/content_owner.rb +31 -1
  40. data/lib/yt/models/group_info.rb +16 -0
  41. data/lib/yt/models/group_item.rb +15 -0
  42. data/lib/yt/models/resource.rb +3 -10
  43. data/lib/yt/models/revocation.rb +12 -0
  44. data/lib/yt/models/right_owner.rb +0 -2
  45. data/lib/yt/models/snippet.rb +24 -3
  46. data/lib/yt/models/video.rb +42 -11
  47. data/lib/yt/models/video_group.rb +186 -0
  48. data/lib/yt/request.rb +5 -3
  49. data/lib/yt/version.rb +2 -2
  50. data/spec/collections/comment_threads_spec.rb +46 -0
  51. data/spec/collections/playlist_items_spec.rb +1 -1
  52. data/spec/collections/reports_spec.rb +2 -2
  53. data/spec/constants/geography_spec.rb +16 -0
  54. data/spec/models/annotation_spec.rb +1 -1
  55. data/spec/models/claim_spec.rb +15 -3
  56. data/spec/models/comment_spec.rb +40 -0
  57. data/spec/models/comment_thread_spec.rb +93 -0
  58. data/spec/models/content_detail_spec.rb +7 -0
  59. data/spec/models/reference_spec.rb +2 -2
  60. data/spec/models/request_spec.rb +21 -0
  61. data/spec/models/resource_spec.rb +0 -15
  62. data/spec/models/video_spec.rb +1 -1
  63. data/spec/requests/as_account/account_spec.rb +16 -4
  64. data/spec/requests/as_account/authentications_spec.rb +1 -13
  65. data/spec/requests/as_account/channel_spec.rb +15 -45
  66. data/spec/requests/as_account/playlist_item_spec.rb +3 -3
  67. data/spec/requests/as_account/playlist_spec.rb +5 -32
  68. data/spec/requests/as_account/video_spec.rb +2022 -21
  69. data/spec/requests/as_content_owner/account_spec.rb +4 -0
  70. data/spec/requests/as_content_owner/bulk_report_job_spec.rb +19 -0
  71. data/spec/requests/as_content_owner/channel_spec.rb +59 -270
  72. data/spec/requests/as_content_owner/content_owner_spec.rb +89 -1
  73. data/spec/requests/as_content_owner/playlist_spec.rb +0 -15
  74. data/spec/requests/as_content_owner/video_group_spec.rb +112 -0
  75. data/spec/requests/as_content_owner/video_spec.rb +72 -146
  76. data/spec/requests/as_server_app/channel_spec.rb +1 -21
  77. data/spec/requests/as_server_app/comment_spec.rb +22 -0
  78. data/spec/requests/as_server_app/comment_thread_spec.rb +27 -0
  79. data/spec/requests/as_server_app/comment_threads_spec.rb +41 -0
  80. data/spec/requests/as_server_app/playlist_item_spec.rb +2 -2
  81. data/spec/requests/as_server_app/playlist_spec.rb +1 -22
  82. data/spec/requests/as_server_app/video_spec.rb +21 -19
  83. data/spec/requests/as_server_app/videos_spec.rb +5 -5
  84. data/spec/requests/unauthenticated/video_spec.rb +1 -9
  85. data/spec/spec_helper.rb +1 -1
  86. data/yt.gemspec +2 -1
  87. metadata +51 -17
  88. data/lib/yt/collections/ids.rb +0 -27
  89. data/lib/yt/config.rb +0 -54
  90. data/lib/yt/models/configuration.rb +0 -70
  91. data/lib/yt/models/description.rb +0 -58
  92. data/lib/yt/models/url.rb +0 -91
  93. data/spec/models/configuration_spec.rb +0 -44
  94. data/spec/models/description_spec.rb +0 -94
  95. data/spec/models/url_spec.rb +0 -84
  96. data/spec/requests/as_account/resource_spec.rb +0 -18
@@ -80,22 +80,6 @@ describe Yt::Channel, :device_app do
80
80
  expect(channel.video_count).to be > 500
81
81
  expect(channel.videos.size).to be > 500
82
82
  end
83
-
84
- specify 'over 500 videos can only be retrieved when sorting by date' do
85
- # @note: these tests are slow because they go through multiple pages
86
- # of results to test that we can overcome YouTube’s limitation of only
87
- # returning the first 500 results when ordered by date.
88
- expect(channel.videos.count).to be > 500
89
- expect(channel.videos.where(order: 'viewCount').count).to be 500
90
- end
91
-
92
- specify 'over 500 videos can be retrieved even with a publishedBefore condition' do
93
- # @note: these tests are slow because they go through multiple pages
94
- # of results to test that we can overcome YouTube’s limitation of only
95
- # returning the first 500 results when ordered by date.
96
- today = Date.today.beginning_of_day.iso8601(0)
97
- expect(channel.videos.where(published_before: today).count).to be > 500
98
- end
99
83
  end
100
84
  end
101
85
 
@@ -164,8 +148,12 @@ describe Yt::Channel, :device_app do
164
148
  before { channel.throttle_subscriptions }
165
149
 
166
150
  it { expect(channel.subscribed?).to be true }
167
- it { expect(channel.subscribe).to be_falsey }
168
- it { expect{channel.subscribe!}.to raise_error Yt::Errors::RequestError }
151
+ # NOTE: These tests are commented out because YouTube randomly changed the
152
+ # behavior of the API without changing the documentation, so subscribing
153
+ # to a channel you are already subscribed to does not raise an error
154
+ # anymore.
155
+ # it { expect(channel.subscribe).to be_falsey }
156
+ # it { expect{channel.subscribe!}.to raise_error Yt::Errors::RequestError }
169
157
 
170
158
  context 'when I unsubscribe' do
171
159
  before { channel.unsubscribe }
@@ -195,7 +183,7 @@ describe Yt::Channel, :device_app do
195
183
 
196
184
  it { expect(channel.delete_playlists title: %r{#{params[:title]}}).to eq [true] }
197
185
  it { expect(channel.delete_playlists params).to eq [true] }
198
- it { expect{channel.delete_playlists params}.to change{channel.playlists.count}.by(-1) }
186
+ it { expect{channel.delete_playlists params}.to change{sleep 1; channel.playlists.count}.by(-1) }
199
187
  end
200
188
 
201
189
  # Can't subscribe to your own channel.
@@ -212,8 +200,6 @@ describe Yt::Channel, :device_app do
212
200
  expect{channel.shares}.not_to raise_error
213
201
  expect{channel.subscribers_gained}.not_to raise_error
214
202
  expect{channel.subscribers_lost}.not_to raise_error
215
- expect{channel.favorites_added}.not_to raise_error
216
- expect{channel.favorites_removed}.not_to raise_error
217
203
  expect{channel.videos_added_to_playlists}.not_to raise_error
218
204
  expect{channel.videos_removed_from_playlists}.not_to raise_error
219
205
  expect{channel.estimated_minutes_watched}.not_to raise_error
@@ -222,33 +208,17 @@ describe Yt::Channel, :device_app do
222
208
  expect{channel.annotation_clicks}.not_to raise_error
223
209
  expect{channel.annotation_click_through_rate}.not_to raise_error
224
210
  expect{channel.annotation_close_rate}.not_to raise_error
211
+ expect{channel.card_impressions}.not_to raise_error
212
+ expect{channel.card_clicks}.not_to raise_error
213
+ expect{channel.card_click_rate}.not_to raise_error
214
+ expect{channel.card_teaser_impressions}.not_to raise_error
215
+ expect{channel.card_teaser_clicks}.not_to raise_error
216
+ expect{channel.card_teaser_click_rate}.not_to raise_error
225
217
  expect{channel.viewer_percentage}.not_to raise_error
226
- expect{channel.earnings}.to raise_error Yt::Errors::Unauthorized
227
- expect{channel.impressions}.to raise_error Yt::Errors::Unauthorized
218
+ expect{channel.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
219
+ expect{channel.ad_impressions}.to raise_error Yt::Errors::Unauthorized
228
220
  expect{channel.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
229
221
  expect{channel.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
230
-
231
- expect{channel.views_on 3.days.ago}.not_to raise_error
232
- expect{channel.comments_on 3.days.ago}.not_to raise_error
233
- expect{channel.likes_on 3.days.ago}.not_to raise_error
234
- expect{channel.dislikes_on 3.days.ago}.not_to raise_error
235
- expect{channel.shares_on 3.days.ago}.not_to raise_error
236
- expect{channel.subscribers_gained_on 3.days.ago}.not_to raise_error
237
- expect{channel.subscribers_lost_on 3.days.ago}.not_to raise_error
238
- expect{channel.favorites_added_on 3.days.ago}.not_to raise_error
239
- expect{channel.favorites_removed_on 3.days.ago}.not_to raise_error
240
- expect{channel.videos_added_to_playlists_on 3.days.ago}.not_to raise_error
241
- expect{channel.videos_removed_from_playlists_on 3.days.ago}.not_to raise_error
242
- expect{channel.estimated_minutes_watched_on 3.days.ago}.not_to raise_error
243
- expect{channel.average_view_duration_on 3.days.ago}.not_to raise_error
244
- expect{channel.average_view_percentage_on 3.days.ago}.not_to raise_error
245
- expect{channel.earnings_on 3.days.ago}.to raise_error Yt::Errors::Unauthorized
246
- expect{channel.impressions_on 3.days.ago}.to raise_error Yt::Errors::Unauthorized
247
- end
248
-
249
- it 'cannot give information about its content owner' do
250
- expect{channel.content_owner}.to raise_error Yt::Errors::Forbidden
251
- expect{channel.linked_at}.to raise_error Yt::Errors::Forbidden
252
222
  end
253
223
  end
254
224
 
@@ -5,7 +5,7 @@ describe Yt::PlaylistItem, :device_app do
5
5
  subject(:item) { Yt::PlaylistItem.new id: id, auth: $account }
6
6
 
7
7
  context 'given an existing playlist item' do
8
- let(:id) { 'PLjW_GNR5Ir0GMlbJzA-aW0UV8TchJFb8p3uzrLNcZKPY' }
8
+ let(:id) { 'UExTV1lrWXpPclBNVDlwSkc1U3Q1RzBXRGFsaFJ6R2tVNC4yQUE2Q0JEMTk4NTM3RTZC' }
9
9
 
10
10
  it 'returns valid metadata' do
11
11
  expect(item.title).to be_a String
@@ -32,8 +32,8 @@ describe Yt::PlaylistItem, :device_app do
32
32
  context 'given one of my own playlist items that I want to update' do
33
33
  before(:all) do
34
34
  @my_playlist = $account.create_playlist title: "Yt Test Update Playlist Item #{rand}"
35
- @my_playlist.add_video 'MESycYJytkU'
36
- @my_playlist_item = @my_playlist.add_video 'MESycYJytkU'
35
+ @my_playlist.add_video '9bZkp7q19f0'
36
+ @my_playlist_item = @my_playlist.add_video '9bZkp7q19f0'
37
37
  end
38
38
  after(:all) { @my_playlist.delete }
39
39
 
@@ -48,18 +48,6 @@ describe Yt::Playlist, :device_app do
48
48
  end
49
49
  end
50
50
 
51
- context 'given a playlist that only includes other people’s private or deleted videos' do
52
- let(:id) { 'PLsnYEvcCzABOsJdehqkIDhwz8CPGWzX59' }
53
-
54
- describe '.playlist_items.includes(:video)' do
55
- let(:items) { playlist.playlist_items.includes(:video).map{|i| i} }
56
-
57
- specify 'returns nil (without running an infinite loop)' do
58
- expect(items.size).to be 2
59
- end
60
- end
61
- end
62
-
63
51
  context 'given an unknown playlist' do
64
52
  let(:id) { 'not-a-playlist-id' }
65
53
 
@@ -69,10 +57,10 @@ describe Yt::Playlist, :device_app do
69
57
 
70
58
  context 'given someone else’s playlist' do
71
59
  let(:id) { 'PLSWYkYzOrPMT9pJG5St5G0WDalhRzGkU4' }
72
- let(:video_id) { 'MESycYJytkU' }
60
+ let(:video_id) { '9bZkp7q19f0' }
73
61
 
74
- it { expect{playlist.delete}.to fail.with 'forbidden' }
75
- it { expect{playlist.update}.to fail.with 'forbidden' }
62
+ it { expect{playlist.delete}.to fail.with 'playlistForbidden' }
63
+ it { expect{playlist.update}.to fail.with 'playlistForbidden' }
76
64
  it { expect{playlist.add_video! video_id}.to raise_error Yt::Errors::RequestError }
77
65
  it { expect{playlist.delete_playlist_items}.to raise_error Yt::Errors::RequestError }
78
66
  end
@@ -164,7 +152,7 @@ describe Yt::Playlist, :device_app do
164
152
  end
165
153
 
166
154
  context 'given an existing video' do
167
- let(:video_id) { 'MESycYJytkU' }
155
+ let(:video_id) { '9bZkp7q19f0' }
168
156
 
169
157
  describe 'can be added' do
170
158
  it { expect(playlist.add_video video_id).to be_a Yt::PlaylistItem }
@@ -206,18 +194,8 @@ describe Yt::Playlist, :device_app do
206
194
  end
207
195
  end
208
196
 
209
- context 'given a video of a terminated account' do
210
- let(:video_id) { 'kDCpdKeTe5g' }
211
-
212
- describe 'cannot be added' do
213
- it { expect(playlist.add_video video_id).to be_nil }
214
- it { expect{playlist.add_video video_id}.not_to change{playlist.playlist_items.count} }
215
- it { expect{playlist.add_video! video_id}.to fail.with 'forbidden' }
216
- end
217
- end
218
-
219
197
  context 'given one existing and one unknown video' do
220
- let(:video_ids) { ['MESycYJytkU', 'not-a-video'] }
198
+ let(:video_ids) { ['9bZkp7q19f0', 'not-a-video'] }
221
199
 
222
200
  describe 'only one can be added' do
223
201
  it { expect(playlist.add_videos(video_ids).length).to eq 2 }
@@ -235,11 +213,6 @@ describe Yt::Playlist, :device_app do
235
213
  expect{playlist.playlist_starts}.not_to raise_error
236
214
  expect{playlist.average_time_in_playlist}.not_to raise_error
237
215
  expect{playlist.views_per_playlist_start}.not_to raise_error
238
-
239
- expect{playlist.views_on 3.days.ago}.not_to raise_error
240
- expect{playlist.playlist_starts_on 3.days.ago}.not_to raise_error
241
- expect{playlist.average_time_in_playlist_on 3.days.ago}.not_to raise_error
242
- expect{playlist.views_per_playlist_start_on 3.days.ago}.not_to raise_error
243
216
  end
244
217
  end
245
218
  end
@@ -7,7 +7,7 @@ describe Yt::Video, :device_app do
7
7
  subject(:video) { Yt::Video.new id: id, auth: $account }
8
8
 
9
9
  context 'given someone else’s video' do
10
- let(:id) { 'MESycYJytkU' }
10
+ let(:id) { '9bZkp7q19f0' }
11
11
 
12
12
  it { expect(video.content_detail).to be_a Yt::ContentDetail }
13
13
 
@@ -20,6 +20,7 @@ describe Yt::Video, :device_app do
20
20
  expect(video.tags).to be_an Array
21
21
  expect(video.channel_id).to be_a String
22
22
  expect(video.channel_title).to be_a String
23
+ expect(video.channel_url).to be_a String
23
24
  expect(video.category_id).to be_a String
24
25
  expect(video.live_broadcast_content).to be_a String
25
26
  expect(video.view_count).to be_an Integer
@@ -28,6 +29,7 @@ describe Yt::Video, :device_app do
28
29
  expect(video.favorite_count).to be_an Integer
29
30
  expect(video.comment_count).to be_an Integer
30
31
  expect(video.duration).to be_an Integer
32
+ expect(video.length).to be_a String
31
33
  expect(video.hd?).to be_in [true, false]
32
34
  expect(video.stereoscopic?).to be_in [true, false]
33
35
  expect(video.captioned?).to be_in [true, false]
@@ -290,8 +292,6 @@ describe Yt::Video, :device_app do
290
292
  expect{video.shares}.not_to raise_error
291
293
  expect{video.subscribers_gained}.not_to raise_error
292
294
  expect{video.subscribers_lost}.not_to raise_error
293
- expect{video.favorites_added}.not_to raise_error
294
- expect{video.favorites_removed}.not_to raise_error
295
295
  expect{video.videos_added_to_playlists}.not_to raise_error
296
296
  expect{video.videos_removed_from_playlists}.not_to raise_error
297
297
  expect{video.estimated_minutes_watched}.not_to raise_error
@@ -300,29 +300,2030 @@ describe Yt::Video, :device_app do
300
300
  expect{video.annotation_clicks}.not_to raise_error
301
301
  expect{video.annotation_click_through_rate}.not_to raise_error
302
302
  expect{video.annotation_close_rate}.not_to raise_error
303
+ expect{video.card_impressions}.not_to raise_error
304
+ expect{video.card_clicks}.not_to raise_error
305
+ expect{video.card_click_rate}.not_to raise_error
306
+ expect{video.card_teaser_impressions}.not_to raise_error
307
+ expect{video.card_teaser_clicks}.not_to raise_error
308
+ expect{video.card_teaser_click_rate}.not_to raise_error
303
309
  expect{video.viewer_percentage}.not_to raise_error
304
- expect{video.earnings}.to raise_error Yt::Errors::Unauthorized
305
- expect{video.impressions}.to raise_error Yt::Errors::Unauthorized
310
+ expect{video.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
311
+ expect{video.ad_impressions}.to raise_error Yt::Errors::Unauthorized
306
312
  expect{video.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
307
313
  expect{video.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
308
314
  expect{video.advertising_options_set}.to raise_error Yt::Errors::Forbidden
315
+ end
316
+ end
317
+
318
+ # @note: This test is separated from the block above because, for some
319
+ # undocumented reasons, if an existing video was private, then set to
320
+ # unlisted, then set to private again, YouTube _sometimes_ raises a
321
+ # 400 Error when trying to set the publishAt timestamp.
322
+ # Therefore, just to test the updating of publishAt, we use a brand new
323
+ # video (set to private), rather than reusing an existing one as above.
324
+ context 'given one of my own *private* videos that I want to update' do
325
+ before { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: old_title, privacy_status: old_privacy_status }
326
+ let(:id) { @tmp_video.id }
327
+ let!(:old_title) { "Yt Test Update publishAt Video #{rand}" }
328
+ let!(:old_privacy_status) { 'private' }
329
+ after { video.delete }
330
+
331
+ let!(:new_scheduled_at) { Yt::Timestamp.parse("#{rand(30) + 1} Jan 2020", Time.now) }
332
+
333
+ context 'passing the parameter in underscore syntax' do
334
+ let(:attrs) { {publish_at: new_scheduled_at} }
335
+
336
+ specify 'only updates the timestamp to publish the video' do
337
+ expect(video.update attrs).to be true
338
+ expect(video.privacy_status).to eq old_privacy_status
339
+ expect(video.title).to eq old_title
340
+ # NOTE: This is another irrational behavior of YouTube API. In short,
341
+ # the response of Video#update *does not* include the publishAt value
342
+ # even if it exists. You need to call Video#list again to get it.
343
+ video = Yt::Video.new id: id, auth: $account
344
+ expect(video.scheduled_at).to eq new_scheduled_at
345
+ # Setting a private (scheduled) video to private has no effect:
346
+ expect(video.update privacy_status: 'private').to be true
347
+ video = Yt::Video.new id: id, auth: $account
348
+ expect(video.scheduled_at).to eq new_scheduled_at
349
+ # Setting a private (scheduled) video to unlisted/public removes publishAt:
350
+ expect(video.update privacy_status: 'unlisted').to be true
351
+ video = Yt::Video.new id: id, auth: $account
352
+ expect(video.scheduled_at).to be_nil
353
+ end
354
+ end
355
+
356
+ context 'passing the parameter in camel-case syntax' do
357
+ let(:attrs) { {publishAt: new_scheduled_at} }
358
+
359
+ specify 'only updates the timestamp to publish the video' do
360
+ expect(video.update attrs).to be true
361
+ expect(video.scheduled_at).to eq new_scheduled_at
362
+ expect(video.privacy_status).to eq old_privacy_status
363
+ expect(video.title).to eq old_title
364
+ end
365
+ end
366
+ end
367
+
368
+ # @note: This should somehow test that the thumbnail *changes*. However,
369
+ # YouTube does not change the URL of the thumbnail even though the content
370
+ # changes. A full test would have to *download* the thumbnails before and
371
+ # after, and compare the files. For now, not raising error is enough.
372
+ # Eventually, change to `expect{update}.to change{video.thumbnail_url}`
373
+ context 'given one of my own videos for which I want to upload a thumbnail' do
374
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
375
+ let(:update) { video.upload_thumbnail path_or_url }
376
+
377
+ context 'given the path to a local JPG image file' do
378
+ let(:path_or_url) { File.expand_path '../thumbnail.jpg', __FILE__ }
379
+
380
+ it { expect{update}.not_to raise_error }
381
+ end
382
+
383
+ context 'given the path to a remote PNG image file' do
384
+ let(:path_or_url) { 'https://bit.ly/yt_thumbnail' }
385
+
386
+ it { expect{update}.not_to raise_error }
387
+ end
388
+
389
+ context 'given an invalid URL' do
390
+ let(:path_or_url) { 'this-is-not-a-url' }
391
+
392
+ it { expect{update}.to raise_error Yt::Errors::RequestError }
393
+ end
394
+ end
395
+
396
+ # @note: This test is separated from the block above because YouTube only
397
+ # returns file details for *some videos*: "The fileDetails object will
398
+ # only be returned if the processingDetails.fileAvailability property
399
+ # has a value of available.". Therefore, just to test fileDetails, we use a
400
+ # different video that (for some unknown reason) is marked as 'available'.
401
+ # Also note that I was not able to find a single video returning fileName,
402
+ # therefore video.file_name is not returned by Yt, until it can be tested.
403
+ # @see https://developers.google.com/youtube/v3/docs/videos#processingDetails.fileDetailsAvailability
404
+ context 'given one of my own *available* videos' do
405
+ let(:id) { 'yCmaOvUFhlI' }
406
+
407
+ it 'returns valid file details' do
408
+ expect(video.file_size).to be_an Integer
409
+ expect(video.file_type).to be_a String
410
+ expect(video.container).to be_a String
411
+ end
412
+ end
413
+ end
414
+ # encoding: UTF-8
415
+
416
+ require 'spec_helper'
417
+ require 'yt/models/video'
418
+
419
+ describe Yt::Video, :device_app do
420
+ subject(:video) { Yt::Video.new id: id, auth: $account }
421
+
422
+ context 'given someone else’s video' do
423
+ let(:id) { '9bZkp7q19f0' }
424
+
425
+ it { expect(video.content_detail).to be_a Yt::ContentDetail }
426
+
427
+ it 'returns valid metadata' do
428
+ expect(video.title).to be_a String
429
+ expect(video.description).to be_a String
430
+ expect(video.thumbnail_url).to be_a String
431
+ expect(video.published_at).to be_a Time
432
+ expect(video.privacy_status).to be_a String
433
+ expect(video.tags).to be_an Array
434
+ expect(video.channel_id).to be_a String
435
+ expect(video.channel_title).to be_a String
436
+ expect(video.channel_url).to be_a String
437
+ expect(video.category_id).to be_a String
438
+ expect(video.live_broadcast_content).to be_a String
439
+ expect(video.view_count).to be_an Integer
440
+ expect(video.like_count).to be_an Integer
441
+ expect(video.dislike_count).to be_an Integer
442
+ expect(video.favorite_count).to be_an Integer
443
+ expect(video.comment_count).to be_an Integer
444
+ expect(video.duration).to be_an Integer
445
+ expect(video.hd?).to be_in [true, false]
446
+ expect(video.stereoscopic?).to be_in [true, false]
447
+ expect(video.captioned?).to be_in [true, false]
448
+ expect(video.licensed?).to be_in [true, false]
449
+ expect(video.deleted?).to be_in [true, false]
450
+ expect(video.failed?).to be_in [true, false]
451
+ expect(video.processed?).to be_in [true, false]
452
+ expect(video.rejected?).to be_in [true, false]
453
+ expect(video.uploading?).to be_in [true, false]
454
+ expect(video.uses_unsupported_codec?).to be_in [true, false]
455
+ expect(video.has_failed_conversion?).to be_in [true, false]
456
+ expect(video.empty?).to be_in [true, false]
457
+ expect(video.invalid?).to be_in [true, false]
458
+ expect(video.too_small?).to be_in [true, false]
459
+ expect(video.aborted?).to be_in [true, false]
460
+ expect(video.claimed?).to be_in [true, false]
461
+ expect(video.infringes_copyright?).to be_in [true, false]
462
+ expect(video.duplicate?).to be_in [true, false]
463
+ expect(video.scheduled_at.class).to be_in [NilClass, Time]
464
+ expect(video.scheduled?).to be_in [true, false]
465
+ expect(video.too_long?).to be_in [true, false]
466
+ expect(video.violates_terms_of_use?).to be_in [true, false]
467
+ expect(video.inappropriate?).to be_in [true, false]
468
+ expect(video.infringes_trademark?).to be_in [true, false]
469
+ expect(video.belongs_to_closed_account?).to be_in [true, false]
470
+ expect(video.belongs_to_suspended_account?).to be_in [true, false]
471
+ expect(video.licensed_as_creative_commons?).to be_in [true, false]
472
+ expect(video.licensed_as_standard_youtube?).to be_in [true, false]
473
+ expect(video.has_public_stats_viewable?).to be_in [true, false]
474
+ expect(video.embeddable?).to be_in [true, false]
475
+ expect(video.actual_start_time).to be_nil
476
+ expect(video.actual_end_time).to be_nil
477
+ expect(video.scheduled_start_time).to be_nil
478
+ expect(video.scheduled_end_time).to be_nil
479
+ expect(video.concurrent_viewers).to be_nil
480
+ expect(video.embed_html).to be_a String
481
+ expect(video.category_title).to be_a String
482
+ end
483
+
484
+ it { expect{video.update}.to fail }
485
+ it { expect{video.delete}.to fail.with 'forbidden' }
486
+
487
+ context 'that I like' do
488
+ before { video.like }
489
+ it { expect(video).to be_liked }
490
+ it { expect(video.dislike).to be true }
491
+ end
492
+
493
+ context 'that I dislike' do
494
+ before { video.dislike }
495
+ it { expect(video).not_to be_liked }
496
+ it { expect(video.like).to be true }
497
+ end
498
+
499
+ context 'that I am indifferent to' do
500
+ before { video.unlike }
501
+ it { expect(video).not_to be_liked }
502
+ it { expect(video.like).to be true }
503
+ end
504
+ end
505
+
506
+ context 'given someone else’s live video broadcast scheduled in the future' do
507
+ let(:id) { 'PqzGI8gO_gk' }
508
+
509
+ it 'returns valid live streaming details' do
510
+ expect(video.actual_start_time).to be_nil
511
+ expect(video.actual_end_time).to be_nil
512
+ expect(video.scheduled_start_time).to be_a Time
513
+ expect(video.scheduled_end_time).to be_nil
514
+ end
515
+ end
516
+
517
+ context 'given someone else’s past live video broadcast' do
518
+ let(:id) { 'COOM8_tOy6U' }
519
+
520
+ it 'returns valid live streaming details' do
521
+ expect(video.actual_start_time).to be_a Time
522
+ expect(video.actual_end_time).to be_a Time
523
+ expect(video.scheduled_start_time).to be_a Time
524
+ expect(video.scheduled_end_time).to be_a Time
525
+ expect(video.concurrent_viewers).to be_nil
526
+ end
527
+ end
528
+
529
+ context 'given an unknown video' do
530
+ let(:id) { 'not-a-video-id' }
531
+
532
+ it { expect{video.content_detail}.to raise_error Yt::Errors::NoItems }
533
+ it { expect{video.snippet}.to raise_error Yt::Errors::NoItems }
534
+ it { expect{video.rating}.to raise_error Yt::Errors::NoItems }
535
+ it { expect{video.status}.to raise_error Yt::Errors::NoItems }
536
+ it { expect{video.statistics_set}.to raise_error Yt::Errors::NoItems }
537
+ it { expect{video.file_detail}.to raise_error Yt::Errors::NoItems }
538
+ end
539
+
540
+ context 'given one of my own videos that I want to delete' do
541
+ before(:all) { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: "Yt Test Delete Video #{rand}" }
542
+ let(:id) { @tmp_video.id }
543
+
544
+ it { expect(video.delete).to be true }
545
+ end
546
+
547
+ context 'given one of my own videos that I want to update' do
548
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
549
+ let!(:old_title) { video.title }
550
+ let!(:old_privacy_status) { video.privacy_status }
551
+ let(:update) { video.update attrs }
552
+
553
+ context 'given I update the title' do
554
+ # NOTE: The use of UTF-8 characters is to test that we can pass up to
555
+ # 50 characters, independently of their representation
556
+ let(:attrs) { {title: "Yt Example Update Video #{rand} - ®•♡❥❦❧☙"} }
557
+
558
+ specify 'only updates the title' do
559
+ expect(update).to be true
560
+ expect(video.title).not_to eq old_title
561
+ expect(video.privacy_status).to eq old_privacy_status
562
+ end
563
+ end
564
+
565
+ context 'given I update the description' do
566
+ let!(:old_description) { video.description }
567
+ let(:attrs) { {description: "Yt Example Description #{rand} - ®•♡❥❦❧☙"} }
568
+
569
+ specify 'only updates the description' do
570
+ expect(update).to be true
571
+ expect(video.description).not_to eq old_description
572
+ expect(video.title).to eq old_title
573
+ expect(video.privacy_status).to eq old_privacy_status
574
+ end
575
+ end
576
+
577
+ context 'given I update the tags' do
578
+ let!(:old_tags) { video.tags }
579
+ let(:attrs) { {tags: ["Yt Test Tag #{rand}"]} }
580
+
581
+ specify 'only updates the tag' do
582
+ expect(update).to be true
583
+ expect(video.tags).not_to eq old_tags
584
+ expect(video.title).to eq old_title
585
+ expect(video.privacy_status).to eq old_privacy_status
586
+ end
587
+ end
588
+
589
+ context 'given I update the category ID' do
590
+ let!(:old_category_id) { video.category_id }
591
+ let!(:new_category_id) { old_category_id == '22' ? '21' : '22' }
592
+
593
+ context 'passing the parameter in underscore syntax' do
594
+ let(:attrs) { {category_id: new_category_id} }
595
+
596
+ specify 'only updates the category ID' do
597
+ expect(update).to be true
598
+ expect(video.category_id).not_to eq old_category_id
599
+ expect(video.title).to eq old_title
600
+ expect(video.privacy_status).to eq old_privacy_status
601
+ end
602
+ end
603
+
604
+ context 'passing the parameter in camel-case syntax' do
605
+ let(:attrs) { {categoryId: new_category_id} }
606
+
607
+ specify 'only updates the category ID' do
608
+ expect(update).to be true
609
+ expect(video.category_id).not_to eq old_category_id
610
+ end
611
+ end
612
+ end
613
+
614
+ context 'given I update title, description and/or tags using angle brackets' do
615
+ let(:attrs) { {title: "Example Yt Test < >", description: '< >', tags: ['<tag>']} }
616
+
617
+ specify 'updates them replacing angle brackets with similar unicode characters accepted by YouTube' do
618
+ expect(update).to be true
619
+ expect(video.title).to eq 'Example Yt Test ‹ ›'
620
+ expect(video.description).to eq '‹ ›'
621
+ expect(video.tags).to eq ['‹tag›']
622
+ end
623
+ end
624
+
625
+ # note: 'scheduled' videos cannot be set to 'unlisted'
626
+ context 'given I update the privacy status' do
627
+ before { video.update publish_at: nil if video.scheduled? }
628
+ let!(:new_privacy_status) { old_privacy_status == 'private' ? 'unlisted' : 'private' }
629
+
630
+ context 'passing the parameter in underscore syntax' do
631
+ let(:attrs) { {privacy_status: new_privacy_status} }
632
+
633
+ specify 'only updates the privacy status' do
634
+ expect(update).to be true
635
+ expect(video.privacy_status).not_to eq old_privacy_status
636
+ expect(video.title).to eq old_title
637
+ end
638
+ end
639
+
640
+ context 'passing the parameter in camel-case syntax' do
641
+ let(:attrs) { {privacyStatus: new_privacy_status} }
642
+
643
+ specify 'only updates the privacy status' do
644
+ expect(update).to be true
645
+ expect(video.privacy_status).not_to eq old_privacy_status
646
+ expect(video.title).to eq old_title
647
+ end
648
+ end
649
+ end
650
+
651
+ context 'given I update the embeddable status' do
652
+ let!(:old_embeddable) { video.embeddable? }
653
+ let!(:new_embeddable) { !old_embeddable }
654
+
655
+ let(:attrs) { {embeddable: new_embeddable} }
656
+
657
+ # @note: This test is a reflection of another irrational behavior of
658
+ # YouTube API. Although 'embeddable' can be passed as an 'update'
659
+ # attribute according to the documentation, it simply does not work.
660
+ # The day YouTube fixes it, then this test will finally fail and will
661
+ # be removed, documenting how to update 'embeddable' too.
662
+ # @see https://developers.google.com/youtube/v3/docs/videos/update
663
+ # @see https://code.google.com/p/gdata-issues/issues/detail?id=4861
664
+ specify 'does not update the embeddable status' do
665
+ expect(update).to be true
666
+ expect(video.embeddable?).to eq old_embeddable
667
+ end
668
+ end
669
+
670
+ context 'given I update the public stats viewable setting' do
671
+ let!(:old_public_stats_viewable) { video.has_public_stats_viewable? }
672
+ let!(:new_public_stats_viewable) { !old_public_stats_viewable }
673
+
674
+ context 'passing the parameter in underscore syntax' do
675
+ let(:attrs) { {public_stats_viewable: new_public_stats_viewable} }
676
+
677
+ specify 'only updates the public stats viewable setting' do
678
+ expect(update).to be true
679
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
680
+ expect(video.privacy_status).to eq old_privacy_status
681
+ expect(video.title).to eq old_title
682
+ end
683
+ end
684
+
685
+ context 'passing the parameter in camel-case syntax' do
686
+ let(:attrs) { {publicStatsViewable: new_public_stats_viewable} }
687
+
688
+ specify 'only updates the public stats viewable setting' do
689
+ expect(update).to be true
690
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
691
+ expect(video.privacy_status).to eq old_privacy_status
692
+ expect(video.title).to eq old_title
693
+ end
694
+ end
695
+ end
696
+
697
+ it 'returns valid reports for video-related metrics' do
698
+ # Some reports are only available to Content Owners.
699
+ # See content owner test for more details about what the methods return.
700
+ expect{video.views}.not_to raise_error
701
+ expect{video.comments}.not_to raise_error
702
+ expect{video.likes}.not_to raise_error
703
+ expect{video.dislikes}.not_to raise_error
704
+ expect{video.shares}.not_to raise_error
705
+ expect{video.subscribers_gained}.not_to raise_error
706
+ expect{video.subscribers_lost}.not_to raise_error
707
+ expect{video.videos_added_to_playlists}.not_to raise_error
708
+ expect{video.videos_removed_from_playlists}.not_to raise_error
709
+ expect{video.estimated_minutes_watched}.not_to raise_error
710
+ expect{video.average_view_duration}.not_to raise_error
711
+ expect{video.average_view_percentage}.not_to raise_error
712
+ expect{video.annotation_clicks}.not_to raise_error
713
+ expect{video.annotation_click_through_rate}.not_to raise_error
714
+ expect{video.annotation_close_rate}.not_to raise_error
715
+ expect{video.viewer_percentage}.not_to raise_error
716
+ expect{video.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
717
+ expect{video.ad_impressions}.to raise_error Yt::Errors::Unauthorized
718
+ expect{video.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
719
+ expect{video.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
720
+ expect{video.advertising_options_set}.to raise_error Yt::Errors::Forbidden
721
+ end
722
+ end
723
+
724
+ # @note: This test is separated from the block above because, for some
725
+ # undocumented reasons, if an existing video was private, then set to
726
+ # unlisted, then set to private again, YouTube _sometimes_ raises a
727
+ # 400 Error when trying to set the publishAt timestamp.
728
+ # Therefore, just to test the updating of publishAt, we use a brand new
729
+ # video (set to private), rather than reusing an existing one as above.
730
+ context 'given one of my own *private* videos that I want to update' do
731
+ before { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: old_title, privacy_status: old_privacy_status }
732
+ let(:id) { @tmp_video.id }
733
+ let!(:old_title) { "Yt Test Update publishAt Video #{rand}" }
734
+ let!(:old_privacy_status) { 'private' }
735
+ after { video.delete }
736
+
737
+ let!(:new_scheduled_at) { Yt::Timestamp.parse("#{rand(30) + 1} Jan 2020", Time.now) }
738
+
739
+ context 'passing the parameter in underscore syntax' do
740
+ let(:attrs) { {publish_at: new_scheduled_at} }
741
+
742
+ specify 'only updates the timestamp to publish the video' do
743
+ expect(video.update attrs).to be true
744
+ expect(video.privacy_status).to eq old_privacy_status
745
+ expect(video.title).to eq old_title
746
+ # NOTE: This is another irrational behavior of YouTube API. In short,
747
+ # the response of Video#update *does not* include the publishAt value
748
+ # even if it exists. You need to call Video#list again to get it.
749
+ video = Yt::Video.new id: id, auth: $account
750
+ expect(video.scheduled_at).to eq new_scheduled_at
751
+ # Setting a private (scheduled) video to private has no effect:
752
+ expect(video.update privacy_status: 'private').to be true
753
+ video = Yt::Video.new id: id, auth: $account
754
+ expect(video.scheduled_at).to eq new_scheduled_at
755
+ # Setting a private (scheduled) video to unlisted/public removes publishAt:
756
+ expect(video.update privacy_status: 'unlisted').to be true
757
+ video = Yt::Video.new id: id, auth: $account
758
+ expect(video.scheduled_at).to be_nil
759
+ end
760
+ end
761
+
762
+ context 'passing the parameter in camel-case syntax' do
763
+ let(:attrs) { {publishAt: new_scheduled_at} }
764
+
765
+ specify 'only updates the timestamp to publish the video' do
766
+ expect(video.update attrs).to be true
767
+ expect(video.scheduled_at).to eq new_scheduled_at
768
+ expect(video.privacy_status).to eq old_privacy_status
769
+ expect(video.title).to eq old_title
770
+ end
771
+ end
772
+ end
773
+
774
+ # @note: This should somehow test that the thumbnail *changes*. However,
775
+ # YouTube does not change the URL of the thumbnail even though the content
776
+ # changes. A full test would have to *download* the thumbnails before and
777
+ # after, and compare the files. For now, not raising error is enough.
778
+ # Eventually, change to `expect{update}.to change{video.thumbnail_url}`
779
+ context 'given one of my own videos for which I want to upload a thumbnail' do
780
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
781
+ let(:update) { video.upload_thumbnail path_or_url }
782
+
783
+ context 'given the path to a local JPG image file' do
784
+ let(:path_or_url) { File.expand_path '../thumbnail.jpg', __FILE__ }
785
+
786
+ it { expect{update}.not_to raise_error }
787
+ end
788
+
789
+ context 'given the path to a remote PNG image file' do
790
+ let(:path_or_url) { 'https://bit.ly/yt_thumbnail' }
791
+
792
+ it { expect{update}.not_to raise_error }
793
+ end
794
+
795
+ context 'given an invalid URL' do
796
+ let(:path_or_url) { 'this-is-not-a-url' }
797
+
798
+ it { expect{update}.to raise_error Yt::Errors::RequestError }
799
+ end
800
+ end
801
+
802
+ # @note: This test is separated from the block above because YouTube only
803
+ # returns file details for *some videos*: "The fileDetails object will
804
+ # only be returned if the processingDetails.fileAvailability property
805
+ # has a value of available.". Therefore, just to test fileDetails, we use a
806
+ # different video that (for some unknown reason) is marked as 'available'.
807
+ # Also note that I was not able to find a single video returning fileName,
808
+ # therefore video.file_name is not returned by Yt, until it can be tested.
809
+ # @see https://developers.google.com/youtube/v3/docs/videos#processingDetails.fileDetailsAvailability
810
+ context 'given one of my own *available* videos' do
811
+ let(:id) { 'yCmaOvUFhlI' }
812
+
813
+ it 'returns valid file details' do
814
+ expect(video.file_size).to be_an Integer
815
+ expect(video.file_type).to be_a String
816
+ expect(video.container).to be_a String
817
+ end
818
+ end
819
+ end
820
+ # encoding: UTF-8
821
+
822
+ require 'spec_helper'
823
+ require 'yt/models/video'
824
+
825
+ describe Yt::Video, :device_app do
826
+ subject(:video) { Yt::Video.new id: id, auth: $account }
827
+
828
+ context 'given someone else’s video' do
829
+ let(:id) { '9bZkp7q19f0' }
830
+
831
+ it { expect(video.content_detail).to be_a Yt::ContentDetail }
832
+
833
+ it 'returns valid metadata' do
834
+ expect(video.title).to be_a String
835
+ expect(video.description).to be_a String
836
+ expect(video.thumbnail_url).to be_a String
837
+ expect(video.published_at).to be_a Time
838
+ expect(video.privacy_status).to be_a String
839
+ expect(video.tags).to be_an Array
840
+ expect(video.channel_id).to be_a String
841
+ expect(video.channel_title).to be_a String
842
+ expect(video.channel_url).to be_a String
843
+ expect(video.category_id).to be_a String
844
+ expect(video.live_broadcast_content).to be_a String
845
+ expect(video.view_count).to be_an Integer
846
+ expect(video.like_count).to be_an Integer
847
+ expect(video.dislike_count).to be_an Integer
848
+ expect(video.favorite_count).to be_an Integer
849
+ expect(video.comment_count).to be_an Integer
850
+ expect(video.duration).to be_an Integer
851
+ expect(video.hd?).to be_in [true, false]
852
+ expect(video.stereoscopic?).to be_in [true, false]
853
+ expect(video.captioned?).to be_in [true, false]
854
+ expect(video.licensed?).to be_in [true, false]
855
+ expect(video.deleted?).to be_in [true, false]
856
+ expect(video.failed?).to be_in [true, false]
857
+ expect(video.processed?).to be_in [true, false]
858
+ expect(video.rejected?).to be_in [true, false]
859
+ expect(video.uploading?).to be_in [true, false]
860
+ expect(video.uses_unsupported_codec?).to be_in [true, false]
861
+ expect(video.has_failed_conversion?).to be_in [true, false]
862
+ expect(video.empty?).to be_in [true, false]
863
+ expect(video.invalid?).to be_in [true, false]
864
+ expect(video.too_small?).to be_in [true, false]
865
+ expect(video.aborted?).to be_in [true, false]
866
+ expect(video.claimed?).to be_in [true, false]
867
+ expect(video.infringes_copyright?).to be_in [true, false]
868
+ expect(video.duplicate?).to be_in [true, false]
869
+ expect(video.scheduled_at.class).to be_in [NilClass, Time]
870
+ expect(video.scheduled?).to be_in [true, false]
871
+ expect(video.too_long?).to be_in [true, false]
872
+ expect(video.violates_terms_of_use?).to be_in [true, false]
873
+ expect(video.inappropriate?).to be_in [true, false]
874
+ expect(video.infringes_trademark?).to be_in [true, false]
875
+ expect(video.belongs_to_closed_account?).to be_in [true, false]
876
+ expect(video.belongs_to_suspended_account?).to be_in [true, false]
877
+ expect(video.licensed_as_creative_commons?).to be_in [true, false]
878
+ expect(video.licensed_as_standard_youtube?).to be_in [true, false]
879
+ expect(video.has_public_stats_viewable?).to be_in [true, false]
880
+ expect(video.embeddable?).to be_in [true, false]
881
+ expect(video.actual_start_time).to be_nil
882
+ expect(video.actual_end_time).to be_nil
883
+ expect(video.scheduled_start_time).to be_nil
884
+ expect(video.scheduled_end_time).to be_nil
885
+ expect(video.concurrent_viewers).to be_nil
886
+ expect(video.embed_html).to be_a String
887
+ expect(video.category_title).to be_a String
888
+ end
889
+
890
+ it { expect{video.update}.to fail }
891
+ it { expect{video.delete}.to fail.with 'forbidden' }
892
+
893
+ context 'that I like' do
894
+ before { video.like }
895
+ it { expect(video).to be_liked }
896
+ it { expect(video.dislike).to be true }
897
+ end
898
+
899
+ context 'that I dislike' do
900
+ before { video.dislike }
901
+ it { expect(video).not_to be_liked }
902
+ it { expect(video.like).to be true }
903
+ end
904
+
905
+ context 'that I am indifferent to' do
906
+ before { video.unlike }
907
+ it { expect(video).not_to be_liked }
908
+ it { expect(video.like).to be true }
909
+ end
910
+ end
911
+
912
+ context 'given someone else’s live video broadcast scheduled in the future' do
913
+ let(:id) { 'PqzGI8gO_gk' }
914
+
915
+ it 'returns valid live streaming details' do
916
+ expect(video.actual_start_time).to be_nil
917
+ expect(video.actual_end_time).to be_nil
918
+ expect(video.scheduled_start_time).to be_a Time
919
+ expect(video.scheduled_end_time).to be_nil
920
+ end
921
+ end
922
+
923
+ context 'given someone else’s past live video broadcast' do
924
+ let(:id) { 'COOM8_tOy6U' }
925
+
926
+ it 'returns valid live streaming details' do
927
+ expect(video.actual_start_time).to be_a Time
928
+ expect(video.actual_end_time).to be_a Time
929
+ expect(video.scheduled_start_time).to be_a Time
930
+ expect(video.scheduled_end_time).to be_a Time
931
+ expect(video.concurrent_viewers).to be_nil
932
+ end
933
+ end
934
+
935
+ context 'given an unknown video' do
936
+ let(:id) { 'not-a-video-id' }
937
+
938
+ it { expect{video.content_detail}.to raise_error Yt::Errors::NoItems }
939
+ it { expect{video.snippet}.to raise_error Yt::Errors::NoItems }
940
+ it { expect{video.rating}.to raise_error Yt::Errors::NoItems }
941
+ it { expect{video.status}.to raise_error Yt::Errors::NoItems }
942
+ it { expect{video.statistics_set}.to raise_error Yt::Errors::NoItems }
943
+ it { expect{video.file_detail}.to raise_error Yt::Errors::NoItems }
944
+ end
945
+
946
+ context 'given one of my own videos that I want to delete' do
947
+ before(:all) { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: "Yt Test Delete Video #{rand}" }
948
+ let(:id) { @tmp_video.id }
949
+
950
+ it { expect(video.delete).to be true }
951
+ end
952
+
953
+ context 'given one of my own videos that I want to update' do
954
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
955
+ let!(:old_title) { video.title }
956
+ let!(:old_privacy_status) { video.privacy_status }
957
+ let(:update) { video.update attrs }
958
+
959
+ context 'given I update the title' do
960
+ # NOTE: The use of UTF-8 characters is to test that we can pass up to
961
+ # 50 characters, independently of their representation
962
+ let(:attrs) { {title: "Yt Example Update Video #{rand} - ®•♡❥❦❧☙"} }
963
+
964
+ specify 'only updates the title' do
965
+ expect(update).to be true
966
+ expect(video.title).not_to eq old_title
967
+ expect(video.privacy_status).to eq old_privacy_status
968
+ end
969
+ end
970
+
971
+ context 'given I update the description' do
972
+ let!(:old_description) { video.description }
973
+ let(:attrs) { {description: "Yt Example Description #{rand} - ®•♡❥❦❧☙"} }
974
+
975
+ specify 'only updates the description' do
976
+ expect(update).to be true
977
+ expect(video.description).not_to eq old_description
978
+ expect(video.title).to eq old_title
979
+ expect(video.privacy_status).to eq old_privacy_status
980
+ end
981
+ end
982
+
983
+ context 'given I update the tags' do
984
+ let!(:old_tags) { video.tags }
985
+ let(:attrs) { {tags: ["Yt Test Tag #{rand}"]} }
986
+
987
+ specify 'only updates the tag' do
988
+ expect(update).to be true
989
+ expect(video.tags).not_to eq old_tags
990
+ expect(video.title).to eq old_title
991
+ expect(video.privacy_status).to eq old_privacy_status
992
+ end
993
+ end
994
+
995
+ context 'given I update the category ID' do
996
+ let!(:old_category_id) { video.category_id }
997
+ let!(:new_category_id) { old_category_id == '22' ? '21' : '22' }
998
+
999
+ context 'passing the parameter in underscore syntax' do
1000
+ let(:attrs) { {category_id: new_category_id} }
1001
+
1002
+ specify 'only updates the category ID' do
1003
+ expect(update).to be true
1004
+ expect(video.category_id).not_to eq old_category_id
1005
+ expect(video.title).to eq old_title
1006
+ expect(video.privacy_status).to eq old_privacy_status
1007
+ end
1008
+ end
1009
+
1010
+ context 'passing the parameter in camel-case syntax' do
1011
+ let(:attrs) { {categoryId: new_category_id} }
1012
+
1013
+ specify 'only updates the category ID' do
1014
+ expect(update).to be true
1015
+ expect(video.category_id).not_to eq old_category_id
1016
+ end
1017
+ end
1018
+ end
1019
+
1020
+ context 'given I update title, description and/or tags using angle brackets' do
1021
+ let(:attrs) { {title: "Example Yt Test < >", description: '< >', tags: ['<tag>']} }
1022
+
1023
+ specify 'updates them replacing angle brackets with similar unicode characters accepted by YouTube' do
1024
+ expect(update).to be true
1025
+ expect(video.title).to eq 'Example Yt Test ‹ ›'
1026
+ expect(video.description).to eq '‹ ›'
1027
+ expect(video.tags).to eq ['‹tag›']
1028
+ end
1029
+ end
1030
+
1031
+ # note: 'scheduled' videos cannot be set to 'unlisted'
1032
+ context 'given I update the privacy status' do
1033
+ before { video.update publish_at: nil if video.scheduled? }
1034
+ let!(:new_privacy_status) { old_privacy_status == 'private' ? 'unlisted' : 'private' }
1035
+
1036
+ context 'passing the parameter in underscore syntax' do
1037
+ let(:attrs) { {privacy_status: new_privacy_status} }
1038
+
1039
+ specify 'only updates the privacy status' do
1040
+ expect(update).to be true
1041
+ expect(video.privacy_status).not_to eq old_privacy_status
1042
+ expect(video.title).to eq old_title
1043
+ end
1044
+ end
1045
+
1046
+ context 'passing the parameter in camel-case syntax' do
1047
+ let(:attrs) { {privacyStatus: new_privacy_status} }
1048
+
1049
+ specify 'only updates the privacy status' do
1050
+ expect(update).to be true
1051
+ expect(video.privacy_status).not_to eq old_privacy_status
1052
+ expect(video.title).to eq old_title
1053
+ end
1054
+ end
1055
+ end
1056
+
1057
+ context 'given I update the public stats viewable setting' do
1058
+ let!(:old_public_stats_viewable) { video.has_public_stats_viewable? }
1059
+ let!(:new_public_stats_viewable) { !old_public_stats_viewable }
1060
+
1061
+ context 'passing the parameter in underscore syntax' do
1062
+ let(:attrs) { {public_stats_viewable: new_public_stats_viewable} }
1063
+
1064
+ specify 'only updates the public stats viewable setting' do
1065
+ expect(update).to be true
1066
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
1067
+ expect(video.privacy_status).to eq old_privacy_status
1068
+ expect(video.title).to eq old_title
1069
+ end
1070
+ end
1071
+
1072
+ context 'passing the parameter in camel-case syntax' do
1073
+ let(:attrs) { {publicStatsViewable: new_public_stats_viewable} }
1074
+
1075
+ specify 'only updates the public stats viewable setting' do
1076
+ expect(update).to be true
1077
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
1078
+ expect(video.privacy_status).to eq old_privacy_status
1079
+ expect(video.title).to eq old_title
1080
+ end
1081
+ end
1082
+ end
1083
+
1084
+ it 'returns valid reports for video-related metrics' do
1085
+ # Some reports are only available to Content Owners.
1086
+ # See content owner test for more details about what the methods return.
1087
+ expect{video.views}.not_to raise_error
1088
+ expect{video.comments}.not_to raise_error
1089
+ expect{video.likes}.not_to raise_error
1090
+ expect{video.dislikes}.not_to raise_error
1091
+ expect{video.shares}.not_to raise_error
1092
+ expect{video.subscribers_gained}.not_to raise_error
1093
+ expect{video.subscribers_lost}.not_to raise_error
1094
+ expect{video.videos_added_to_playlists}.not_to raise_error
1095
+ expect{video.videos_removed_from_playlists}.not_to raise_error
1096
+ expect{video.estimated_minutes_watched}.not_to raise_error
1097
+ expect{video.average_view_duration}.not_to raise_error
1098
+ expect{video.average_view_percentage}.not_to raise_error
1099
+ expect{video.annotation_clicks}.not_to raise_error
1100
+ expect{video.annotation_click_through_rate}.not_to raise_error
1101
+ expect{video.annotation_close_rate}.not_to raise_error
1102
+ expect{video.viewer_percentage}.not_to raise_error
1103
+ expect{video.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
1104
+ expect{video.ad_impressions}.to raise_error Yt::Errors::Unauthorized
1105
+ expect{video.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
1106
+ expect{video.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
1107
+ expect{video.advertising_options_set}.to raise_error Yt::Errors::Forbidden
1108
+ end
1109
+ end
1110
+
1111
+ # @note: This test is separated from the block above because, for some
1112
+ # undocumented reasons, if an existing video was private, then set to
1113
+ # unlisted, then set to private again, YouTube _sometimes_ raises a
1114
+ # 400 Error when trying to set the publishAt timestamp.
1115
+ # Therefore, just to test the updating of publishAt, we use a brand new
1116
+ # video (set to private), rather than reusing an existing one as above.
1117
+ context 'given one of my own *private* videos that I want to update' do
1118
+ before { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: old_title, privacy_status: old_privacy_status }
1119
+ let(:id) { @tmp_video.id }
1120
+ let!(:old_title) { "Yt Test Update publishAt Video #{rand}" }
1121
+ let!(:old_privacy_status) { 'private' }
1122
+ after { video.delete }
1123
+
1124
+ let!(:new_scheduled_at) { Yt::Timestamp.parse("#{rand(30) + 1} Jan 2020", Time.now) }
1125
+
1126
+ context 'passing the parameter in underscore syntax' do
1127
+ let(:attrs) { {publish_at: new_scheduled_at} }
1128
+
1129
+ specify 'only updates the timestamp to publish the video' do
1130
+ expect(video.update attrs).to be true
1131
+ expect(video.privacy_status).to eq old_privacy_status
1132
+ expect(video.title).to eq old_title
1133
+ # NOTE: This is another irrational behavior of YouTube API. In short,
1134
+ # the response of Video#update *does not* include the publishAt value
1135
+ # even if it exists. You need to call Video#list again to get it.
1136
+ video = Yt::Video.new id: id, auth: $account
1137
+ expect(video.scheduled_at).to eq new_scheduled_at
1138
+ # Setting a private (scheduled) video to private has no effect:
1139
+ expect(video.update privacy_status: 'private').to be true
1140
+ video = Yt::Video.new id: id, auth: $account
1141
+ expect(video.scheduled_at).to eq new_scheduled_at
1142
+ # Setting a private (scheduled) video to unlisted/public removes publishAt:
1143
+ expect(video.update privacy_status: 'unlisted').to be true
1144
+ video = Yt::Video.new id: id, auth: $account
1145
+ expect(video.scheduled_at).to be_nil
1146
+ end
1147
+ end
1148
+
1149
+ context 'passing the parameter in camel-case syntax' do
1150
+ let(:attrs) { {publishAt: new_scheduled_at} }
1151
+
1152
+ specify 'only updates the timestamp to publish the video' do
1153
+ expect(video.update attrs).to be true
1154
+ expect(video.scheduled_at).to eq new_scheduled_at
1155
+ expect(video.privacy_status).to eq old_privacy_status
1156
+ expect(video.title).to eq old_title
1157
+ end
1158
+ end
1159
+ end
1160
+
1161
+ # @note: This should somehow test that the thumbnail *changes*. However,
1162
+ # YouTube does not change the URL of the thumbnail even though the content
1163
+ # changes. A full test would have to *download* the thumbnails before and
1164
+ # after, and compare the files. For now, not raising error is enough.
1165
+ # Eventually, change to `expect{update}.to change{video.thumbnail_url}`
1166
+ context 'given one of my own videos for which I want to upload a thumbnail' do
1167
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
1168
+ let(:update) { video.upload_thumbnail path_or_url }
1169
+
1170
+ context 'given the path to a local JPG image file' do
1171
+ let(:path_or_url) { File.expand_path '../thumbnail.jpg', __FILE__ }
1172
+
1173
+ it { expect{update}.not_to raise_error }
1174
+ end
1175
+
1176
+ context 'given the path to a remote PNG image file' do
1177
+ let(:path_or_url) { 'https://bit.ly/yt_thumbnail' }
1178
+
1179
+ it { expect{update}.not_to raise_error }
1180
+ end
1181
+
1182
+ context 'given an invalid URL' do
1183
+ let(:path_or_url) { 'this-is-not-a-url' }
1184
+
1185
+ it { expect{update}.to raise_error Yt::Errors::RequestError }
1186
+ end
1187
+ end
1188
+
1189
+ # @note: This test is separated from the block above because YouTube only
1190
+ # returns file details for *some videos*: "The fileDetails object will
1191
+ # only be returned if the processingDetails.fileAvailability property
1192
+ # has a value of available.". Therefore, just to test fileDetails, we use a
1193
+ # different video that (for some unknown reason) is marked as 'available'.
1194
+ # Also note that I was not able to find a single video returning fileName,
1195
+ # therefore video.file_name is not returned by Yt, until it can be tested.
1196
+ # @see https://developers.google.com/youtube/v3/docs/videos#processingDetails.fileDetailsAvailability
1197
+ context 'given one of my own *available* videos' do
1198
+ let(:id) { 'yCmaOvUFhlI' }
1199
+
1200
+ it 'returns valid file details' do
1201
+ expect(video.file_size).to be_an Integer
1202
+ expect(video.file_type).to be_a String
1203
+ expect(video.container).to be_a String
1204
+ end
1205
+ end
1206
+ end
1207
+ # encoding: UTF-8
1208
+
1209
+ require 'spec_helper'
1210
+ require 'yt/models/video'
1211
+
1212
+ describe Yt::Video, :device_app do
1213
+ subject(:video) { Yt::Video.new id: id, auth: $account }
1214
+
1215
+ context 'given someone else’s video' do
1216
+ let(:id) { '9bZkp7q19f0' }
1217
+
1218
+ it { expect(video.content_detail).to be_a Yt::ContentDetail }
1219
+
1220
+ it 'returns valid metadata' do
1221
+ expect(video.title).to be_a String
1222
+ expect(video.description).to be_a String
1223
+ expect(video.thumbnail_url).to be_a String
1224
+ expect(video.published_at).to be_a Time
1225
+ expect(video.privacy_status).to be_a String
1226
+ expect(video.tags).to be_an Array
1227
+ expect(video.channel_id).to be_a String
1228
+ expect(video.channel_title).to be_a String
1229
+ expect(video.channel_url).to be_a String
1230
+ expect(video.category_id).to be_a String
1231
+ expect(video.live_broadcast_content).to be_a String
1232
+ expect(video.view_count).to be_an Integer
1233
+ expect(video.like_count).to be_an Integer
1234
+ expect(video.dislike_count).to be_an Integer
1235
+ expect(video.favorite_count).to be_an Integer
1236
+ expect(video.comment_count).to be_an Integer
1237
+ expect(video.duration).to be_an Integer
1238
+ expect(video.hd?).to be_in [true, false]
1239
+ expect(video.stereoscopic?).to be_in [true, false]
1240
+ expect(video.captioned?).to be_in [true, false]
1241
+ expect(video.licensed?).to be_in [true, false]
1242
+ expect(video.deleted?).to be_in [true, false]
1243
+ expect(video.failed?).to be_in [true, false]
1244
+ expect(video.processed?).to be_in [true, false]
1245
+ expect(video.rejected?).to be_in [true, false]
1246
+ expect(video.uploading?).to be_in [true, false]
1247
+ expect(video.uses_unsupported_codec?).to be_in [true, false]
1248
+ expect(video.has_failed_conversion?).to be_in [true, false]
1249
+ expect(video.empty?).to be_in [true, false]
1250
+ expect(video.invalid?).to be_in [true, false]
1251
+ expect(video.too_small?).to be_in [true, false]
1252
+ expect(video.aborted?).to be_in [true, false]
1253
+ expect(video.claimed?).to be_in [true, false]
1254
+ expect(video.infringes_copyright?).to be_in [true, false]
1255
+ expect(video.duplicate?).to be_in [true, false]
1256
+ expect(video.scheduled_at.class).to be_in [NilClass, Time]
1257
+ expect(video.scheduled?).to be_in [true, false]
1258
+ expect(video.too_long?).to be_in [true, false]
1259
+ expect(video.violates_terms_of_use?).to be_in [true, false]
1260
+ expect(video.inappropriate?).to be_in [true, false]
1261
+ expect(video.infringes_trademark?).to be_in [true, false]
1262
+ expect(video.belongs_to_closed_account?).to be_in [true, false]
1263
+ expect(video.belongs_to_suspended_account?).to be_in [true, false]
1264
+ expect(video.licensed_as_creative_commons?).to be_in [true, false]
1265
+ expect(video.licensed_as_standard_youtube?).to be_in [true, false]
1266
+ expect(video.has_public_stats_viewable?).to be_in [true, false]
1267
+ expect(video.embeddable?).to be_in [true, false]
1268
+ expect(video.actual_start_time).to be_nil
1269
+ expect(video.actual_end_time).to be_nil
1270
+ expect(video.scheduled_start_time).to be_nil
1271
+ expect(video.scheduled_end_time).to be_nil
1272
+ expect(video.concurrent_viewers).to be_nil
1273
+ expect(video.embed_html).to be_a String
1274
+ expect(video.category_title).to be_a String
1275
+ end
1276
+
1277
+ it { expect{video.update}.to fail }
1278
+ it { expect{video.delete}.to fail.with 'forbidden' }
1279
+
1280
+ context 'that I like' do
1281
+ before { video.like }
1282
+ it { expect(video).to be_liked }
1283
+ it { expect(video.dislike).to be true }
1284
+ end
1285
+
1286
+ context 'that I dislike' do
1287
+ before { video.dislike }
1288
+ it { expect(video).not_to be_liked }
1289
+ it { expect(video.like).to be true }
1290
+ end
1291
+
1292
+ context 'that I am indifferent to' do
1293
+ before { video.unlike }
1294
+ it { expect(video).not_to be_liked }
1295
+ it { expect(video.like).to be true }
1296
+ end
1297
+ end
1298
+
1299
+ context 'given someone else’s live video broadcast scheduled in the future' do
1300
+ let(:id) { 'PqzGI8gO_gk' }
1301
+
1302
+ it 'returns valid live streaming details' do
1303
+ expect(video.actual_start_time).to be_nil
1304
+ expect(video.actual_end_time).to be_nil
1305
+ expect(video.scheduled_start_time).to be_a Time
1306
+ expect(video.scheduled_end_time).to be_nil
1307
+ end
1308
+ end
1309
+
1310
+ context 'given someone else’s past live video broadcast' do
1311
+ let(:id) { 'COOM8_tOy6U' }
1312
+
1313
+ it 'returns valid live streaming details' do
1314
+ expect(video.actual_start_time).to be_a Time
1315
+ expect(video.actual_end_time).to be_a Time
1316
+ expect(video.scheduled_start_time).to be_a Time
1317
+ expect(video.scheduled_end_time).to be_a Time
1318
+ expect(video.concurrent_viewers).to be_nil
1319
+ end
1320
+ end
1321
+
1322
+ context 'given an unknown video' do
1323
+ let(:id) { 'not-a-video-id' }
1324
+
1325
+ it { expect{video.content_detail}.to raise_error Yt::Errors::NoItems }
1326
+ it { expect{video.snippet}.to raise_error Yt::Errors::NoItems }
1327
+ it { expect{video.rating}.to raise_error Yt::Errors::NoItems }
1328
+ it { expect{video.status}.to raise_error Yt::Errors::NoItems }
1329
+ it { expect{video.statistics_set}.to raise_error Yt::Errors::NoItems }
1330
+ it { expect{video.file_detail}.to raise_error Yt::Errors::NoItems }
1331
+ end
1332
+
1333
+ context 'given one of my own videos that I want to delete' do
1334
+ before(:all) { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: "Yt Test Delete Video #{rand}" }
1335
+ let(:id) { @tmp_video.id }
1336
+
1337
+ it { expect(video.delete).to be true }
1338
+ end
1339
+
1340
+ context 'given one of my own videos that I want to update' do
1341
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
1342
+ let!(:old_title) { video.title }
1343
+ let!(:old_privacy_status) { video.privacy_status }
1344
+ let(:update) { video.update attrs }
1345
+
1346
+ context 'given I update the title' do
1347
+ # NOTE: The use of UTF-8 characters is to test that we can pass up to
1348
+ # 50 characters, independently of their representation
1349
+ let(:attrs) { {title: "Yt Example Update Video #{rand} - ®•♡❥❦❧☙"} }
1350
+
1351
+ specify 'only updates the title' do
1352
+ expect(update).to be true
1353
+ expect(video.title).not_to eq old_title
1354
+ expect(video.privacy_status).to eq old_privacy_status
1355
+ end
1356
+ end
1357
+
1358
+ context 'given I update the description' do
1359
+ let!(:old_description) { video.description }
1360
+ let(:attrs) { {description: "Yt Example Description #{rand} - ®•♡❥❦❧☙"} }
1361
+
1362
+ specify 'only updates the description' do
1363
+ expect(update).to be true
1364
+ expect(video.description).not_to eq old_description
1365
+ expect(video.title).to eq old_title
1366
+ expect(video.privacy_status).to eq old_privacy_status
1367
+ end
1368
+ end
1369
+
1370
+ context 'given I update the tags' do
1371
+ let!(:old_tags) { video.tags }
1372
+ let(:attrs) { {tags: ["Yt Test Tag #{rand}"]} }
1373
+
1374
+ specify 'only updates the tag' do
1375
+ expect(update).to be true
1376
+ expect(video.tags).not_to eq old_tags
1377
+ expect(video.title).to eq old_title
1378
+ expect(video.privacy_status).to eq old_privacy_status
1379
+ end
1380
+ end
1381
+
1382
+ context 'given I update the category ID' do
1383
+ let!(:old_category_id) { video.category_id }
1384
+ let!(:new_category_id) { old_category_id == '22' ? '21' : '22' }
1385
+
1386
+ context 'passing the parameter in underscore syntax' do
1387
+ let(:attrs) { {category_id: new_category_id} }
1388
+
1389
+ specify 'only updates the category ID' do
1390
+ expect(update).to be true
1391
+ expect(video.category_id).not_to eq old_category_id
1392
+ expect(video.title).to eq old_title
1393
+ expect(video.privacy_status).to eq old_privacy_status
1394
+ end
1395
+ end
1396
+
1397
+ context 'passing the parameter in camel-case syntax' do
1398
+ let(:attrs) { {categoryId: new_category_id} }
1399
+
1400
+ specify 'only updates the category ID' do
1401
+ expect(update).to be true
1402
+ expect(video.category_id).not_to eq old_category_id
1403
+ end
1404
+ end
1405
+ end
1406
+
1407
+ context 'given I update title, description and/or tags using angle brackets' do
1408
+ let(:attrs) { {title: "Example Yt Test < >", description: '< >', tags: ['<tag>']} }
1409
+
1410
+ specify 'updates them replacing angle brackets with similar unicode characters accepted by YouTube' do
1411
+ expect(update).to be true
1412
+ expect(video.title).to eq 'Example Yt Test ‹ ›'
1413
+ expect(video.description).to eq '‹ ›'
1414
+ expect(video.tags).to eq ['‹tag›']
1415
+ end
1416
+ end
1417
+
1418
+ # note: 'scheduled' videos cannot be set to 'unlisted'
1419
+ context 'given I update the privacy status' do
1420
+ before { video.update publish_at: nil if video.scheduled? }
1421
+ let!(:new_privacy_status) { old_privacy_status == 'private' ? 'unlisted' : 'private' }
1422
+
1423
+ context 'passing the parameter in underscore syntax' do
1424
+ let(:attrs) { {privacy_status: new_privacy_status} }
1425
+
1426
+ specify 'only updates the privacy status' do
1427
+ expect(update).to be true
1428
+ expect(video.privacy_status).not_to eq old_privacy_status
1429
+ expect(video.title).to eq old_title
1430
+ end
1431
+ end
1432
+
1433
+ context 'passing the parameter in camel-case syntax' do
1434
+ let(:attrs) { {privacyStatus: new_privacy_status} }
1435
+
1436
+ specify 'only updates the privacy status' do
1437
+ expect(update).to be true
1438
+ expect(video.privacy_status).not_to eq old_privacy_status
1439
+ expect(video.title).to eq old_title
1440
+ end
1441
+ end
1442
+ end
1443
+
1444
+ context 'given I update the embeddable status' do
1445
+ let!(:old_embeddable) { video.embeddable? }
1446
+ let!(:new_embeddable) { !old_embeddable }
1447
+
1448
+ let(:attrs) { {embeddable: new_embeddable} }
1449
+
1450
+ # @note: This test is a reflection of another irrational behavior of
1451
+ # YouTube API. Although 'embeddable' can be passed as an 'update'
1452
+ # attribute according to the documentation, it simply does not work.
1453
+ # The day YouTube fixes it, then this test will finally fail and will
1454
+ # be removed, documenting how to update 'embeddable' too.
1455
+ # @see https://developers.google.com/youtube/v3/docs/videos/update
1456
+ # @see https://code.google.com/p/gdata-issues/issues/detail?id=4861
1457
+ specify 'does not update the embeddable status' do
1458
+ expect(update).to be true
1459
+ expect(video.embeddable?).to eq old_embeddable
1460
+ end
1461
+ end
1462
+
1463
+ context 'given I update the public stats viewable setting' do
1464
+ let!(:old_public_stats_viewable) { video.has_public_stats_viewable? }
1465
+ let!(:new_public_stats_viewable) { !old_public_stats_viewable }
1466
+
1467
+ context 'passing the parameter in underscore syntax' do
1468
+ let(:attrs) { {public_stats_viewable: new_public_stats_viewable} }
1469
+
1470
+ specify 'only updates the public stats viewable setting' do
1471
+ expect(update).to be true
1472
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
1473
+ expect(video.privacy_status).to eq old_privacy_status
1474
+ expect(video.title).to eq old_title
1475
+ end
1476
+ end
1477
+
1478
+ context 'passing the parameter in camel-case syntax' do
1479
+ let(:attrs) { {publicStatsViewable: new_public_stats_viewable} }
1480
+
1481
+ specify 'only updates the public stats viewable setting' do
1482
+ expect(update).to be true
1483
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
1484
+ expect(video.privacy_status).to eq old_privacy_status
1485
+ expect(video.title).to eq old_title
1486
+ end
1487
+ end
1488
+ end
1489
+
1490
+ it 'returns valid reports for video-related metrics' do
1491
+ # Some reports are only available to Content Owners.
1492
+ # See content owner test for more details about what the methods return.
1493
+ expect{video.views}.not_to raise_error
1494
+ expect{video.comments}.not_to raise_error
1495
+ expect{video.likes}.not_to raise_error
1496
+ expect{video.dislikes}.not_to raise_error
1497
+ expect{video.shares}.not_to raise_error
1498
+ expect{video.subscribers_gained}.not_to raise_error
1499
+ expect{video.subscribers_lost}.not_to raise_error
1500
+ expect{video.videos_added_to_playlists}.not_to raise_error
1501
+ expect{video.videos_removed_from_playlists}.not_to raise_error
1502
+ expect{video.estimated_minutes_watched}.not_to raise_error
1503
+ expect{video.average_view_duration}.not_to raise_error
1504
+ expect{video.average_view_percentage}.not_to raise_error
1505
+ expect{video.annotation_clicks}.not_to raise_error
1506
+ expect{video.annotation_click_through_rate}.not_to raise_error
1507
+ expect{video.annotation_close_rate}.not_to raise_error
1508
+ expect{video.viewer_percentage}.not_to raise_error
1509
+ expect{video.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
1510
+ expect{video.ad_impressions}.to raise_error Yt::Errors::Unauthorized
1511
+ expect{video.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
1512
+ expect{video.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
1513
+ expect{video.advertising_options_set}.to raise_error Yt::Errors::Forbidden
1514
+ end
1515
+ end
1516
+
1517
+ # @note: This test is separated from the block above because, for some
1518
+ # undocumented reasons, if an existing video was private, then set to
1519
+ # unlisted, then set to private again, YouTube _sometimes_ raises a
1520
+ # 400 Error when trying to set the publishAt timestamp.
1521
+ # Therefore, just to test the updating of publishAt, we use a brand new
1522
+ # video (set to private), rather than reusing an existing one as above.
1523
+ context 'given one of my own *private* videos that I want to update' do
1524
+ before { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: old_title, privacy_status: old_privacy_status }
1525
+ let(:id) { @tmp_video.id }
1526
+ let!(:old_title) { "Yt Test Update publishAt Video #{rand}" }
1527
+ let!(:old_privacy_status) { 'private' }
1528
+ after { video.delete }
1529
+
1530
+ let!(:new_scheduled_at) { Yt::Timestamp.parse("#{rand(30) + 1} Jan 2020", Time.now) }
1531
+
1532
+ context 'passing the parameter in underscore syntax' do
1533
+ let(:attrs) { {publish_at: new_scheduled_at} }
1534
+
1535
+ specify 'only updates the timestamp to publish the video' do
1536
+ expect(video.update attrs).to be true
1537
+ expect(video.privacy_status).to eq old_privacy_status
1538
+ expect(video.title).to eq old_title
1539
+ # NOTE: This is another irrational behavior of YouTube API. In short,
1540
+ # the response of Video#update *does not* include the publishAt value
1541
+ # even if it exists. You need to call Video#list again to get it.
1542
+ video = Yt::Video.new id: id, auth: $account
1543
+ expect(video.scheduled_at).to eq new_scheduled_at
1544
+ # Setting a private (scheduled) video to private has no effect:
1545
+ expect(video.update privacy_status: 'private').to be true
1546
+ video = Yt::Video.new id: id, auth: $account
1547
+ expect(video.scheduled_at).to eq new_scheduled_at
1548
+ # Setting a private (scheduled) video to unlisted/public removes publishAt:
1549
+ expect(video.update privacy_status: 'unlisted').to be true
1550
+ video = Yt::Video.new id: id, auth: $account
1551
+ expect(video.scheduled_at).to be_nil
1552
+ end
1553
+ end
1554
+
1555
+ context 'passing the parameter in camel-case syntax' do
1556
+ let(:attrs) { {publishAt: new_scheduled_at} }
1557
+
1558
+ specify 'only updates the timestamp to publish the video' do
1559
+ expect(video.update attrs).to be true
1560
+ expect(video.scheduled_at).to eq new_scheduled_at
1561
+ expect(video.privacy_status).to eq old_privacy_status
1562
+ expect(video.title).to eq old_title
1563
+ end
1564
+ end
1565
+ end
1566
+
1567
+ # @note: This should somehow test that the thumbnail *changes*. However,
1568
+ # YouTube does not change the URL of the thumbnail even though the content
1569
+ # changes. A full test would have to *download* the thumbnails before and
1570
+ # after, and compare the files. For now, not raising error is enough.
1571
+ # Eventually, change to `expect{update}.to change{video.thumbnail_url}`
1572
+ context 'given one of my own videos for which I want to upload a thumbnail' do
1573
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
1574
+ let(:update) { video.upload_thumbnail path_or_url }
1575
+
1576
+ context 'given the path to a local JPG image file' do
1577
+ let(:path_or_url) { File.expand_path '../thumbnail.jpg', __FILE__ }
1578
+
1579
+ it { expect{update}.not_to raise_error }
1580
+ end
1581
+
1582
+ context 'given the path to a remote PNG image file' do
1583
+ let(:path_or_url) { 'https://bit.ly/yt_thumbnail' }
1584
+
1585
+ it { expect{update}.not_to raise_error }
1586
+ end
1587
+
1588
+ context 'given an invalid URL' do
1589
+ let(:path_or_url) { 'this-is-not-a-url' }
1590
+
1591
+ it { expect{update}.to raise_error Yt::Errors::RequestError }
1592
+ end
1593
+ end
1594
+
1595
+ # @note: This test is separated from the block above because YouTube only
1596
+ # returns file details for *some videos*: "The fileDetails object will
1597
+ # only be returned if the processingDetails.fileAvailability property
1598
+ # has a value of available.". Therefore, just to test fileDetails, we use a
1599
+ # different video that (for some unknown reason) is marked as 'available'.
1600
+ # Also note that I was not able to find a single video returning fileName,
1601
+ # therefore video.file_name is not returned by Yt, until it can be tested.
1602
+ # @see https://developers.google.com/youtube/v3/docs/videos#processingDetails.fileDetailsAvailability
1603
+ context 'given one of my own *available* videos' do
1604
+ let(:id) { 'yCmaOvUFhlI' }
1605
+
1606
+ it 'returns valid file details' do
1607
+ expect(video.file_size).to be_an Integer
1608
+ expect(video.file_type).to be_a String
1609
+ expect(video.container).to be_a String
1610
+ end
1611
+ end
1612
+ end
1613
+ # encoding: UTF-8
1614
+
1615
+ require 'spec_helper'
1616
+ require 'yt/models/video'
1617
+
1618
+ describe Yt::Video, :device_app do
1619
+ subject(:video) { Yt::Video.new id: id, auth: $account }
1620
+
1621
+ context 'given someone else’s video' do
1622
+ let(:id) { '9bZkp7q19f0' }
1623
+
1624
+ it { expect(video.content_detail).to be_a Yt::ContentDetail }
1625
+
1626
+ it 'returns valid metadata' do
1627
+ expect(video.title).to be_a String
1628
+ expect(video.description).to be_a String
1629
+ expect(video.thumbnail_url).to be_a String
1630
+ expect(video.published_at).to be_a Time
1631
+ expect(video.privacy_status).to be_a String
1632
+ expect(video.tags).to be_an Array
1633
+ expect(video.channel_id).to be_a String
1634
+ expect(video.channel_title).to be_a String
1635
+ expect(video.channel_url).to be_a String
1636
+ expect(video.category_id).to be_a String
1637
+ expect(video.live_broadcast_content).to be_a String
1638
+ expect(video.view_count).to be_an Integer
1639
+ expect(video.like_count).to be_an Integer
1640
+ expect(video.dislike_count).to be_an Integer
1641
+ expect(video.favorite_count).to be_an Integer
1642
+ expect(video.comment_count).to be_an Integer
1643
+ expect(video.duration).to be_an Integer
1644
+ expect(video.hd?).to be_in [true, false]
1645
+ expect(video.stereoscopic?).to be_in [true, false]
1646
+ expect(video.captioned?).to be_in [true, false]
1647
+ expect(video.licensed?).to be_in [true, false]
1648
+ expect(video.deleted?).to be_in [true, false]
1649
+ expect(video.failed?).to be_in [true, false]
1650
+ expect(video.processed?).to be_in [true, false]
1651
+ expect(video.rejected?).to be_in [true, false]
1652
+ expect(video.uploading?).to be_in [true, false]
1653
+ expect(video.uses_unsupported_codec?).to be_in [true, false]
1654
+ expect(video.has_failed_conversion?).to be_in [true, false]
1655
+ expect(video.empty?).to be_in [true, false]
1656
+ expect(video.invalid?).to be_in [true, false]
1657
+ expect(video.too_small?).to be_in [true, false]
1658
+ expect(video.aborted?).to be_in [true, false]
1659
+ expect(video.claimed?).to be_in [true, false]
1660
+ expect(video.infringes_copyright?).to be_in [true, false]
1661
+ expect(video.duplicate?).to be_in [true, false]
1662
+ expect(video.scheduled_at.class).to be_in [NilClass, Time]
1663
+ expect(video.scheduled?).to be_in [true, false]
1664
+ expect(video.too_long?).to be_in [true, false]
1665
+ expect(video.violates_terms_of_use?).to be_in [true, false]
1666
+ expect(video.inappropriate?).to be_in [true, false]
1667
+ expect(video.infringes_trademark?).to be_in [true, false]
1668
+ expect(video.belongs_to_closed_account?).to be_in [true, false]
1669
+ expect(video.belongs_to_suspended_account?).to be_in [true, false]
1670
+ expect(video.licensed_as_creative_commons?).to be_in [true, false]
1671
+ expect(video.licensed_as_standard_youtube?).to be_in [true, false]
1672
+ expect(video.has_public_stats_viewable?).to be_in [true, false]
1673
+ expect(video.embeddable?).to be_in [true, false]
1674
+ expect(video.actual_start_time).to be_nil
1675
+ expect(video.actual_end_time).to be_nil
1676
+ expect(video.scheduled_start_time).to be_nil
1677
+ expect(video.scheduled_end_time).to be_nil
1678
+ expect(video.concurrent_viewers).to be_nil
1679
+ expect(video.embed_html).to be_a String
1680
+ expect(video.category_title).to be_a String
1681
+ end
1682
+
1683
+ it { expect{video.update}.to fail }
1684
+ it { expect{video.delete}.to fail.with 'forbidden' }
1685
+
1686
+ context 'that I like' do
1687
+ before { video.like }
1688
+ it { expect(video).to be_liked }
1689
+ it { expect(video.dislike).to be true }
1690
+ end
1691
+
1692
+ context 'that I dislike' do
1693
+ before { video.dislike }
1694
+ it { expect(video).not_to be_liked }
1695
+ it { expect(video.like).to be true }
1696
+ end
1697
+
1698
+ context 'that I am indifferent to' do
1699
+ before { video.unlike }
1700
+ it { expect(video).not_to be_liked }
1701
+ it { expect(video.like).to be true }
1702
+ end
1703
+ end
1704
+
1705
+ context 'given someone else’s live video broadcast scheduled in the future' do
1706
+ let(:id) { 'PqzGI8gO_gk' }
1707
+
1708
+ it 'returns valid live streaming details' do
1709
+ expect(video.actual_start_time).to be_nil
1710
+ expect(video.actual_end_time).to be_nil
1711
+ expect(video.scheduled_start_time).to be_a Time
1712
+ expect(video.scheduled_end_time).to be_nil
1713
+ end
1714
+ end
1715
+
1716
+ context 'given someone else’s past live video broadcast' do
1717
+ let(:id) { 'COOM8_tOy6U' }
1718
+
1719
+ it 'returns valid live streaming details' do
1720
+ expect(video.actual_start_time).to be_a Time
1721
+ expect(video.actual_end_time).to be_a Time
1722
+ expect(video.scheduled_start_time).to be_a Time
1723
+ expect(video.scheduled_end_time).to be_a Time
1724
+ expect(video.concurrent_viewers).to be_nil
1725
+ end
1726
+ end
1727
+
1728
+ context 'given an unknown video' do
1729
+ let(:id) { 'not-a-video-id' }
1730
+
1731
+ it { expect{video.content_detail}.to raise_error Yt::Errors::NoItems }
1732
+ it { expect{video.snippet}.to raise_error Yt::Errors::NoItems }
1733
+ it { expect{video.rating}.to raise_error Yt::Errors::NoItems }
1734
+ it { expect{video.status}.to raise_error Yt::Errors::NoItems }
1735
+ it { expect{video.statistics_set}.to raise_error Yt::Errors::NoItems }
1736
+ it { expect{video.file_detail}.to raise_error Yt::Errors::NoItems }
1737
+ end
1738
+
1739
+ context 'given one of my own videos that I want to delete' do
1740
+ before(:all) { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: "Yt Test Delete Video #{rand}" }
1741
+ let(:id) { @tmp_video.id }
1742
+
1743
+ it { expect(video.delete).to be true }
1744
+ end
1745
+
1746
+ context 'given one of my own videos that I want to update' do
1747
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
1748
+ let!(:old_title) { video.title }
1749
+ let!(:old_privacy_status) { video.privacy_status }
1750
+ let(:update) { video.update attrs }
1751
+
1752
+ context 'given I update the title' do
1753
+ # NOTE: The use of UTF-8 characters is to test that we can pass up to
1754
+ # 50 characters, independently of their representation
1755
+ let(:attrs) { {title: "Yt Example Update Video #{rand} - ®•♡❥❦❧☙"} }
1756
+
1757
+ specify 'only updates the title' do
1758
+ expect(update).to be true
1759
+ expect(video.title).not_to eq old_title
1760
+ expect(video.privacy_status).to eq old_privacy_status
1761
+ end
1762
+ end
1763
+
1764
+ context 'given I update the description' do
1765
+ let!(:old_description) { video.description }
1766
+ let(:attrs) { {description: "Yt Example Description #{rand} - ®•♡❥❦❧☙"} }
1767
+
1768
+ specify 'only updates the description' do
1769
+ expect(update).to be true
1770
+ expect(video.description).not_to eq old_description
1771
+ expect(video.title).to eq old_title
1772
+ expect(video.privacy_status).to eq old_privacy_status
1773
+ end
1774
+ end
1775
+
1776
+ context 'given I update the tags' do
1777
+ let!(:old_tags) { video.tags }
1778
+ let(:attrs) { {tags: ["Yt Test Tag #{rand}"]} }
1779
+
1780
+ specify 'only updates the tag' do
1781
+ expect(update).to be true
1782
+ expect(video.tags).not_to eq old_tags
1783
+ expect(video.title).to eq old_title
1784
+ expect(video.privacy_status).to eq old_privacy_status
1785
+ end
1786
+ end
1787
+
1788
+ context 'given I update the category ID' do
1789
+ let!(:old_category_id) { video.category_id }
1790
+ let!(:new_category_id) { old_category_id == '22' ? '21' : '22' }
1791
+
1792
+ context 'passing the parameter in underscore syntax' do
1793
+ let(:attrs) { {category_id: new_category_id} }
1794
+
1795
+ specify 'only updates the category ID' do
1796
+ expect(update).to be true
1797
+ expect(video.category_id).not_to eq old_category_id
1798
+ expect(video.title).to eq old_title
1799
+ expect(video.privacy_status).to eq old_privacy_status
1800
+ end
1801
+ end
1802
+
1803
+ context 'passing the parameter in camel-case syntax' do
1804
+ let(:attrs) { {categoryId: new_category_id} }
1805
+
1806
+ specify 'only updates the category ID' do
1807
+ expect(update).to be true
1808
+ expect(video.category_id).not_to eq old_category_id
1809
+ end
1810
+ end
1811
+ end
1812
+
1813
+ context 'given I update title, description and/or tags using angle brackets' do
1814
+ let(:attrs) { {title: "Example Yt Test < >", description: '< >', tags: ['<tag>']} }
1815
+
1816
+ specify 'updates them replacing angle brackets with similar unicode characters accepted by YouTube' do
1817
+ expect(update).to be true
1818
+ expect(video.title).to eq 'Example Yt Test ‹ ›'
1819
+ expect(video.description).to eq '‹ ›'
1820
+ expect(video.tags).to eq ['‹tag›']
1821
+ end
1822
+ end
1823
+
1824
+ # note: 'scheduled' videos cannot be set to 'unlisted'
1825
+ context 'given I update the privacy status' do
1826
+ before { video.update publish_at: nil if video.scheduled? }
1827
+ let!(:new_privacy_status) { old_privacy_status == 'private' ? 'unlisted' : 'private' }
1828
+
1829
+ context 'passing the parameter in underscore syntax' do
1830
+ let(:attrs) { {privacy_status: new_privacy_status} }
1831
+
1832
+ specify 'only updates the privacy status' do
1833
+ expect(update).to be true
1834
+ expect(video.privacy_status).not_to eq old_privacy_status
1835
+ expect(video.title).to eq old_title
1836
+ end
1837
+ end
1838
+
1839
+ context 'passing the parameter in camel-case syntax' do
1840
+ let(:attrs) { {privacyStatus: new_privacy_status} }
1841
+
1842
+ specify 'only updates the privacy status' do
1843
+ expect(update).to be true
1844
+ expect(video.privacy_status).not_to eq old_privacy_status
1845
+ expect(video.title).to eq old_title
1846
+ end
1847
+ end
1848
+ end
1849
+
1850
+ context 'given I update the embeddable status' do
1851
+ let!(:old_embeddable) { video.embeddable? }
1852
+ let!(:new_embeddable) { !old_embeddable }
1853
+
1854
+ let(:attrs) { {embeddable: new_embeddable} }
1855
+
1856
+ # @note: This test is a reflection of another irrational behavior of
1857
+ # YouTube API. Although 'embeddable' can be passed as an 'update'
1858
+ # attribute according to the documentation, it simply does not work.
1859
+ # The day YouTube fixes it, then this test will finally fail and will
1860
+ # be removed, documenting how to update 'embeddable' too.
1861
+ # @see https://developers.google.com/youtube/v3/docs/videos/update
1862
+ # @see https://code.google.com/p/gdata-issues/issues/detail?id=4861
1863
+ specify 'does not update the embeddable status' do
1864
+ expect(update).to be true
1865
+ expect(video.embeddable?).to eq old_embeddable
1866
+ end
1867
+ end
1868
+
1869
+ context 'given I update the public stats viewable setting' do
1870
+ let!(:old_public_stats_viewable) { video.has_public_stats_viewable? }
1871
+ let!(:new_public_stats_viewable) { !old_public_stats_viewable }
1872
+
1873
+ context 'passing the parameter in underscore syntax' do
1874
+ let(:attrs) { {public_stats_viewable: new_public_stats_viewable} }
1875
+
1876
+ specify 'only updates the public stats viewable setting' do
1877
+ expect(update).to be true
1878
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
1879
+ expect(video.privacy_status).to eq old_privacy_status
1880
+ expect(video.title).to eq old_title
1881
+ end
1882
+ end
1883
+
1884
+ context 'passing the parameter in camel-case syntax' do
1885
+ let(:attrs) { {publicStatsViewable: new_public_stats_viewable} }
1886
+
1887
+ specify 'only updates the public stats viewable setting' do
1888
+ expect(update).to be true
1889
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
1890
+ expect(video.privacy_status).to eq old_privacy_status
1891
+ expect(video.title).to eq old_title
1892
+ end
1893
+ end
1894
+ end
1895
+
1896
+ it 'returns valid reports for video-related metrics' do
1897
+ # Some reports are only available to Content Owners.
1898
+ # See content owner test for more details about what the methods return.
1899
+ expect{video.views}.not_to raise_error
1900
+ expect{video.comments}.not_to raise_error
1901
+ expect{video.likes}.not_to raise_error
1902
+ expect{video.dislikes}.not_to raise_error
1903
+ expect{video.shares}.not_to raise_error
1904
+ expect{video.subscribers_gained}.not_to raise_error
1905
+ expect{video.subscribers_lost}.not_to raise_error
1906
+ expect{video.videos_added_to_playlists}.not_to raise_error
1907
+ expect{video.videos_removed_from_playlists}.not_to raise_error
1908
+ expect{video.estimated_minutes_watched}.not_to raise_error
1909
+ expect{video.average_view_duration}.not_to raise_error
1910
+ expect{video.average_view_percentage}.not_to raise_error
1911
+ expect{video.annotation_clicks}.not_to raise_error
1912
+ expect{video.annotation_click_through_rate}.not_to raise_error
1913
+ expect{video.annotation_close_rate}.not_to raise_error
1914
+ expect{video.viewer_percentage}.not_to raise_error
1915
+ expect{video.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
1916
+ expect{video.ad_impressions}.to raise_error Yt::Errors::Unauthorized
1917
+ expect{video.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
1918
+ expect{video.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
1919
+ expect{video.advertising_options_set}.to raise_error Yt::Errors::Forbidden
1920
+
1921
+ end
1922
+ end
1923
+
1924
+ # @note: This test is separated from the block above because, for some
1925
+ # undocumented reasons, if an existing video was private, then set to
1926
+ # unlisted, then set to private again, YouTube _sometimes_ raises a
1927
+ # 400 Error when trying to set the publishAt timestamp.
1928
+ # Therefore, just to test the updating of publishAt, we use a brand new
1929
+ # video (set to private), rather than reusing an existing one as above.
1930
+ context 'given one of my own *private* videos that I want to update' do
1931
+ before { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: old_title, privacy_status: old_privacy_status }
1932
+ let(:id) { @tmp_video.id }
1933
+ let!(:old_title) { "Yt Test Update publishAt Video #{rand}" }
1934
+ let!(:old_privacy_status) { 'private' }
1935
+ after { video.delete }
1936
+
1937
+ let!(:new_scheduled_at) { Yt::Timestamp.parse("#{rand(30) + 1} Jan 2020", Time.now) }
1938
+
1939
+ context 'passing the parameter in underscore syntax' do
1940
+ let(:attrs) { {publish_at: new_scheduled_at} }
1941
+
1942
+ specify 'only updates the timestamp to publish the video' do
1943
+ expect(video.update attrs).to be true
1944
+ expect(video.privacy_status).to eq old_privacy_status
1945
+ expect(video.title).to eq old_title
1946
+ # NOTE: This is another irrational behavior of YouTube API. In short,
1947
+ # the response of Video#update *does not* include the publishAt value
1948
+ # even if it exists. You need to call Video#list again to get it.
1949
+ video = Yt::Video.new id: id, auth: $account
1950
+ expect(video.scheduled_at).to eq new_scheduled_at
1951
+ # Setting a private (scheduled) video to private has no effect:
1952
+ expect(video.update privacy_status: 'private').to be true
1953
+ video = Yt::Video.new id: id, auth: $account
1954
+ expect(video.scheduled_at).to eq new_scheduled_at
1955
+ # Setting a private (scheduled) video to unlisted/public removes publishAt:
1956
+ expect(video.update privacy_status: 'unlisted').to be true
1957
+ video = Yt::Video.new id: id, auth: $account
1958
+ expect(video.scheduled_at).to be_nil
1959
+ end
1960
+ end
1961
+
1962
+ context 'passing the parameter in camel-case syntax' do
1963
+ let(:attrs) { {publishAt: new_scheduled_at} }
1964
+
1965
+ specify 'only updates the timestamp to publish the video' do
1966
+ expect(video.update attrs).to be true
1967
+ expect(video.scheduled_at).to eq new_scheduled_at
1968
+ expect(video.privacy_status).to eq old_privacy_status
1969
+ expect(video.title).to eq old_title
1970
+ end
1971
+ end
1972
+ end
1973
+
1974
+ # @note: This should somehow test that the thumbnail *changes*. However,
1975
+ # YouTube does not change the URL of the thumbnail even though the content
1976
+ # changes. A full test would have to *download* the thumbnails before and
1977
+ # after, and compare the files. For now, not raising error is enough.
1978
+ # Eventually, change to `expect{update}.to change{video.thumbnail_url}`
1979
+ context 'given one of my own videos for which I want to upload a thumbnail' do
1980
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
1981
+ let(:update) { video.upload_thumbnail path_or_url }
1982
+
1983
+ context 'given the path to a local JPG image file' do
1984
+ let(:path_or_url) { File.expand_path '../thumbnail.jpg', __FILE__ }
1985
+
1986
+ it { expect{update}.not_to raise_error }
1987
+ end
1988
+
1989
+ context 'given the path to a remote PNG image file' do
1990
+ let(:path_or_url) { 'https://bit.ly/yt_thumbnail' }
1991
+
1992
+ it { expect{update}.not_to raise_error }
1993
+ end
1994
+
1995
+ context 'given an invalid URL' do
1996
+ let(:path_or_url) { 'this-is-not-a-url' }
1997
+
1998
+ it { expect{update}.to raise_error Yt::Errors::RequestError }
1999
+ end
2000
+ end
2001
+
2002
+ # @note: This test is separated from the block above because YouTube only
2003
+ # returns file details for *some videos*: "The fileDetails object will
2004
+ # only be returned if the processingDetails.fileAvailability property
2005
+ # has a value of available.". Therefore, just to test fileDetails, we use a
2006
+ # different video that (for some unknown reason) is marked as 'available'.
2007
+ # Also note that I was not able to find a single video returning fileName,
2008
+ # therefore video.file_name is not returned by Yt, until it can be tested.
2009
+ # @see https://developers.google.com/youtube/v3/docs/videos#processingDetails.fileDetailsAvailability
2010
+ context 'given one of my own *available* videos' do
2011
+ let(:id) { 'yCmaOvUFhlI' }
2012
+
2013
+ it 'returns valid file details' do
2014
+ expect(video.file_size).to be_an Integer
2015
+ expect(video.file_type).to be_a String
2016
+ expect(video.container).to be_a String
2017
+ end
2018
+ end
2019
+ end
2020
+ # encoding: UTF-8
2021
+
2022
+ require 'spec_helper'
2023
+ require 'yt/models/video'
2024
+
2025
+ describe Yt::Video, :device_app do
2026
+ subject(:video) { Yt::Video.new id: id, auth: $account }
2027
+
2028
+ context 'given someone else’s video' do
2029
+ let(:id) { '9bZkp7q19f0' }
2030
+
2031
+ it { expect(video.content_detail).to be_a Yt::ContentDetail }
2032
+
2033
+ it 'returns valid metadata' do
2034
+ expect(video.title).to be_a String
2035
+ expect(video.description).to be_a String
2036
+ expect(video.thumbnail_url).to be_a String
2037
+ expect(video.published_at).to be_a Time
2038
+ expect(video.privacy_status).to be_a String
2039
+ expect(video.tags).to be_an Array
2040
+ expect(video.channel_id).to be_a String
2041
+ expect(video.channel_title).to be_a String
2042
+ expect(video.channel_url).to be_a String
2043
+ expect(video.category_id).to be_a String
2044
+ expect(video.live_broadcast_content).to be_a String
2045
+ expect(video.view_count).to be_an Integer
2046
+ expect(video.like_count).to be_an Integer
2047
+ expect(video.dislike_count).to be_an Integer
2048
+ expect(video.favorite_count).to be_an Integer
2049
+ expect(video.comment_count).to be_an Integer
2050
+ expect(video.duration).to be_an Integer
2051
+ expect(video.hd?).to be_in [true, false]
2052
+ expect(video.stereoscopic?).to be_in [true, false]
2053
+ expect(video.captioned?).to be_in [true, false]
2054
+ expect(video.licensed?).to be_in [true, false]
2055
+ expect(video.deleted?).to be_in [true, false]
2056
+ expect(video.failed?).to be_in [true, false]
2057
+ expect(video.processed?).to be_in [true, false]
2058
+ expect(video.rejected?).to be_in [true, false]
2059
+ expect(video.uploading?).to be_in [true, false]
2060
+ expect(video.uses_unsupported_codec?).to be_in [true, false]
2061
+ expect(video.has_failed_conversion?).to be_in [true, false]
2062
+ expect(video.empty?).to be_in [true, false]
2063
+ expect(video.invalid?).to be_in [true, false]
2064
+ expect(video.too_small?).to be_in [true, false]
2065
+ expect(video.aborted?).to be_in [true, false]
2066
+ expect(video.claimed?).to be_in [true, false]
2067
+ expect(video.infringes_copyright?).to be_in [true, false]
2068
+ expect(video.duplicate?).to be_in [true, false]
2069
+ expect(video.scheduled_at.class).to be_in [NilClass, Time]
2070
+ expect(video.scheduled?).to be_in [true, false]
2071
+ expect(video.too_long?).to be_in [true, false]
2072
+ expect(video.violates_terms_of_use?).to be_in [true, false]
2073
+ expect(video.inappropriate?).to be_in [true, false]
2074
+ expect(video.infringes_trademark?).to be_in [true, false]
2075
+ expect(video.belongs_to_closed_account?).to be_in [true, false]
2076
+ expect(video.belongs_to_suspended_account?).to be_in [true, false]
2077
+ expect(video.licensed_as_creative_commons?).to be_in [true, false]
2078
+ expect(video.licensed_as_standard_youtube?).to be_in [true, false]
2079
+ expect(video.has_public_stats_viewable?).to be_in [true, false]
2080
+ expect(video.embeddable?).to be_in [true, false]
2081
+ expect(video.actual_start_time).to be_nil
2082
+ expect(video.actual_end_time).to be_nil
2083
+ expect(video.scheduled_start_time).to be_nil
2084
+ expect(video.scheduled_end_time).to be_nil
2085
+ expect(video.concurrent_viewers).to be_nil
2086
+ expect(video.embed_html).to be_a String
2087
+ expect(video.category_title).to be_a String
2088
+ end
2089
+
2090
+ it { expect{video.update}.to fail }
2091
+ it { expect{video.delete}.to fail.with 'forbidden' }
2092
+
2093
+ context 'that I like' do
2094
+ before { video.like }
2095
+ it { expect(video).to be_liked }
2096
+ it { expect(video.dislike).to be true }
2097
+ end
2098
+
2099
+ context 'that I dislike' do
2100
+ before { video.dislike }
2101
+ it { expect(video).not_to be_liked }
2102
+ it { expect(video.like).to be true }
2103
+ end
2104
+
2105
+ context 'that I am indifferent to' do
2106
+ before { video.unlike }
2107
+ it { expect(video).not_to be_liked }
2108
+ it { expect(video.like).to be true }
2109
+ end
2110
+ end
2111
+
2112
+ context 'given someone else’s live video broadcast scheduled in the future' do
2113
+ let(:id) { 'PqzGI8gO_gk' }
2114
+
2115
+ it 'returns valid live streaming details' do
2116
+ expect(video.actual_start_time).to be_nil
2117
+ expect(video.actual_end_time).to be_nil
2118
+ expect(video.scheduled_start_time).to be_a Time
2119
+ expect(video.scheduled_end_time).to be_nil
2120
+ end
2121
+ end
2122
+
2123
+ context 'given someone else’s past live video broadcast' do
2124
+ let(:id) { 'COOM8_tOy6U' }
2125
+
2126
+ it 'returns valid live streaming details' do
2127
+ expect(video.actual_start_time).to be_a Time
2128
+ expect(video.actual_end_time).to be_a Time
2129
+ expect(video.scheduled_start_time).to be_a Time
2130
+ expect(video.scheduled_end_time).to be_a Time
2131
+ expect(video.concurrent_viewers).to be_nil
2132
+ end
2133
+ end
2134
+
2135
+ context 'given an unknown video' do
2136
+ let(:id) { 'not-a-video-id' }
2137
+
2138
+ it { expect{video.content_detail}.to raise_error Yt::Errors::NoItems }
2139
+ it { expect{video.snippet}.to raise_error Yt::Errors::NoItems }
2140
+ it { expect{video.rating}.to raise_error Yt::Errors::NoItems }
2141
+ it { expect{video.status}.to raise_error Yt::Errors::NoItems }
2142
+ it { expect{video.statistics_set}.to raise_error Yt::Errors::NoItems }
2143
+ it { expect{video.file_detail}.to raise_error Yt::Errors::NoItems }
2144
+ end
2145
+
2146
+ context 'given one of my own videos that I want to delete' do
2147
+ before(:all) { @tmp_video = $account.upload_video 'https://bit.ly/yt_test', title: "Yt Test Delete Video #{rand}" }
2148
+ let(:id) { @tmp_video.id }
2149
+
2150
+ it { expect(video.delete).to be true }
2151
+ end
2152
+
2153
+ context 'given one of my own videos that I want to update' do
2154
+ let(:id) { $account.videos.where(order: 'viewCount').first.id }
2155
+ let!(:old_title) { video.title }
2156
+ let!(:old_privacy_status) { video.privacy_status }
2157
+ let(:update) { video.update attrs }
2158
+
2159
+ context 'given I update the title' do
2160
+ # NOTE: The use of UTF-8 characters is to test that we can pass up to
2161
+ # 50 characters, independently of their representation
2162
+ let(:attrs) { {title: "Yt Example Update Video #{rand} - ®•♡❥❦❧☙"} }
2163
+
2164
+ specify 'only updates the title' do
2165
+ expect(update).to be true
2166
+ expect(video.title).not_to eq old_title
2167
+ expect(video.privacy_status).to eq old_privacy_status
2168
+ end
2169
+ end
2170
+
2171
+ context 'given I update the description' do
2172
+ let!(:old_description) { video.description }
2173
+ let(:attrs) { {description: "Yt Example Description #{rand} - ®•♡❥❦❧☙"} }
2174
+
2175
+ specify 'only updates the description' do
2176
+ expect(update).to be true
2177
+ expect(video.description).not_to eq old_description
2178
+ expect(video.title).to eq old_title
2179
+ expect(video.privacy_status).to eq old_privacy_status
2180
+ end
2181
+ end
2182
+
2183
+ context 'given I update the tags' do
2184
+ let!(:old_tags) { video.tags }
2185
+ let(:attrs) { {tags: ["Yt Test Tag #{rand}"]} }
2186
+
2187
+ specify 'only updates the tag' do
2188
+ expect(update).to be true
2189
+ expect(video.tags).not_to eq old_tags
2190
+ expect(video.title).to eq old_title
2191
+ expect(video.privacy_status).to eq old_privacy_status
2192
+ end
2193
+ end
2194
+
2195
+ context 'given I update the category ID' do
2196
+ let!(:old_category_id) { video.category_id }
2197
+ let!(:new_category_id) { old_category_id == '22' ? '21' : '22' }
2198
+
2199
+ context 'passing the parameter in underscore syntax' do
2200
+ let(:attrs) { {category_id: new_category_id} }
2201
+
2202
+ specify 'only updates the category ID' do
2203
+ expect(update).to be true
2204
+ expect(video.category_id).not_to eq old_category_id
2205
+ expect(video.title).to eq old_title
2206
+ expect(video.privacy_status).to eq old_privacy_status
2207
+ end
2208
+ end
2209
+
2210
+ context 'passing the parameter in camel-case syntax' do
2211
+ let(:attrs) { {categoryId: new_category_id} }
2212
+
2213
+ specify 'only updates the category ID' do
2214
+ expect(update).to be true
2215
+ expect(video.category_id).not_to eq old_category_id
2216
+ end
2217
+ end
2218
+ end
2219
+
2220
+ context 'given I update title, description and/or tags using angle brackets' do
2221
+ let(:attrs) { {title: "Example Yt Test < >", description: '< >', tags: ['<tag>']} }
2222
+
2223
+ specify 'updates them replacing angle brackets with similar unicode characters accepted by YouTube' do
2224
+ expect(update).to be true
2225
+ expect(video.title).to eq 'Example Yt Test ‹ ›'
2226
+ expect(video.description).to eq '‹ ›'
2227
+ expect(video.tags).to eq ['‹tag›']
2228
+ end
2229
+ end
2230
+
2231
+ # note: 'scheduled' videos cannot be set to 'unlisted'
2232
+ context 'given I update the privacy status' do
2233
+ before { video.update publish_at: nil if video.scheduled? }
2234
+ let!(:new_privacy_status) { old_privacy_status == 'private' ? 'unlisted' : 'private' }
2235
+
2236
+ context 'passing the parameter in underscore syntax' do
2237
+ let(:attrs) { {privacy_status: new_privacy_status} }
2238
+
2239
+ specify 'only updates the privacy status' do
2240
+ expect(update).to be true
2241
+ expect(video.privacy_status).not_to eq old_privacy_status
2242
+ expect(video.title).to eq old_title
2243
+ end
2244
+ end
2245
+
2246
+ context 'passing the parameter in camel-case syntax' do
2247
+ let(:attrs) { {privacyStatus: new_privacy_status} }
2248
+
2249
+ specify 'only updates the privacy status' do
2250
+ expect(update).to be true
2251
+ expect(video.privacy_status).not_to eq old_privacy_status
2252
+ expect(video.title).to eq old_title
2253
+ end
2254
+ end
2255
+ end
2256
+
2257
+ context 'given I update the embeddable status' do
2258
+ let!(:old_embeddable) { video.embeddable? }
2259
+ let!(:new_embeddable) { !old_embeddable }
309
2260
 
310
- expect{video.views_on 3.days.ago}.not_to raise_error
311
- expect{video.comments_on 3.days.ago}.not_to raise_error
312
- expect{video.likes_on 3.days.ago}.not_to raise_error
313
- expect{video.dislikes_on 3.days.ago}.not_to raise_error
314
- expect{video.shares_on 3.days.ago}.not_to raise_error
315
- expect{video.subscribers_gained_on 3.days.ago}.not_to raise_error
316
- expect{video.subscribers_lost_on 3.days.ago}.not_to raise_error
317
- expect{video.favorites_added_on 3.days.ago}.not_to raise_error
318
- expect{video.favorites_removed_on 3.days.ago}.not_to raise_error
319
- expect{video.videos_added_to_playlists_on 3.days.ago}.not_to raise_error
320
- expect{video.videos_removed_from_playlists_on 3.days.ago}.not_to raise_error
321
- expect{video.estimated_minutes_watched_on 3.days.ago}.not_to raise_error
322
- expect{video.average_view_duration_on 3.days.ago}.not_to raise_error
323
- expect{video.average_view_percentage_on 3.days.ago}.not_to raise_error
324
- expect{video.earnings_on 3.days.ago}.to raise_error Yt::Errors::Unauthorized
325
- expect{video.impressions_on 3.days.ago}.to raise_error Yt::Errors::Unauthorized
2261
+ let(:attrs) { {embeddable: new_embeddable} }
2262
+
2263
+ # @note: This test is a reflection of another irrational behavior of
2264
+ # YouTube API. Although 'embeddable' can be passed as an 'update'
2265
+ # attribute according to the documentation, it simply does not work.
2266
+ # The day YouTube fixes it, then this test will finally fail and will
2267
+ # be removed, documenting how to update 'embeddable' too.
2268
+ # @see https://developers.google.com/youtube/v3/docs/videos/update
2269
+ # @see https://code.google.com/p/gdata-issues/issues/detail?id=4861
2270
+ specify 'does not update the embeddable status' do
2271
+ expect(update).to be true
2272
+ expect(video.embeddable?).to eq old_embeddable
2273
+ end
2274
+ end
2275
+
2276
+ context 'given I update the public stats viewable setting' do
2277
+ let!(:old_public_stats_viewable) { video.has_public_stats_viewable? }
2278
+ let!(:new_public_stats_viewable) { !old_public_stats_viewable }
2279
+
2280
+ context 'passing the parameter in underscore syntax' do
2281
+ let(:attrs) { {public_stats_viewable: new_public_stats_viewable} }
2282
+
2283
+ specify 'only updates the public stats viewable setting' do
2284
+ expect(update).to be true
2285
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
2286
+ expect(video.privacy_status).to eq old_privacy_status
2287
+ expect(video.title).to eq old_title
2288
+ end
2289
+ end
2290
+
2291
+ context 'passing the parameter in camel-case syntax' do
2292
+ let(:attrs) { {publicStatsViewable: new_public_stats_viewable} }
2293
+
2294
+ specify 'only updates the public stats viewable setting' do
2295
+ expect(update).to be true
2296
+ expect(video.has_public_stats_viewable?).to eq new_public_stats_viewable
2297
+ expect(video.privacy_status).to eq old_privacy_status
2298
+ expect(video.title).to eq old_title
2299
+ end
2300
+ end
2301
+ end
2302
+
2303
+ it 'returns valid reports for video-related metrics' do
2304
+ # Some reports are only available to Content Owners.
2305
+ # See content owner test for more details about what the methods return.
2306
+ expect{video.views}.not_to raise_error
2307
+ expect{video.comments}.not_to raise_error
2308
+ expect{video.likes}.not_to raise_error
2309
+ expect{video.dislikes}.not_to raise_error
2310
+ expect{video.shares}.not_to raise_error
2311
+ expect{video.subscribers_gained}.not_to raise_error
2312
+ expect{video.subscribers_lost}.not_to raise_error
2313
+ expect{video.videos_added_to_playlists}.not_to raise_error
2314
+ expect{video.videos_removed_from_playlists}.not_to raise_error
2315
+ expect{video.estimated_minutes_watched}.not_to raise_error
2316
+ expect{video.average_view_duration}.not_to raise_error
2317
+ expect{video.average_view_percentage}.not_to raise_error
2318
+ expect{video.annotation_clicks}.not_to raise_error
2319
+ expect{video.annotation_click_through_rate}.not_to raise_error
2320
+ expect{video.annotation_close_rate}.not_to raise_error
2321
+ expect{video.viewer_percentage}.not_to raise_error
2322
+ expect{video.estimated_revenue}.to raise_error Yt::Errors::Unauthorized
2323
+ expect{video.ad_impressions}.to raise_error Yt::Errors::Unauthorized
2324
+ expect{video.monetized_playbacks}.to raise_error Yt::Errors::Unauthorized
2325
+ expect{video.playback_based_cpm}.to raise_error Yt::Errors::Unauthorized
2326
+ expect{video.advertising_options_set}.to raise_error Yt::Errors::Forbidden
326
2327
  end
327
2328
  end
328
2329