diff --git a/lms/djangoapps/course_api/blocks/transformers/blocks_api.py b/lms/djangoapps/course_api/blocks/transformers/blocks_api.py index 2d2345b0a8..81dc388aa1 100644 --- a/lms/djangoapps/course_api/blocks/transformers/blocks_api.py +++ b/lms/djangoapps/course_api/blocks/transformers/blocks_api.py @@ -11,6 +11,7 @@ from .extra_fields import ExtraFieldsTransformer from .navigation import BlockNavigationTransformer from .student_view import StudentViewTransformer from .video_urls import VideoBlockURLTransformer +from .video_stream_priority import VideoBlockStreamPriorityTransformer class BlocksAPITransformer(BlockStructureTransformer): @@ -23,6 +24,7 @@ class BlocksAPITransformer(BlockStructureTransformer): BlockCountsTransformer BlockDepthTransformer BlockNavigationTransformer + VideoBlockStreamPriorityTransformer ExtraFieldsTransformer Note: @@ -72,4 +74,5 @@ class BlocksAPITransformer(BlockStructureTransformer): BlockDepthTransformer(self.depth).transform(usage_info, block_structure) BlockNavigationTransformer(self.nav_depth).transform(usage_info, block_structure) VideoBlockURLTransformer().transform(usage_info, block_structure) + VideoBlockStreamPriorityTransformer().transform(usage_info, block_structure) ExtraFieldsTransformer().transform(usage_info, block_structure) diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_video_stream_priority.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_video_stream_priority.py new file mode 100644 index 0000000000..8a2c1a6019 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_video_stream_priority.py @@ -0,0 +1,178 @@ +""" +Tests for VideoBlockStreamPriorityTransformer. +""" + + +from unittest import mock + +from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ToyCourseFactory # lint-amnesty, pylint: disable=wrong-import-order + +from ..student_view import StudentViewTransformer +from ..video_stream_priority import VideoBlockStreamPriorityTransformer + + +class TestVideoBlockStreamPriorityTransformer(ModuleStoreTestCase): + """ + Test the stream priority for videos using VideoBlockStreamPriorityTransformer. + """ + + def setUp(self): + super().setUp() + self.course_key = ToyCourseFactory.create().id + self.course_usage_key = self.store.make_course_usage_key(self.course_key) + self.block_structure = BlockStructureFactory.create_from_modulestore(self.course_usage_key, self.store) + + def get_pre_transform_data(self, block_key): + """ + Return the student view data before the transformation for given video block. + """ + video_block = self.block_structure.get_xblock(block_key) + return video_block.student_view_data() + + def change_encoded_videos_presentation(self, encoded_videos): + """ + Relocate stream priority data in new dictionary for pre & post transformation + data comparison. + """ + stream_priorities = {} + for video_format, video_data in encoded_videos.items(): + stream_priorities[video_format] = video_data['stream_priority'] + return stream_priorities + + def get_post_transform_data(self, block_key): + """ + Return the block's student view data after transformation. + """ + return self.block_structure.get_transformer_block_field( + block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA + ) + + def collect_and_transform(self): + """ + Perform transformer operations. + """ + StudentViewTransformer.collect(self.block_structure) + self.block_structure._collect_requested_xblock_fields() # pylint: disable=protected-access + StudentViewTransformer(['video']).transform( + usage_info=None, + block_structure=self.block_structure, + ) + VideoBlockStreamPriorityTransformer().transform( + usage_info=self.course_usage_key, + block_structure=self.block_structure, + ) + + @mock.patch('lms.djangoapps.course_blocks.usage_info.CourseUsageInfo') + @mock.patch('openedx.core.djangoapps.waffle_utils.CourseWaffleFlag.is_enabled') + @mock.patch('xmodule.video_module.VideoBlock.student_view_data') + def test_write_for_deprecated_youtube_flag_on(self, mock_video_data, deprecate_youtube_flag, usage_info): + """ + Test that video stream priority is written correctly with + videos.deprecate_youtube flag on. + """ + mock_video_data.return_value = { + 'encoded_videos': { + 'hls': { + 'url': 'https://xyz123.cloudfront.net/XYZ123ABC.mp4', + 'file_size': 0 + }, + 'mobile_low': { + 'url': 'https://1234a.cloudfront.net/A1234a.mp4', + 'file_size': 0 + }, + 'mobile_high': { + 'url': 'https://1234ab.cloudfront.net/A1234ab.mp4', + 'file_size': 0 + }, + 'desktop_mp4': { + 'url': 'https://1234abc.cloudfront.net/A1234abc.mp4', + 'file_size': 0 + }, + 'fallback': { + 'url': 'https://1234abcd.cloudfront.net/A1234abcd.mp4', + 'file_size': 0 + }, + 'youtube': { + 'url': 'https://1234abcde.cloudfront.net/A1234abcde.mp4', + 'file_size': 0 + } + }, + 'only_on_web': False + } + deprecate_youtube_flag.return_value = True + usage_info.return_value = {'course_key': self.course_key} + + video_block_key = self.course_key.make_usage_key('video', 'sample_video') + self.collect_and_transform() + post_transform_data = self.get_post_transform_data(video_block_key) + post_transform_data = self.change_encoded_videos_presentation(post_transform_data['encoded_videos']) + + for video_format, stream_priority in post_transform_data.items(): + assert post_transform_data[video_format] == \ + VideoBlockStreamPriorityTransformer.DEPRECATE_YOUTUBE_VIDEO_STREAM_PRIORITY[video_format] + + @mock.patch('lms.djangoapps.course_blocks.usage_info.CourseUsageInfo') + @mock.patch('openedx.core.djangoapps.waffle_utils.CourseWaffleFlag.is_enabled') + @mock.patch('xmodule.video_module.VideoBlock.student_view_data') + def test_write_for_deprecated_youtube_flag_off(self, mock_video_data, deprecate_youtube_flag, usage_info): + """ + Test that video stream priority is written correctly with + videos.deprecate_youtube flag off. + """ + mock_video_data.return_value = { + 'encoded_videos': { + 'hls': { + 'url': 'https://xyz123.cloudfront.net/XYZ123ABC.mp4', + 'file_size': 0 + }, + 'mobile_low': { + 'url': 'https://1234a.cloudfront.net/A1234a.mp4', + 'file_size': 0 + }, + 'mobile_high': { + 'url': 'https://1234ab.cloudfront.net/A1234ab.mp4', + 'file_size': 0 + }, + 'desktop_mp4': { + 'url': 'https://1234abc.cloudfront.net/A1234abc.mp4', + 'file_size': 0 + }, + 'fallback': { + 'url': 'https://1234abcd.cloudfront.net/A1234abcd.mp4', + 'file_size': 0 + }, + 'youtube': { + 'url': 'https://1234abcde.cloudfront.net/A1234abcde.mp4', + 'file_size': 0 + } + }, + 'only_on_web': False + } + deprecate_youtube_flag.return_value = False + usage_info.return_value = {'course_key': self.course_key} + + video_block_key = self.course_key.make_usage_key('video', 'sample_video') + self.collect_and_transform() + post_transform_data = self.get_post_transform_data(video_block_key) + post_transform_data = self.change_encoded_videos_presentation(post_transform_data['encoded_videos']) + + for video_format, stream_priority in post_transform_data.items(): + assert post_transform_data[video_format] == \ + VideoBlockStreamPriorityTransformer.DEFAULT_VIDEO_STREAM_PRIORITY[video_format] + + @mock.patch('xmodule.video_module.VideoBlock.student_view_data') + def test_no_priority_for_web_only_videos(self, mock_video_data): + """ + Verify no write attempt is made for the videos + available on web only. + """ + mock_video_data.return_value = { + 'only_on_web': True + } + video_block_key = self.course_key.make_usage_key('video', 'sample_video') + pre_transform_data = self.get_pre_transform_data(video_block_key) + self.collect_and_transform() + post_transform_data = self.get_post_transform_data(video_block_key) + self.assertDictEqual(pre_transform_data, post_transform_data) diff --git a/lms/djangoapps/course_api/blocks/transformers/video_stream_priority.py b/lms/djangoapps/course_api/blocks/transformers/video_stream_priority.py new file mode 100644 index 0000000000..fbef8623b7 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/video_stream_priority.py @@ -0,0 +1,71 @@ +""" +Video block stream priority Transformer +""" + +from openedx.core.djangoapps.content.block_structure.transformer import BlockStructureTransformer +from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE + +from .student_view import StudentViewTransformer + + +class VideoBlockStreamPriorityTransformer(BlockStructureTransformer): + """ + Transformer to add stream priority for encoded_videos. + + If DEPRECATE_YOUTUBE waffle flag is on for a course, Youtube videos + have highest priority i.e. 0. Else, the default priority for videos + is as shown in DEFAULT_VIDEO_STREAM_PRIORITY below. + """ + + WRITE_VERSION = 1 + READ_VERSION = 1 + DEFAULT_VIDEO_STREAM_PRIORITY = { + 'hls': 0, + 'mobile_low': 1, + 'mobile_high': 2, + 'desktop_mp4': 3, + 'fallback': 4, + 'youtube': 5, + } + DEPRECATE_YOUTUBE_VIDEO_STREAM_PRIORITY = { + 'youtube': 0, + 'hls': 1, + 'mobile_low': 2, + 'mobile_high': 3, + 'desktop_mp4': 4, + 'fallback': 5, + } + + @classmethod + def name(cls): + return "blocks_api:video_stream_priority" + + def transform(self, usage_info, block_structure): + """ + Write all the video blocks' stream priority. + + For the encoded_videos dictionary, a field called stream_priority + will be added to all the available video blocks. Client end can use this + value to prioritise streaming for different video formats. + """ + + for block_key in block_structure.topological_traversal( + filter_func=lambda block_key: block_key.block_type == 'video', + yield_descendants_of_unyielded=True, + ): + student_view_data = block_structure.get_transformer_block_field( + block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA + ) + if not student_view_data: + return + + # web-only videos don't contain any video information for native clients + only_on_web = student_view_data.get('only_on_web') + if only_on_web: + continue + encoded_videos = student_view_data.get('encoded_videos') + for video_format, video_data in encoded_videos.items(): + if DEPRECATE_YOUTUBE.is_enabled(usage_info.course_key): + video_data['stream_priority'] = self.DEPRECATE_YOUTUBE_VIDEO_STREAM_PRIORITY[video_format] + else: + video_data['stream_priority'] = self.DEFAULT_VIDEO_STREAM_PRIORITY[video_format]