diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 0e34696377..b18208f183 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -1065,6 +1065,37 @@ function(VideoPlayer, HLS, _) { }).always(done); }); }); + + describe('HLS Primary Playback', function() { + beforeEach(function() { + spyOn(window.YT, 'Player').and.callThrough(); + }); + + afterEach(function() { + YT.Player.calls.reset(); + }); + + it('loads youtube if flag is disabled', function() { + state = jasmine.initializePlayer('video_all.html', { + prioritizeHls: false, + streams: '0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl' + }); + expect(state.config.prioritizeHls).toBeFalsy(); + expect(YT.Player).toHaveBeenCalled(); + expect(state.videoPlayer.player.hls).toBeUndefined(); + }); + + it('does not load youtube if flag is enabled', function() { + state = jasmine.initializePlayer('video_all.html', { + prioritizeHls: true, + streams: '0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl', + sources: ['/base/fixtures/test.mp4', '/base/fixtures/test.webm', '/base/fixtures/hls/hls.m3u8'] + }); + expect(state.config.prioritizeHls).toBeTruthy(); + expect(YT.Player).not.toHaveBeenCalled(); + expect(state.videoPlayer.player.hls).toBeDefined(); + }); + }); }); }); }(require, define)); diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index 996f3637b7..abc3c7ccb8 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -585,7 +585,8 @@ function(VideoPlayer, i18n, moment, _) { _setConfigurations(this); - if (!(_parseYouTubeIDs(this))) { + // If `prioritizeHls` is set to true than `hls` is the primary playback + if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) { // If we do not have YouTube ID's, try parsing HTML5 video sources. if (!_prepareHTML5Video(this)) { __dfd__.reject(); diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py index f1d886c4db..9299b8f616 100644 --- a/common/lib/xmodule/xmodule/video_module/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -22,9 +22,11 @@ from operator import itemgetter from pkg_resources import resource_string from django.conf import settings +from django.utils.functional import cached_property from lxml import etree from opaque_keys.edx.locator import AssetLocator from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag +from openedx.core.djangoapps.waffle_utils import WaffleFlagNamespace, CourseWaffleFlag from openedx.core.lib.cache_utils import memoize_in_request_cache from openedx.core.lib.license import LicenseMixin from xblock.completable import XBlockCompletionMode @@ -98,6 +100,11 @@ log = logging.getLogger(__name__) # `django.utils.translation.ugettext_noop` because Django cannot be imported in this file _ = lambda text: text +# Waffle flags namespace for videos +WAFFLE_VIDEOS_NAMESPACE = 'videos' + +# Waffle flag to enable/disable hls as primary playback +DEPRECATE_YOUTUBE = 'deprecate_youtube' EXPORT_IMPORT_COURSE_DIR = u'course' EXPORT_IMPORT_STATIC_DIR = u'static' @@ -182,6 +189,36 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, sorted_languages = OrderedDict(sorted_languages) return track_url, transcript_language, sorted_languages + @cached_property + def youtube_deprecated(self): + """ + Return True if youtube is deprecated and hls as primary playback is enabled else False + """ + # Return False if `hls` playback feature is disabled. + if not HLSPlaybackEnabledFlag.feature_enabled(self.location.course_key): + return False + + # check if hls as primary playback is enabled for this course + return CourseWaffleFlag( + WaffleFlagNamespace(name=WAFFLE_VIDEOS_NAMESPACE), + DEPRECATE_YOUTUBE + ).is_enabled(self.location.course_key) + + def prioritize_hls(self, youtube_streams, html5_sources): + """ + Decide whether hls can be prioritized as primary playback or not. + + If both the youtube and hls sources are present then make decision on flag + If only either youtube or hls is present then play whichever is present + """ + yt_present = bool(youtube_streams.strip()) + hls_present = any(source for source in html5_sources if source.strip().endswith('.m3u8')) + + if yt_present and hls_present: + return self.youtube_deprecated + + return False + def get_html(self): track_status = (self.download_track and self.track) @@ -363,6 +400,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, # this user, based on what was recorded the last time we saw the # user, and defaulting to True. 'recordedYoutubeIsAvailable': self.youtube_is_available, + 'prioritizeHls': self.prioritize_hls(self.youtube_streams, sources), } bumperize(self) diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index a0b1799bdf..607043b413 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -4,36 +4,38 @@ Video xmodule tests in mongo. """ import json +import shutil from collections import OrderedDict +from tempfile import mkdtemp from uuid import uuid4 -from tempfile import mkdtemp -import shutil import ddt from django.conf import settings from django.core.files import File from django.core.files.base import ContentFile from django.test import TestCase from django.test.utils import override_settings -from fs.osfs import OSFS -from fs.path import combine from edxval.api import ( ValCannotCreateError, ValVideoNotFoundError, - create_video_transcript, create_or_update_video_transcript, create_profile, create_video, + create_video_transcript, get_video_info, get_video_transcript, get_video_transcript_data ) from edxval.utils import create_file_in_fs +from fs.osfs import OSFS +from fs.path import combine from lxml import etree from mock import MagicMock, Mock, patch from path import Path as path from openedx.core.lib.tests import attr +from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel +from waffle.testutils import override_flag from xmodule.contentstore.content import StaticContent from xmodule.exceptions import NotFoundError from xmodule.modulestore import ModuleStoreEnum @@ -43,7 +45,12 @@ from xmodule.tests.test_import import DummySystem from xmodule.tests.test_video import VideoDescriptorTestBase, instantiate_descriptor from xmodule.video_module import VideoDescriptor, bumper_utils, rewrite_video_url, video_utils from xmodule.video_module.transcripts_utils import Transcript, save_to_store, subs_filename -from xmodule.video_module.video_module import EXPORT_IMPORT_STATIC_DIR, EXPORT_IMPORT_COURSE_DIR +from xmodule.video_module.video_module import ( + EXPORT_IMPORT_COURSE_DIR, + EXPORT_IMPORT_STATIC_DIR, + DEPRECATE_YOUTUBE, + WAFFLE_VIDEOS_NAMESPACE, +) from xmodule.x_module import STUDENT_VIEW from .helpers import BaseTestXmodule @@ -55,6 +62,8 @@ MODULESTORES = { ModuleStoreEnum.Type.split: TEST_DATA_SPLIT_MODULESTORE, } +DEPRECATE_YOUTUBE_FLAG = '{}.{}'.format(WAFFLE_VIDEOS_NAMESPACE, DEPRECATE_YOUTUBE) + TRANSCRIPT_FILE_SRT_DATA = u""" 1 00:00:14,370 --> 00:00:16,530 @@ -116,6 +125,7 @@ class TestVideoYouTube(TestVideo): 'completionEnabled': False, 'completionPercentage': 0.95, 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), + 'prioritizeHls': False, })), 'track': None, 'transcript_download_format': u'srt', @@ -197,6 +207,7 @@ class TestVideoNonYouTube(TestVideo): 'completionEnabled': False, 'completionPercentage': 0.95, 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), + 'prioritizeHls': False, })), 'track': None, 'transcript_download_format': u'srt', @@ -219,6 +230,7 @@ class TestGetHtmlMethod(BaseTestXmodule): ''' Make sure that `get_html` works correctly. ''' + maxDiff = None CATEGORY = "video" DATA = SOURCE_XML METADATA = {} @@ -254,6 +266,7 @@ class TestGetHtmlMethod(BaseTestXmodule): 'completionEnabled': False, 'completionPercentage': 0.95, 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), + 'prioritizeHls': False, }) def get_handler_url(self, handler, suffix): @@ -986,6 +999,70 @@ class TestGetHtmlMethod(BaseTestXmodule): self.assertIn("\'poster\': \'null\'", context) + @patch('xmodule.video_module.video_module.HLSPlaybackEnabledFlag.feature_enabled', Mock(return_value=False)) + def test_hls_primary_playback_on_toggling_hls_feature(self): + """ + Verify that `prioritize_hls` is set to `False` if `HLSPlaybackEnabledFlag` is disabled. + """ + video_xml = '' + self.initialize_module(data=video_xml) + context = self.item_descriptor.render(STUDENT_VIEW).content + self.assertIn('"prioritizeHls": false', context) + + @ddt.data( + { + 'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.on, + 'waffle_enabled': False, + 'youtube': '3_yD_cEKoCk', + 'hls': ['https://hls.com/hls.m3u8'], + 'result': 'true' + }, + { + 'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.on, + 'waffle_enabled': False, + 'youtube': '', + 'hls': ['https://hls.com/hls.m3u8'], + 'result': 'false' + }, + { + 'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.on, + 'waffle_enabled': False, + 'youtube': '', + 'hls': [], + 'result': 'false' + }, + { + 'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.on, + 'waffle_enabled': False, + 'youtube': '3_yD_cEKoCk', + 'hls': [], + 'result': 'false' + }, + { + 'course_override': WaffleFlagCourseOverrideModel.ALL_CHOICES.off, + 'waffle_enabled': True, + 'youtube': '3_yD_cEKoCk', + 'hls': ['https://hls.com/hls.m3u8'], + 'result': 'false' + }, + ) + @patch('xmodule.video_module.video_module.HLSPlaybackEnabledFlag.feature_enabled', Mock(return_value=True)) + def test_deprecate_youtube_course_waffle_flag(self, data): + """ + Tests various combinations of a `prioritize_hls` flag being set in waffle and overridden for a course. + """ + metadata = { + 'html5_sources': ['http://youtu.be/3_yD_cEKoCk.mp4'] + data['hls'], + } + video_xml = ''.format( + data['youtube'] + ) + with patch.object(WaffleFlagCourseOverrideModel, 'override_value', return_value=data['course_override']): + with override_flag(DEPRECATE_YOUTUBE_FLAG, active=data['waffle_enabled']): + self.initialize_module(data=video_xml, metadata=metadata) + context = self.item_descriptor.render(STUDENT_VIEW).content + self.assertIn('"prioritizeHls": {}'.format(data['result']), context) + @attr(shard=7) @ddt.ddt @@ -2085,6 +2162,7 @@ class TestVideoWithBumper(TestVideo): 'completionEnabled': False, 'completionPercentage': 0.95, 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), + 'prioritizeHls': False, })), 'track': None, 'transcript_download_format': u'srt', @@ -2107,6 +2185,7 @@ class TestAutoAdvanceVideo(TestVideo): """ Tests the server side of video auto-advance. """ + maxDiff = None CATEGORY = "video" METADATA = {} # Use temporary FEATURES in this test without affecting the original @@ -2160,6 +2239,7 @@ class TestAutoAdvanceVideo(TestVideo): 'completionEnabled': False, 'completionPercentage': 0.95, 'publishCompletionUrl': self.get_handler_url('publish_completion', ''), + 'prioritizeHls': False, })), 'track': None, 'transcript_download_format': u'srt',