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