1# This Source Code Form is subject to the terms of the Mozilla Public 2# License, v. 2.0. If a copy of the MPL was not distributed with this 3# file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5import re 6 7from collections import namedtuple 8from json import loads 9from time import sleep 10 11from marionette_driver import By, expected, Wait 12from marionette_driver.errors import TimeoutException, NoSuchElementException 13from marionette_harness import Marionette 14 15from video_puppeteer import VideoPuppeteer, VideoException 16from external_media_tests.utils import verbose_until 17 18 19class YouTubePuppeteer(VideoPuppeteer): 20 """ 21 Wrapper around a YouTube .html5-video-player element. 22 23 Can be used with youtube videos or youtube videos at embedded URLS. E.g. 24 both https://www.youtube.com/watch?v=AbAACm1IQE0 and 25 https://www.youtube.com/embed/AbAACm1IQE0 should work. 26 27 Using an embedded video has the advantage of not auto-playing more videos 28 while a test is running. 29 30 Compared to video puppeteer, this class has the benefit of accessing the 31 youtube player object as well as the video element. The YT player will 32 report information for the underlying video even if an add is playing (as 33 opposed to the video element behaviour, which will report on whatever 34 is play at the time of query), and can also report if an ad is playing. 35 36 Partial reference: https://developers.google.com/youtube/iframe_api_reference. 37 This reference is useful for site-specific features such as interacting 38 with ads, or accessing YouTube's debug data. 39 """ 40 41 _player_var_script = ( 42 'var player_duration = arguments[1].wrappedJSObject.getDuration();' 43 'var player_current_time = ' 44 'arguments[1].wrappedJSObject.getCurrentTime();' 45 'var player_playback_quality = ' 46 'arguments[1].wrappedJSObject.getPlaybackQuality();' 47 'var player_movie_id = ' 48 'arguments[1].wrappedJSObject.getVideoData()["video_id"];' 49 'var player_movie_title = ' 50 'arguments[1].wrappedJSObject.getVideoData()["title"];' 51 'var player_url = ' 52 'arguments[1].wrappedJSObject.getVideoUrl();' 53 'var player_state = ' 54 'arguments[1].wrappedJSObject.getPlayerState();' 55 'var player_ad_state = arguments[1].wrappedJSObject.getAdState();' 56 'var player_breaks_count = ' 57 'arguments[1].wrappedJSObject.getOption("ad", "breakscount");' 58 ) 59 """ 60 A string containing JS that will assign player state to 61 variables. This is similar to `_video_var_script` from 62 `VideoPuppeteer`. See `_video_var_script` for more information on the 63 motivation for this method. 64 65 This script assigns a subset of the vars used later by the 66 `_yt_state_named_tuple` function. Please see that functions 67 documentation for further information on these variables. 68 """ 69 70 _yt_player_state = { 71 'UNSTARTED': -1, 72 'ENDED': 0, 73 'PLAYING': 1, 74 'PAUSED': 2, 75 'BUFFERING': 3, 76 'CUED': 5 77 } 78 _yt_player_state_name = {v: k for k, v in _yt_player_state.items()} 79 _time_pattern = re.compile('(?P<minute>\d+):(?P<second>\d+)') 80 81 def __init__(self, marionette, url, autostart=True, **kwargs): 82 self.player = None 83 self._last_seen_player_state = None 84 super(YouTubePuppeteer, 85 self).__init__(marionette, url, 86 video_selector='.html5-video-player video', 87 autostart=False, 88 **kwargs) 89 wait = Wait(self.marionette, timeout=30) 90 with self.marionette.using_context(Marionette.CONTEXT_CONTENT): 91 verbose_until(wait, self, 92 expected.element_present(By.CLASS_NAME, 93 'html5-video-player')) 94 self.player = self.marionette.find_element(By.CLASS_NAME, 95 'html5-video-player') 96 self.marionette.execute_script("log('.html5-video-player " 97 "element obtained');") 98 # When an ad is playing, self.player_duration indicates the duration 99 # of the spliced-in ad stream, not the duration of the main video, so 100 # we attempt to skip the ad first. 101 for attempt in range(5): 102 sleep(1) 103 self.process_ad() 104 if (self._last_seen_player_state.player_ad_inactive and 105 self._last_seen_video_state.duration and not 106 self._last_seen_player_state.player_buffering): 107 break 108 self._update_expected_duration() 109 if autostart: 110 self.start() 111 112 def player_play(self): 113 """ 114 Play via YouTube API. 115 """ 116 self._execute_yt_script('arguments[1].wrappedJSObject.playVideo();') 117 118 def player_pause(self): 119 """ 120 Pause via YouTube API. 121 """ 122 self._execute_yt_script('arguments[1].wrappedJSObject.pauseVideo();') 123 124 def _player_measure_progress(self): 125 """ 126 Determine player progress. Refreshes state. 127 128 :return: Playback progress in seconds via YouTube API with snapshots. 129 """ 130 self._refresh_state() 131 initial = self._last_seen_player_state.player_current_time 132 sleep(1) 133 self._refresh_state() 134 return self._last_seen_player_state.player_current_time - initial 135 136 def _get_player_debug_dict(self): 137 text = self._execute_yt_script('return arguments[1].' 138 'wrappedJSObject.getDebugText();') 139 if text: 140 try: 141 return loads(text) 142 except ValueError: 143 self.marionette.log('Error loading json: DebugText', 144 level='DEBUG') 145 146 def _execute_yt_script(self, script): 147 """ 148 Execute JS script in content context with access to video element and 149 YouTube .html5-video-player element. 150 151 :param script: script to be executed. 152 153 :return: value returned by script 154 """ 155 with self.marionette.using_context(Marionette.CONTEXT_CONTENT): 156 return self.marionette.execute_script(script, 157 script_args=[self.video, 158 self.player]) 159 160 def _check_if_ad_ended(self): 161 self._refresh_state() 162 return self._last_seen_player_state.player_ad_ended 163 164 def process_ad(self): 165 """ 166 Wait for this ad to finish. Refreshes state. 167 """ 168 self._refresh_state() 169 if self._last_seen_player_state.player_ad_inactive: 170 return 171 ad_timeout = (self._search_ad_duration() or 30) + 5 172 wait = Wait(self, timeout=ad_timeout, interval=1) 173 try: 174 self.marionette.log('process_ad: waiting {} s for ad' 175 .format(ad_timeout)) 176 verbose_until(wait, 177 self, 178 YouTubePuppeteer._check_if_ad_ended, 179 "Check if ad ended") 180 except TimeoutException: 181 self.marionette.log('Waiting for ad to end timed out', 182 level='WARNING') 183 184 def _search_ad_duration(self): 185 """ 186 Try and determine ad duration. Refreshes state. 187 188 :return: ad duration in seconds, if currently displayed in player 189 """ 190 self._refresh_state() 191 if not (self._last_seen_player_state.player_ad_playing or 192 self._player_measure_progress() == 0): 193 return None 194 if (self._last_seen_player_state.player_ad_playing and 195 self._last_seen_video_state.duration): 196 return self._last_seen_video_state.duration 197 selector = '.html5-video-player .videoAdUiAttribution' 198 wait = Wait(self.marionette, timeout=5) 199 try: 200 with self.marionette.using_context(Marionette.CONTEXT_CONTENT): 201 wait.until(expected.element_present(By.CSS_SELECTOR, 202 selector)) 203 countdown = self.marionette.find_element(By.CSS_SELECTOR, 204 selector) 205 ad_time = self._time_pattern.search(countdown.text) 206 if ad_time: 207 ad_minutes = int(ad_time.group('minute')) 208 ad_seconds = int(ad_time.group('second')) 209 return 60 * ad_minutes + ad_seconds 210 except (TimeoutException, NoSuchElementException): 211 self.marionette.log('Could not obtain ' 212 'element: {}'.format(selector), 213 level='WARNING') 214 return None 215 216 def _player_stalled(self): 217 """ 218 Checks if the player has stalled. Refreshes state. 219 220 :return: True if playback is not making progress for 4-9 seconds. This 221 excludes ad breaks. Note that the player might just be busy with 222 buffering due to a slow network. 223 """ 224 225 # `current_time` stands still while ad is playing 226 def condition(): 227 # no ad is playing and current_time stands still 228 return (not self._last_seen_player_state.player_ad_playing and 229 self._measure_progress() < 0.1 and 230 self._player_measure_progress() < 0.1 and 231 (self._last_seen_player_state.player_playing or 232 self._last_seen_player_state.player_buffering)) 233 234 if condition(): 235 sleep(2) 236 self._refresh_state() 237 if self._last_seen_player_state.player_buffering: 238 sleep(5) 239 self._refresh_state() 240 return condition() 241 else: 242 return False 243 244 @staticmethod 245 def _yt_state_named_tuple(): 246 """ 247 Create a named tuple class that can be used to store state snapshots 248 of the wrapped youtube player. The fields in the tuple should be used 249 as follows: 250 251 player_duration: the duration as fetched from the wrapped player. 252 player_current_time: the current playback time as fetched from the 253 wrapped player. 254 player_remaining_time: the remaining time as calculated based on the 255 puppeteers expected time and the players current time. 256 player_playback_quality: the playback quality as fetched from the 257 wrapped player. See: 258 https://developers.google.com/youtube/js_api_reference#Playback_quality 259 player_movie_id: the movie id fetched from the wrapped player. 260 player_movie_title: the title fetched from the wrapped player. 261 player_url: the self reported url fetched from the wrapped player. 262 player_state: the current state of playback as fetch from the wrapped 263 player. See: 264 https://developers.google.com/youtube/js_api_reference#Playback_status 265 player_unstarted, player_ended, player_playing, player_paused, 266 player_buffering, and player_cued: these are all shortcuts to the 267 player state, only one should be true at any time. 268 player_ad_state: as player_state, but reports for the current ad. 269 player_ad_state, player_ad_inactive, player_ad_playing, and 270 player_ad_ended: these are all shortcuts to the ad state, only one 271 should be true at any time. 272 player_breaks_count: the number of ads as fetched from the wrapped 273 player. This includes both played and unplayed ads, and includes 274 streaming ads as well as pop up ads. 275 276 :return: A 'player_state_info' named tuple class. 277 """ 278 return namedtuple('player_state_info', 279 ['player_duration', 280 'player_current_time', 281 'player_remaining_time', 282 'player_playback_quality', 283 'player_movie_id', 284 'player_movie_title', 285 'player_url', 286 'player_state', 287 'player_unstarted', 288 'player_ended', 289 'player_playing', 290 'player_paused', 291 'player_buffering', 292 'player_cued', 293 'player_ad_state', 294 'player_ad_inactive', 295 'player_ad_playing', 296 'player_ad_ended', 297 'player_breaks_count' 298 ]) 299 300 def _create_player_state_info(self, **player_state_info_kwargs): 301 """ 302 Create an instance of the state info named tuple. This function 303 expects a dictionary containing the following keys: 304 player_duration, player_current_time, player_playback_quality, 305 player_movie_id, player_movie_title, player_url, player_state, 306 player_ad_state, and player_breaks_count. 307 308 For more information on the above keys and their values see 309 `_yt_state_named_tuple`. 310 311 :return: A named tuple 'yt_state_info', derived from arguments and 312 state information from the puppeteer. 313 """ 314 player_state_info_kwargs['player_remaining_time'] = ( 315 self.expected_duration - 316 player_state_info_kwargs['player_current_time']) 317 # Calculate player state convenience info 318 player_state = player_state_info_kwargs['player_state'] 319 player_state_info_kwargs['player_unstarted'] = ( 320 player_state == self._yt_player_state['UNSTARTED']) 321 player_state_info_kwargs['player_ended'] = ( 322 player_state == self._yt_player_state['ENDED']) 323 player_state_info_kwargs['player_playing'] = ( 324 player_state == self._yt_player_state['PLAYING']) 325 player_state_info_kwargs['player_paused'] = ( 326 player_state == self._yt_player_state['PAUSED']) 327 player_state_info_kwargs['player_buffering'] = ( 328 player_state == self._yt_player_state['BUFFERING']) 329 player_state_info_kwargs['player_cued'] = ( 330 player_state == self._yt_player_state['CUED']) 331 # Calculate ad state convenience info 332 player_ad_state = player_state_info_kwargs['player_ad_state'] 333 player_state_info_kwargs['player_ad_inactive'] = ( 334 player_ad_state == self._yt_player_state['UNSTARTED']) 335 player_state_info_kwargs['player_ad_playing'] = ( 336 player_ad_state == self._yt_player_state['PLAYING']) 337 player_state_info_kwargs['player_ad_ended'] = ( 338 player_ad_state == self._yt_player_state['ENDED']) 339 # Create player snapshot 340 state_info = self._yt_state_named_tuple() 341 return state_info(**player_state_info_kwargs) 342 343 @property 344 def _fetch_state_script(self): 345 if not self._fetch_state_script_string: 346 self._fetch_state_script_string = ( 347 self._video_var_script + 348 self._player_var_script + 349 'return [' 350 'baseURI,' 351 'currentTime,' 352 'duration,' 353 '[buffered.length, bufferedRanges],' 354 '[played.length, playedRanges],' 355 'totalFrames,' 356 'droppedFrames,' 357 'corruptedFrames,' 358 'player_duration,' 359 'player_current_time,' 360 'player_playback_quality,' 361 'player_movie_id,' 362 'player_movie_title,' 363 'player_url,' 364 'player_state,' 365 'player_ad_state,' 366 'player_breaks_count];') 367 return self._fetch_state_script_string 368 369 def _refresh_state(self): 370 """ 371 Refresh the snapshot of the underlying video and player state. We do 372 this allin one so that the state doesn't change in between queries. 373 374 We also store information that can be derived from the snapshotted 375 information, such as lag. This is stored in the last seen state to 376 stress that it's based on the snapshot. 377 """ 378 values = self._execute_yt_script(self._fetch_state_script) 379 video_keys = ['base_uri', 'current_time', 'duration', 380 'raw_buffered_ranges', 'raw_played_ranges', 381 'total_frames', 'dropped_frames', 'corrupted_frames'] 382 player_keys = ['player_duration', 'player_current_time', 383 'player_playback_quality', 'player_movie_id', 384 'player_movie_title', 'player_url', 'player_state', 385 'player_ad_state', 'player_breaks_count'] 386 # Get video state 387 self._last_seen_video_state = ( 388 self._create_video_state_info(**dict( 389 zip(video_keys, values[:len(video_keys)])))) 390 # Get player state 391 self._last_seen_player_state = ( 392 self._create_player_state_info(**dict( 393 zip(player_keys, values[-len(player_keys):])))) 394 395 def mse_enabled(self): 396 """ 397 Check if the video source indicates mse usage for current video. 398 Refreshes state. 399 400 :return: True if MSE is being used, False if not. 401 """ 402 self._refresh_state() 403 return self._last_seen_video_state.video_src.startswith('blob') 404 405 def playback_started(self): 406 """ 407 Check whether playback has started. Refreshes state. 408 409 :return: True if play back has started, False if not. 410 """ 411 self._refresh_state() 412 # usually, ad is playing during initial buffering 413 if (self._last_seen_player_state.player_playing or 414 self._last_seen_player_state.player_buffering): 415 return True 416 if (self._last_seen_video_state.current_time > 0 or 417 self._last_seen_player_state.player_current_time > 0): 418 return True 419 return False 420 421 def playback_done(self): 422 """ 423 Check whether playback is done. Refreshes state. 424 425 :return: True if play back has ended, False if not. 426 """ 427 # in case ad plays at end of video 428 self._refresh_state() 429 if self._last_seen_player_state.player_ad_playing: 430 return False 431 return (self._last_seen_player_state.player_ended or 432 self._last_seen_player_state.player_remaining_time < 1) 433 434 def wait_for_almost_done(self, final_piece=120): 435 """ 436 Allow the given video to play until only `final_piece` seconds remain, 437 skipping ads mid-way as much as possible. 438 `final_piece` should be short enough to not be interrupted by an ad. 439 440 Depending on the length of the video, check the ad status every 10-30 441 seconds, skip an active ad if possible. 442 443 This call refreshes state. 444 445 :param final_piece: The length in seconds of the desired remaining time 446 to wait until. 447 """ 448 self._refresh_state() 449 rest = 10 450 duration = remaining_time = self.expected_duration 451 if duration < final_piece: 452 # video is short so don't attempt to skip more ads 453 return duration 454 elif duration > 600: 455 # for videos that are longer than 10 minutes 456 # wait longer between checks 457 rest = duration / 50 458 459 while remaining_time > final_piece: 460 if self._player_stalled(): 461 if self._last_seen_player_state.player_buffering: 462 # fall back on timeout in 'wait' call that comes after this 463 # in test function 464 self.marionette.log('Buffering and no playback progress.') 465 break 466 else: 467 message = '\n'.join(['Playback stalled', str(self)]) 468 raise VideoException(message) 469 if self._last_seen_player_state.player_breaks_count > 0: 470 self.process_ad() 471 if remaining_time > 1.5 * rest: 472 sleep(rest) 473 else: 474 sleep(rest / 2) 475 # TODO during an ad, remaining_time will be based on ad's current_time 476 # rather than current_time of target video 477 remaining_time = self._last_seen_player_state.player_remaining_time 478 return remaining_time 479 480 def __str__(self): 481 messages = [super(YouTubePuppeteer, self).__str__()] 482 if not self.player: 483 messages += ['\t.html5-media-player: None'] 484 return '\n'.join(messages) 485 if not self._last_seen_player_state: 486 messages += ['\t.html5-media-player: No last seen state'] 487 return '\n'.join(messages) 488 messages += ['.html5-media-player: {'] 489 for field in self._last_seen_player_state._fields: 490 # For compatibility with different test environments we force ascii 491 field_ascii = ( 492 unicode(getattr(self._last_seen_player_state, field)) 493 .encode('ascii', 'replace')) 494 messages += [('\t{}: {}'.format(field, field_ascii))] 495 messages += '}' 496 return '\n'.join(messages) 497