Bug 1300653 - Update youtube puppeteer to store snapshots of state to prevent racing. r=maja_zf draft
authorBryce Van Dyk <bvandyk@mozilla.com>
Wed, 21 Sep 2016 15:38:41 +1200
changeset 415815 99aac08fd86d41e7fa3df9b00604dd583ca27bf8
parent 415814 a25a9a45c8dced9439360b9664b1d768100ed2be
child 531709 283be0e24a3e2df4b204c3339dd7dab6e5692c89
push id29978
push userbvandyk@mozilla.com
push dateWed, 21 Sep 2016 04:11:30 +0000
reviewersmaja_zf
bugs1300653
milestone51.0a1
Bug 1300653 - Update youtube puppeteer to store snapshots of state to prevent racing. r=maja_zf This is follow up work to the VideoPuppeteer changes that have it take snapshots to prevent racing. For this work the motivations are the same: prevent racing by querying a stable snapshot of video state, rather than making sequential JS requests to the browser between which video state may change. Much of the YouTubePuppeteer has been made internal, so the class can encapsulate its snapshotting. The property methods have been rolled into the snapshotted data named tuple to make it clear they're derived from snapshotted data. A number of broken parts of the code have been removed or reworked: - Disabling autoplay was not working and has been removed. This is partially addressed by using embedded URLs (in another commit) -- embedded videos do not play next video automatically. However, there may be merit in reinstating a working version of this in future if possible - particularly for videos that can't be embedded, which we have some of in our tests. - Ad skipping was not working. The getOption('ad', 'displaystate') JS call appears to always report an ad is not skippable even if it is. Code related to skipping ads has been removed for now, and ads are waited out. This may also be something worth revisiting if a working implementation is possible. *** Review feedback: update YT puppeteer to use more concise calling conventions, compatibility with changes to VideoPuppeteer. MozReview-Commit-ID: CCxf9ItFYtl
dom/media/test/external/external_media_tests/media_utils/youtube_puppeteer.py
dom/media/test/external/external_media_tests/playback/youtube/test_basic_playback.py
dom/media/test/external/external_media_tests/playback/youtube/test_prefs.py
--- a/dom/media/test/external/external_media_tests/media_utils/youtube_puppeteer.py
+++ b/dom/media/test/external/external_media_tests/media_utils/youtube_puppeteer.py
@@ -1,12 +1,12 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
-
+from collections import namedtuple
 from time import sleep
 import re
 from json import loads
 
 from marionette import Marionette
 from marionette_driver import By, expected, Wait
 from marionette_driver.errors import TimeoutException, NoSuchElementException
 from video_puppeteer import VideoPuppeteer, VideoException
@@ -19,385 +19,184 @@ class YouTubePuppeteer(VideoPuppeteer):
 
     Can be used with youtube videos or youtube videos at embedded URLS. E.g.
     both https://www.youtube.com/watch?v=AbAACm1IQE0 and
     https://www.youtube.com/embed/AbAACm1IQE0 should work.
 
     Using an embedded video has the advantage of not auto-playing more videos
     while a test is running.
 
+    Compared to video puppeteer, this class has the benefit of accessing the
+    youtube player object as well as the video element. The YT player will
+    report information for the underlying video even if an add is playing (as
+    opposed to the video element behaviour, which will report on whatever
+    is play at the time of query), and can also report if an ad is playing.
+
     Partial reference: https://developers.google.com/youtube/iframe_api_reference.
     This reference is useful for site-specific features such as interacting
     with ads, or accessing YouTube's debug data.
     """
 
+    _player_var_script = (
+        'var player_duration = arguments[1].wrappedJSObject.getDuration();'
+        'var player_current_time = '
+        'arguments[1].wrappedJSObject.getCurrentTime();'
+        'var player_playback_quality = '
+        'arguments[1].wrappedJSObject.getPlaybackQuality();'
+        'var player_movie_id = '
+        'arguments[1].wrappedJSObject.getVideoData()["video_id"];'
+        'var player_movie_title = '
+        'arguments[1].wrappedJSObject.getVideoData()["title"];'
+        'var player_url = '
+        'arguments[1].wrappedJSObject.getVideoUrl();'
+        'var player_state = '
+        'arguments[1].wrappedJSObject.getPlayerState();'
+        'var player_ad_state = arguments[1].wrappedJSObject.getAdState();'
+        'var player_breaks_count = '
+        'arguments[1].wrappedJSObject.getOption("ad", "breakscount");'
+    )
+    """
+    A string containing JS that will assign player state to
+    variables. This is similar to `_video_var_script` from
+    `VideoPuppeteer`. See `_video_var_script` for more information on the
+    motivation for this method.
+
+    This script assigns a subset of the vars used later by the
+    `_yt_state_named_tuple` function. Please see that functions
+    documentation for further information on these variables.
+    """
+
     _yt_player_state = {
         'UNSTARTED': -1,
         'ENDED': 0,
         'PLAYING': 1,
         'PAUSED': 2,
         'BUFFERING': 3,
         'CUED': 5
     }
     _yt_player_state_name = {v: k for k, v in _yt_player_state.items()}
     _time_pattern = re.compile('(?P<minute>\d+):(?P<second>\d+)')
 
-    def __init__(self, marionette, url, **kwargs):
+    def __init__(self, marionette, url, autostart=True, **kwargs):
         self.player = None
+        self._last_seen_player_state = None
         super(YouTubePuppeteer,
               self).__init__(marionette, url,
                              video_selector='.html5-video-player video',
+                             autostart=False,
                              **kwargs)
         wait = Wait(self.marionette, timeout=30)
         with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
             verbose_until(wait, self,
                           expected.element_present(By.CLASS_NAME,
                                                    'html5-video-player'))
             self.player = self.marionette.find_element(By.CLASS_NAME,
                                                        'html5-video-player')
             self.marionette.execute_script("log('.html5-video-player "
                                            "element obtained');")
         # When an ad is playing, self.player_duration indicates the duration
         # of the spliced-in ad stream, not the duration of the main video, so
         # we attempt to skip the ad first.
         for attempt in range(5):
             sleep(1)
             self.process_ad()
-            if (self.ad_inactive and self._last_seen_video_state.duration and not
-                    self.player_buffering):
+            if (self._last_seen_player_state.player_ad_inactive and
+                    self._last_seen_video_state.duration and not
+                    self._last_seen_player_state.player_buffering):
                 break
         self._update_expected_duration()
+        if autostart:
+            self.start()
 
     def player_play(self):
         """
         Play via YouTube API.
         """
-        self.execute_yt_script('arguments[1].wrappedJSObject.playVideo();')
+        self._execute_yt_script('arguments[1].wrappedJSObject.playVideo();')
 
     def player_pause(self):
         """
         Pause via YouTube API.
         """
-        self.execute_yt_script('arguments[1].wrappedJSObject.pauseVideo();')
-
-    @property
-    def player_duration(self):
-        """
+        self._execute_yt_script('arguments[1].wrappedJSObject.pauseVideo();')
 
-        :return: Duration in seconds via YouTube API.
-        """
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.getDuration();')
-
-    @property
-    def player_current_time(self):
-        """
-
-        :return: Current time in seconds via YouTube API.
+    def _player_measure_progress(self):
         """
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.getCurrentTime();')
+        Determine player progress. Refreshes state.
 
-    @property
-    def player_remaining_time(self):
-        """
-
-        :return: Remaining time in seconds via YouTube API.
+        :return: Playback progress in seconds via YouTube API with snapshots.
         """
-        return self.expected_duration - self.player_current_time
-
-    def player_measure_progress(self):
-        """
-
-        :return: Playback progress in seconds via YouTube API.
-        """
-        initial = self.player_current_time
+        self._refresh_state()
+        initial = self._last_seen_player_state.player_current_time
         sleep(1)
-        return self.player_current_time - initial
+        self._refresh_state()
+        return self._last_seen_player_state.player_current_time - initial
 
     def _get_player_debug_dict(self):
-        text = self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.getDebugText();')
+        text = self._execute_yt_script('return arguments[1].'
+                                       'wrappedJSObject.getDebugText();')
         if text:
             try:
                 return loads(text)
             except ValueError:
                 self.marionette.log('Error loading json: DebugText',
                                     level='DEBUG')
 
-    def execute_yt_script(self, script):
+    def _execute_yt_script(self, script):
         """
         Execute JS script in content context with access to video element and
         YouTube .html5-video-player element.
 
         :param script: script to be executed.
 
         :return: value returned by script
         """
         with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
             return self.marionette.execute_script(script,
                                                   script_args=[self.video,
                                                                self.player])
 
-    @property
-    def playback_quality(self):
-        """
-        Please see https://developers.google.com/youtube/iframe_api_reference#Playback_quality
-        for valid values.
-
-        :return: A string with a valid value returned via YouTube.
-        """
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.getPlaybackQuality();')
-
-    @property
-    def movie_id(self):
-        """
-
-        :return: The string that is the YouTube identifier.
-        """
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.'
-                                      'getVideoData()["video_id"];')
-
-    @property
-    def movie_title(self):
-        """
-
-        :return: The title of the movie.
-        """
-        title = self.execute_yt_script('return arguments[1].'
-                                       'wrappedJSObject.'
-                                       'getVideoData()["title"];')
-        # title may include non-ascii characters; replace them to avoid
-        # UnicodeEncodeError in string formatting for log messages
-        return title.encode('ascii', 'replace')
-
-    @property
-    def player_url(self):
-        """
-
-        :return: The YouTube URL for the currently playing video.
-        """
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.getVideoUrl();')
-
-    @property
-    def player_state(self):
-        """
-
-        :return: The YouTube state of the video. See
-         https://developers.google.com/youtube/iframe_api_reference#getPlayerState
-         for valid values.
-        """
-        state = self.execute_yt_script('return arguments[1].'
-                                       'wrappedJSObject.getPlayerState();')
-        return state
-
-    @property
-    def player_unstarted(self):
-        """
-        This and the following properties are based on the
-        player.getPlayerState() call
-        (https://developers.google.com/youtube/iframe_api_reference#Playback_status)
-
-        :return: True if the video has not yet started.
-        """
-        return self.player_state == self._yt_player_state['UNSTARTED']
-
-    @property
-    def player_ended(self):
-        """
-
-        :return: True if the video playback has ended.
-        """
-        return self.player_state == self._yt_player_state['ENDED']
-
-    @property
-    def player_playing(self):
-        """
-
-        :return: True if the video is playing.
-        """
-        return self.player_state == self._yt_player_state['PLAYING']
-
-    @property
-    def player_paused(self):
-        """
-
-        :return: True if the video is paused.
-        """
-        return self.player_state == self._yt_player_state['PAUSED']
-
-    @property
-    def player_buffering(self):
-        """
-
-        :return: True if the video is currently buffering.
-        """
-        return self.player_state == self._yt_player_state['BUFFERING']
-
-    @property
-    def player_cued(self):
-        """
-
-        :return: True if the video is cued.
-        """
-        return self.player_state == self._yt_player_state['CUED']
-
-    @property
-    def ad_state(self):
-        """
-        Get state of current ad.
-
-        :return: Returns one of the constants listed in
-         https://developers.google.com/youtube/iframe_api_reference#Playback_status
-         for an ad.
-
-        """
-        # Note: ad_state is sometimes not accurate, due to some sort of lag?
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.getAdState();')
-
-    @property
-    def ad_format(self):
-        """
-        When ad is not playing, ad_format is False.
-
-        :return: integer representing ad format, or False
-        """
-        state = self.get_ad_displaystate()
-        ad_format = False
-        if state:
-            with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
-                ad_format = self.marionette.execute_script("""
-                    return arguments[0].adFormat;""",
-                    script_args=[state])
-        return ad_format
-
-    @property
-    def ad_skippable(self):
-        """
-
-        :return: True if the current ad is skippable.
-        """
-        state = self.get_ad_displaystate()
-        skippable = False
-        if state:
-            with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
-                skippable = self.marionette.execute_script("""
-                    return arguments[0].skippable;""",
-                    script_args=[state])
-        return skippable
-
-    def get_ad_displaystate(self):
-        # may return None
-        return self.execute_yt_script('return arguments[1].'
-                                      'wrappedJSObject.'
-                                      'getOption("ad", "displaystate");')
-
-    @property
-    def breaks_count(self):
-        """
-
-        :return: Number of upcoming ad breaks.
-        """
-        breaks = self.execute_yt_script('return arguments[1].'
-                                        'wrappedJSObject.'
-                                        'getOption("ad", "breakscount")')
-        # if video is not associated with any ads, breaks will be null
-        return breaks or 0
-
-    @property
-    def ad_inactive(self):
-        """
-
-        :return: True if the current ad is inactive.
-        """
-        return (self.ad_ended or
-                self.ad_state == self._yt_player_state['UNSTARTED'])
-
-    @property
-    def ad_playing(self):
-        """
-
-        :return: True if an ad is playing.
-        """
-        return self.ad_state == self._yt_player_state['PLAYING']
-
-    @property
-    def ad_ended(self):
-        """
-
-        :return: True if the current ad has ended.
-        """
-        return self.ad_state == self._yt_player_state['ENDED']
+    def _check_if_ad_ended(self):
+        self._refresh_state()
+        return self._last_seen_player_state.player_ad_ended
 
     def process_ad(self):
         """
-        Try to skip this ad. If not, wait for this ad to finish.
+        Wait for this ad to finish. Refreshes state.
         """
-        if self.attempt_ad_skip() or self.ad_inactive:
+        self._refresh_state()
+        if self._last_seen_player_state.player_ad_inactive:
             return
-        ad_timeout = (self.search_ad_duration() or 30) + 5
+        ad_timeout = (self._search_ad_duration() or 30) + 5
         wait = Wait(self, timeout=ad_timeout, interval=1)
         try:
             self.marionette.log('process_ad: waiting {} s for ad'
                                 .format(ad_timeout))
             verbose_until(wait,
                           self,
-                          lambda y: y.ad_ended,
+                          YouTubePuppeteer._check_if_ad_ended,
                           "Check if ad ended")
         except TimeoutException:
             self.marionette.log('Waiting for ad to end timed out',
                                 level='WARNING')
 
-    def attempt_ad_skip(self):
-        """
-        Attempt to skip ad by clicking on skip-add button.
-
-        :return: True if clicking of ad-skip button occurred.
+    def _search_ad_duration(self):
         """
-        if self.ad_playing:
-            self.marionette.log('Waiting while ad plays')
-            sleep(10)
-        else:
-            # no ad playing
-            return False
-        if self.ad_skippable:
-            selector = '.html5-video-player .videoAdUiSkipContainer'
-            wait = Wait(self.marionette, timeout=30)
-            try:
-                with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
-                    wait.until(expected.element_displayed(By.CSS_SELECTOR,
-                                                          selector))
-                    ad_button = self.marionette.find_element(By.CSS_SELECTOR,
-                                                             selector)
-                    ad_button.click()
-                    self.marionette.log('Skipped ad.')
-                    return True
-            except (TimeoutException, NoSuchElementException):
-                self.marionette.log('Could not obtain '
-                                    'element: {}'.format(selector),
-                                    level='WARNING')
-        return False
-
-    def search_ad_duration(self):
-        """
+        Try and determine ad duration. Refreshes state.
 
         :return: ad duration in seconds, if currently displayed in player
         """
-        if not (self.ad_playing or self.player_measure_progress() == 0):
+        self._refresh_state()
+        if not (self._last_seen_player_state.player_ad_playing or
+                self._player_measure_progress() == 0):
             return None
-        # If the ad is not Flash...
-        if (self.ad_playing and
-                self._last_seen_video_state.video_src.startswith('mediasource') and
+        if (self._last_seen_player_state.player_ad_playing and
                 self._last_seen_video_state.duration):
             return self._last_seen_video_state.duration
-        selector = '.html5-media-player .videoAdUiAttribution'
+        selector = '.html5-video-player .videoAdUiAttribution'
         wait = Wait(self.marionette, timeout=5)
         try:
             with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
                 wait.until(expected.element_present(By.CSS_SELECTOR,
                                                     selector))
                 countdown = self.marionette.find_element(By.CSS_SELECTOR,
                                                          selector)
                 ad_time = self._time_pattern.search(countdown.text)
@@ -406,160 +205,284 @@ class YouTubePuppeteer(VideoPuppeteer):
                     ad_seconds = int(ad_time.group('second'))
                     return 60 * ad_minutes + ad_seconds
         except (TimeoutException, NoSuchElementException):
             self.marionette.log('Could not obtain '
                                 'element: {}'.format(selector),
                                 level='WARNING')
         return None
 
-    @property
-    def player_stalled(self):
+    def _player_stalled(self):
         """
+        Checks if the player has stalled. Refreshes state.
 
         :return: True if playback is not making progress for 4-9 seconds. This
          excludes ad breaks. Note that the player might just be busy with
          buffering due to a slow network.
         """
 
         # `current_time` stands still while ad is playing
         def condition():
             # no ad is playing and current_time stands still
-            return (not self.ad_playing and
+            return (not self._last_seen_player_state.player_ad_playing and
                     self._measure_progress() < 0.1 and
-                    self.player_measure_progress() < 0.1 and
-                    (self.player_playing or self.player_buffering))
+                    self._player_measure_progress() < 0.1 and
+                    (self._last_seen_player_state.player_playing or
+                     self._last_seen_player_state.player_buffering))
 
         if condition():
             sleep(2)
-            if self.player_buffering:
+            self._refresh_state()
+            if self._last_seen_player_state.player_buffering:
                 sleep(5)
+                self._refresh_state()
             return condition()
         else:
             return False
 
-    def deactivate_autoplay(self):
+    @staticmethod
+    def _yt_state_named_tuple():
         """
-        Attempt to turn off autoplay.
+        Create a named tuple class that can be used to store state snapshots
+        of the wrapped youtube player. The fields in the tuple should be used
+        as follows:
 
-        :return: True if successful.
+        player_duration: the duration as fetched from the wrapped player.
+        player_current_time: the current playback time as fetched from the
+        wrapped player.
+        player_remaining_time: the remaining time as calculated based on the
+        puppeteers expected time and the players current time.
+        player_playback_quality: the playback quality as fetched from the
+        wrapped player. See:
+        https://developers.google.com/youtube/js_api_reference#Playback_quality
+        player_movie_id: the movie id fetched from the wrapped player.
+        player_movie_title: the title fetched from the wrapped player.
+        player_url: the self reported url fetched from the wrapped player.
+        player_state: the current state of playback as fetch from the wrapped
+        player. See:
+        https://developers.google.com/youtube/js_api_reference#Playback_status
+        player_unstarted, player_ended, player_playing, player_paused,
+        player_buffering, and player_cued: these are all shortcuts to the
+        player state, only one should be true at any time.
+        player_ad_state: as player_state, but reports for the current ad.
+        player_ad_state, player_ad_inactive, player_ad_playing, and
+        player_ad_ended: these are all shortcuts to the ad state, only one
+        should be true at any time.
+        player_breaks_count: the number of ads as fetched from the wrapped
+        player. This includes both played and unplayed ads, and includes
+        streaming ads as well as pop up ads.
+
+        :return: A 'player_state_info' named tuple class.
         """
-        element_id = 'autoplay-checkbox'
-        mn = self.marionette
-        wait = Wait(mn, timeout=10)
+        return namedtuple('player_state_info',
+                          ['player_duration',
+                           'player_current_time',
+                           'player_remaining_time',
+                           'player_playback_quality',
+                           'player_movie_id',
+                           'player_movie_title',
+                           'player_url',
+                           'player_state',
+                           'player_unstarted',
+                           'player_ended',
+                           'player_playing',
+                           'player_paused',
+                           'player_buffering',
+                           'player_cued',
+                           'player_ad_state',
+                           'player_ad_inactive',
+                           'player_ad_playing',
+                           'player_ad_ended',
+                           'player_breaks_count'
+                           ])
+
+    def _create_player_state_info(self, **player_state_info_kwargs):
+        """
+        Create an instance of the state info named tuple. This function
+        expects a dictionary containing the following keys:
+        player_duration, player_current_time, player_playback_quality,
+        player_movie_id, player_movie_title, player_url, player_state,
+        player_ad_state, and player_breaks_count.
 
-        def get_status(el):
-            script = 'return arguments[0].wrappedJSObject.checked'
-            return mn.execute_script(script, script_args=[el])
+        For more information on the above keys and their values see
+        `_yt_state_named_tuple`.
+
+        :return: A named tuple 'yt_state_info', derived from arguments and
+        state information from the puppeteer.
+        """
+        player_state_info_kwargs['player_remaining_time'] = (
+            self.expected_duration -
+            player_state_info_kwargs['player_current_time'])
+        # Calculate player state convenience info
+        player_state = player_state_info_kwargs['player_state']
+        player_state_info_kwargs['player_unstarted'] = (
+            player_state == self._yt_player_state['UNSTARTED'])
+        player_state_info_kwargs['player_ended'] = (
+            player_state == self._yt_player_state['ENDED'])
+        player_state_info_kwargs['player_playing'] = (
+            player_state == self._yt_player_state['PLAYING'])
+        player_state_info_kwargs['player_paused'] = (
+            player_state == self._yt_player_state['PAUSED'])
+        player_state_info_kwargs['player_buffering'] = (
+            player_state == self._yt_player_state['BUFFERING'])
+        player_state_info_kwargs['player_cued'] = (
+            player_state == self._yt_player_state['CUED'])
+        # Calculate ad state convenience info
+        player_ad_state = player_state_info_kwargs['player_ad_state']
+        player_state_info_kwargs['player_ad_inactive'] = (
+            player_ad_state == self._yt_player_state['UNSTARTED'])
+        player_state_info_kwargs['player_ad_playing'] = (
+            player_ad_state == self._yt_player_state['PLAYING'])
+        player_state_info_kwargs['player_ad_ended'] = (
+            player_ad_state == self._yt_player_state['ENDED'])
+        # Create player snapshot
+        state_info = self._yt_state_named_tuple()
+        return state_info(**player_state_info_kwargs)
 
-        try:
-            with mn.using_context(Marionette.CONTEXT_CONTENT):
-                # the width, height of the element are 0, so it's not visible
-                wait.until(expected.element_present(By.ID, element_id))
-                checkbox = mn.find_element(By.ID, element_id)
+    @property
+    def _fetch_state_script(self):
+        if not self._fetch_state_script_string:
+            self._fetch_state_script_string = (
+                self._video_var_script +
+                self._player_var_script +
+                'return ['
+                'currentTime,'
+                'duration,'
+                '[played.length, timeRanges],'
+                'totalFrames,'
+                'droppedFrames,'
+                'corruptedFrames,'
+                'player_duration,'
+                'player_current_time,'
+                'player_playback_quality,'
+                'player_movie_id,'
+                'player_movie_title,'
+                'player_url,'
+                'player_state,'
+                'player_ad_state,'
+                'player_breaks_count];')
+        return self._fetch_state_script_string
+
+    def _refresh_state(self):
+        """
+        Refresh the snapshot of the underlying video and player state. We do
+        this allin one so that the state doesn't change in between queries.
+
+        We also store information that can be derived from the snapshotted
+        information, such as lag. This is stored in the last seen state to
+        stress that it's based on the snapshot.
+        """
+        values = self._execute_yt_script(self._fetch_state_script)
+        video_keys = ['current_time', 'duration', 'raw_time_ranges',
+                      'total_frames', 'dropped_frames', 'corrupted_frames']
+        player_keys = ['player_duration', 'player_current_time',
+                       'player_playback_quality', 'player_movie_id',
+                       'player_movie_title', 'player_url', 'player_state',
+                       'player_ad_state', 'player_breaks_count']
+        # Get video state
+        self._last_seen_video_state = (
+            self._create_video_state_info(**dict(
+                zip(video_keys, values[:len(video_keys)]))))
+        # Get player state
+        self._last_seen_player_state = (
+            self._create_player_state_info(**dict(
+                zip(player_keys, values[-len(player_keys):]))))
+
+    def mse_enabled(self):
+        """
+        Check if the video source indicates mse usage for current video.
+        Refreshes state.
+
+        :return: True if MSE is being used, False if not.
+        """
+        self._refresh_state()
+        return self._last_seen_video_state.video_src.startswith('blob')
+
+    def playback_started(self):
+        """
+        Check whether playback has started. Refreshes state.
 
-                # Note: in some videos, due to late-loading of sidebar ads, the
-                # button is rerendered after sidebar ads appear & the autoplay
-                # pref resets to "on". In other words, if you click too early,
-                # the pref might get reset moments later.
-                sleep(1)
-                if get_status(checkbox):
-                    mn.execute_script('return arguments[0].'
-                                      'wrappedJSObject.click()',
-                                      script_args=[checkbox])
-                    self.marionette.log('Toggled autoplay.')
-                autoplay = get_status(checkbox)
-                self.marionette.log('Autoplay is {}'.format(autoplay))
-                return (autoplay is not None) and (not autoplay)
-        except (NoSuchElementException, TimeoutException):
+        :return: True if play back has started, False if not.
+        """
+        self._refresh_state()
+        # usually, ad is playing during initial buffering
+        if (self._last_seen_player_state.player_playing or
+                self._last_seen_player_state.player_buffering):
+            return True
+        if (self._last_seen_video_state.current_time > 0 or
+                self._last_seen_player_state.player_current_time > 0):
+            return True
+        return False
+
+    def playback_done(self):
+        """
+        Check whether playback is done. Refreshes state.
+
+        :return: True if play back has ended, False if not.
+        """
+        # in case ad plays at end of video
+        self._refresh_state()
+        if self._last_seen_player_state.player_ad_playing:
             return False
+        return (self._last_seen_player_state.player_ended or
+                self._last_seen_player_state.player_remaining_time < 1)
+
+    def wait_for_almost_done(self, final_piece=120):
+        """
+        Allow the given video to play until only `final_piece` seconds remain,
+        skipping ads mid-way as much as possible.
+        `final_piece` should be short enough to not be interrupted by an ad.
+
+        Depending on the length of the video, check the ad status every 10-30
+        seconds, skip an active ad if possible.
+
+        This call refreshes state.
+
+        :param final_piece: The length in seconds of the desired remaining time
+        to wait until.
+        """
+        self._refresh_state()
+        rest = 10
+        duration = remaining_time = self.expected_duration
+        if duration < final_piece:
+            # video is short so don't attempt to skip more ads
+            return duration
+        elif duration > 600:
+            # for videos that are longer than 10 minutes
+            # wait longer between checks
+            rest = duration / 50
+
+        while remaining_time > final_piece:
+            if self._player_stalled():
+                if self._last_seen_player_state.player_buffering:
+                    # fall back on timeout in 'wait' call that comes after this
+                    # in test function
+                    self.marionette.log('Buffering and no playback progress.')
+                    break
+                else:
+                    message = '\n'.join(['Playback stalled', str(self)])
+                    raise VideoException(message)
+            if self._last_seen_player_state.player_breaks_count > 0:
+                self.process_ad()
+            if remaining_time > 1.5 * rest:
+                sleep(rest)
+            else:
+                sleep(rest / 2)
+            # TODO during an ad, remaining_time will be based on ad's current_time
+            # rather than current_time of target video
+            remaining_time = self._last_seen_player_state.player_remaining_time
+        return remaining_time
 
     def __str__(self):
         messages = [super(YouTubePuppeteer, self).__str__()]
-        if self.player:
-            player_state = self._yt_player_state_name[self.player_state]
-            ad_state = self._yt_player_state_name[self.ad_state]
-            messages += [
-                '.html5-media-player: {',
-                '\tvideo id: {},'.format(self.movie_id),
-                '\tvideo_title: {}'.format(self.movie_title),
-                '\tcurrent_state: {},'.format(player_state),
-                '\tad_state: {},'.format(ad_state),
-                '\tplayback_quality: {},'.format(self.playback_quality),
-                '\tcurrent_time: {},'.format(self.player_current_time),
-                '\tduration: {},'.format(self.player_duration),
-                '}'
-            ]
-        else:
+        if not self.player:
             messages += ['\t.html5-media-player: None']
+            return '\n'.join(messages)
+        if not self._last_seen_player_state:
+            messages += ['\t.html5-media-player: No last seen state']
+            return '\n'.join(messages)
+        messages += ['.html5-media-player: {']
+        for field in self._last_seen_player_state._fields:
+            messages += [('\t{}: {}'
+                          .format(field, getattr(self._last_seen_player_state,
+                                                 field)))]
+        messages += '}'
         return '\n'.join(messages)
-
-
-def playback_started(yt):
-    """
-    Check whether playback has started.
-
-    :param yt: YouTubePuppeteer
-    """
-    # usually, ad is playing during initial buffering
-    if (yt.player_state in
-            [yt._yt_player_state['PLAYING'],
-             yt._yt_player_state['BUFFERING']]):
-        return True
-    if yt.current_time > 0 or yt.player_current_time > 0:
-        return True
-    return False
-
-
-def playback_done(yt):
-    """
-    Check whether playback is done, skipping ads if possible.
-
-    :param yt: YouTubePuppeteer
-    """
-    # in case ad plays at end of video
-    if yt.ad_state == yt._yt_player_state['PLAYING']:
-        yt.attempt_ad_skip()
-        return False
-    return yt.player_ended or yt.player_remaining_time < 1
-
-
-def wait_for_almost_done(yt, final_piece=120):
-    """
-    Allow the given video to play until only `final_piece` seconds remain,
-    skipping ads mid-way as much as possible.
-    `final_piece` should be short enough to not be interrupted by an ad.
-
-    Depending on the length of the video, check the ad status every 10-30
-    seconds, skip an active ad if possible.
-
-    :param yt: YouTubePuppeteer
-    """
-    rest = 10
-    duration = remaining_time = yt.expected_duration
-    if duration < final_piece:
-        # video is short so don't attempt to skip more ads
-        return duration
-    elif duration > 600:
-        # for videos that are longer than 10 minutes
-        # wait longer between checks
-        rest = duration/50
-
-    while remaining_time > final_piece:
-        if yt.player_stalled:
-            if yt.player_buffering:
-                # fall back on timeout in 'wait' call that comes after this
-                # in test function
-                yt.marionette.log('Buffering and no playback progress.')
-                break
-            else:
-                message = '\n'.join(['Playback stalled', str(yt)])
-                raise VideoException(message)
-        if yt.breaks_count > 0:
-            yt.process_ad()
-        if remaining_time > 1.5 * rest:
-            sleep(rest)
-        else:
-            sleep(rest/2)
-        # TODO during an ad, remaining_time will be based on ad's current_time
-        # rather than current_time of target video
-        remaining_time = yt.player_remaining_time
-    return remaining_time
--- a/dom/media/test/external/external_media_tests/playback/youtube/test_basic_playback.py
+++ b/dom/media/test/external/external_media_tests/playback/youtube/test_basic_playback.py
@@ -4,75 +4,69 @@
 
 from marionette import Marionette
 from marionette_driver import Wait
 from marionette_driver.errors import TimeoutException
 
 from external_media_tests.utils import verbose_until
 from external_media_harness.testcase import MediaTestCase
 from external_media_tests.media_utils.video_puppeteer import VideoException
-from external_media_tests.media_utils.youtube_puppeteer import (
-    YouTubePuppeteer,
-    wait_for_almost_done,
-    playback_done
-)
+from external_media_tests.media_utils.youtube_puppeteer import YouTubePuppeteer
 
 
 class TestBasicYouTubePlayback(MediaTestCase):
     def test_mse_is_enabled_by_default(self):
         with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
             youtube = YouTubePuppeteer(self.marionette, self.video_urls[0],
                                        timeout=60)
             wait = Wait(youtube,
                         timeout=min(300, youtube.expected_duration * 1.3),
                         interval=1)
             try:
                 verbose_until(wait, youtube,
-                              lambda y: y._last_seen_video_state.
-                              video_src.startswith('blob'),
+                              YouTubePuppeteer.mse_enabled,
                               "Failed to find 'blob' in video src url.")
             except TimeoutException as e:
                 raise self.failureException(e)
 
     def test_video_playing_in_one_tab(self):
         with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
             for url in self.video_urls:
                 self.logger.info(url)
                 youtube = YouTubePuppeteer(self.marionette, url)
                 self.logger.info('Expected duration: {}'
                                  .format(youtube.expected_duration))
-                youtube.deactivate_autoplay()
 
                 final_piece = 60
                 try:
-                    time_left = wait_for_almost_done(youtube,
-                                                     final_piece=final_piece)
+                    time_left = youtube.wait_for_almost_done(
+                        final_piece=final_piece)
                 except VideoException as e:
                     raise self.failureException(e)
                 duration = abs(youtube.expected_duration) + 1
                 if duration > 1:
                     self.logger.info('Almost done: {} - {} seconds left.'
-                                     .format(youtube.movie_id, time_left))
+                                     .format(url, time_left))
                     if time_left > final_piece:
                         self.marionette.log('time_left greater than '
                                             'final_piece - {}'
                                             .format(time_left),
                                             level='WARNING')
                         self.save_screenshot()
                 else:
                     self.marionette.log('Duration close to 0 - {}'
                                         .format(youtube),
                                         level='WARNING')
                     self.save_screenshot()
                 try:
                     verbose_until(Wait(youtube,
                                        timeout=max(100, time_left) * 1.3,
                                        interval=1),
                                   youtube,
-                                  playback_done)
+                                  YouTubePuppeteer.playback_done)
                 except TimeoutException as e:
                     raise self.failureException(e)
 
     def test_playback_starts(self):
         with self.marionette.using_context(Marionette.CONTEXT_CONTENT):
             for url in self.video_urls:
                 try:
                     YouTubePuppeteer(self.marionette, url, timeout=60)
--- a/dom/media/test/external/external_media_tests/playback/youtube/test_prefs.py
+++ b/dom/media/test/external/external_media_tests/playback/youtube/test_prefs.py
@@ -14,36 +14,33 @@ class TestMediaSourcePrefs(MediaTestCase
         MediaTestCase.setUp(self)
         self.test_urls = self.video_urls[:2]
         self.max_timeout = 60
 
     def tearDown(self):
         MediaTestCase.tearDown(self)
 
     def test_mse_prefs(self):
-        """ 'mediasource' should only be used if MSE prefs are enabled."""
+        """ mediasource should only be used if MSE prefs are enabled."""
         self.set_mse_enabled_prefs(False)
-        self.check_src('http', self.test_urls[0])
+        self.check_mse_src(False, self.test_urls[0])
 
         self.set_mse_enabled_prefs(True)
-        self.check_src('mediasource', self.test_urls[0])
+        self.check_mse_src(True, self.test_urls[0])
 
     def set_mse_enabled_prefs(self, value):
         with self.marionette.using_context('chrome'):
             self.prefs.set_pref('media.mediasource.enabled', value)
             self.prefs.set_pref('media.mediasource.mp4.enabled', value)
+            self.prefs.set_pref('media.mediasource.webm.enabled', value)
 
-    def check_src(self, src_type, url):
-        # Why wait to check src until initial ad is done playing?
-        # - src attribute in video element is sometimes null during ad playback
-        # - many ads still don't use MSE even if main video does
+    def check_mse_src(self, mse_expected, url):
         with self.marionette.using_context('content'):
             youtube = YouTubePuppeteer(self.marionette, url)
-            youtube.attempt_ad_skip()
             wait = Wait(youtube,
                         timeout=min(self.max_timeout,
-                                    youtube.player_duration * 1.3),
+                                    youtube.expected_duration * 1.3),
                         interval=1)
 
             def cond(y):
-                return y.video_src.startswith(src_type)
+                return y.mse_enabled == mse_expected
 
             verbose_until(wait, youtube, cond)