diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c958366b7d36553c9fb7de9c4b5811d77fb92611..00ab686c202488a69c5951c741f2c181704def4a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Allow multiple transcripts with video. BLD-642. + CMS: Add feature to allow exporting a course to a git repository by specifying the giturl in the course settings. diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index b194e356bde59d235aa89e5ec60f81f36158cf31..9a590572a311ec8f523beb9cb3f70301e19ece9b 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -145,7 +145,7 @@ def verify_setting_entry(setting, display_name, value, explicitly_set): # Check if the web object is a list type # If so, we use a slightly different mechanism for determining its value - if setting.has_class('metadata-list-enum'): + if setting.has_class('metadata-list-enum') or setting.has_class('metadata-dict'): list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item')) assert_equal(value, list_value) elif setting.has_class('metadata-videolist-enum'): diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index 72f2beff3a53acffe2d45f5f593a51779795d91c..bd0a6efc1b7b85d12a16a0e30112ddec53de7d17 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -1,6 +1,6 @@ @shard_3 -Feature: CMS.Transcripts - As a course author, I want to be able to create video components. +Feature: CMS Transcripts + As a course author, I want to be able to create video components # For transcripts acceptance tests there are 3 available caption # files. They can be used to test various transcripts features. Two of @@ -72,7 +72,7 @@ Feature: CMS.Transcripts And I remove "t_not_exist" transcripts id from store And I enter a "http://youtu.be/t_not_exist" source to field number 1 Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" # Import: w/o local but with server subs And I remove "t__eq_exist" transcripts id from store @@ -83,7 +83,7 @@ Feature: CMS.Transcripts Then I see status message "found" And I see button "upload_new_timed_transcripts" And I see button "download_to_edit" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + And I see value "t__eq_exist" in the field "Transcript (primary)" #4 Scenario: Youtube id only: check "Found" state @@ -92,7 +92,7 @@ Feature: CMS.Transcripts And I enter a "http://youtu.be/t_not_exist" source to field number 1 Then I see status message "found" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" #5 Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are equal @@ -102,7 +102,7 @@ Feature: CMS.Transcripts And I enter a "http://youtu.be/t__eq_exist" source to field number 1 And I see status message "found" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + And I see value "t__eq_exist" in the field "Transcript (primary)" #6 Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are not equal @@ -114,7 +114,7 @@ Feature: CMS.Transcripts And I see button "replace" And I click transcript button "replace" And I see status message "found" - And I see value "t_neq_exist" in the field "HTML5 Transcript" + And I see value "t_neq_exist" in the field "Transcript (primary)" #7 Scenario: html5 source only: check "Not Found" state @@ -123,7 +123,7 @@ Feature: CMS.Transcripts And I enter a "t_not_exist.mp4" source to field number 1 Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" #8 Scenario: html5 source only: check "Found" state @@ -132,7 +132,7 @@ Feature: CMS.Transcripts And I enter a "t_not_exist.mp4" source to field number 1 Then I see status message "found" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" #9 Scenario: User sets youtube_id w/o server but with local subs and one html5 link w/o subs @@ -144,7 +144,7 @@ Feature: CMS.Transcripts And I enter a "test_video_name.mp4" source to field number 2 Then I see status message "found" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" # Disabled 1/29/14 due to flakiness observed in master #10 @@ -160,7 +160,7 @@ Feature: CMS.Transcripts # # And I enter a "t_not_exist.mp4" source to field number 2 # Then I see status message "found" - # And I see value "t__eq_exist" in the field "HTML5 Transcript" + # And I see value "t__eq_exist" in the field "Transcript (primary)" #11 Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts @@ -338,7 +338,7 @@ Feature: CMS.Transcripts Then I see status message "uploaded_successfully" And I see button "download_to_edit" And I see button "upload_new_timed_transcripts" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + And I see value "t__eq_exist" in the field "Transcript (primary)" And I enter a "http://youtu.be/t_not_exist" source to field number 2 Then I see status message "found" @@ -359,7 +359,7 @@ Feature: CMS.Transcripts And I see button "upload_new_timed_transcripts" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "test_transcripts" in the field "HTML5 Transcript" + And I see value "test_transcripts" in the field "Transcript (primary)" And I enter a "t_not_exist.webm" source to field number 2 Then I see status message "replace" @@ -367,7 +367,7 @@ Feature: CMS.Transcripts And I see choose button "test_transcripts.mp4" number 1 And I see choose button "t_not_exist.webm" number 2 And I click transcript button "choose" number 2 - And I see value "test_transcripts|t_not_exist" in the field "HTML5 Transcript" + And I see value "test_transcripts|t_not_exist" in the field "Transcript (primary)" #21 Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save - > change it to another one HTML5 source w/o transcripts - click on use existing - > change it to another one HTML5 source w/o transcripts - click on use existing @@ -378,7 +378,7 @@ Feature: CMS.Transcripts Then I see status message "found" And I see button "download_to_edit" And I see button "upload_new_timed_transcripts" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" And I save changes And I edit the component @@ -387,13 +387,13 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_2" in the field "Transcript (primary)" And I enter a "video_name_3.mp4" source to field number 1 Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_3" in the field "HTML5 Transcript" + And I see value "video_name_3" in the field "Transcript (primary)" #22 Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - click on use existing -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> change it to another one HTML5 source w/o transcripts - click on use existing @@ -404,7 +404,7 @@ Feature: CMS.Transcripts Then I see status message "found" And I see button "download_to_edit" And I see button "upload_new_timed_transcripts" - And I see value "t_not_exist" in the field "HTML5 Transcript" + And I see value "t_not_exist" in the field "Transcript (primary)" And I save changes And I edit the component @@ -413,7 +413,7 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_2" in the field "Transcript (primary)" And I enter a "video_name_3.mp4" source to field number 1 Then I see status message "use existing" @@ -423,7 +423,7 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_4" in the field "HTML5 Transcript" + And I see value "video_name_4" in the field "Transcript (primary)" #23 Scenario: Work with 2 fields: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> add another one HTML5 source w/o transcripts - click on use existing @@ -446,7 +446,7 @@ Feature: CMS.Transcripts Then I see status message "use existing" And I see button "use_existing" And I click transcript button "use_existing" - And I see value "video_name_2|video_name_3" in the field "HTML5 Transcript" + And I see value "video_name_2|video_name_3" in the field "Transcript (primary)" #24 Uploading subtitles with different file name than file Scenario: File name and name of subs are different @@ -457,7 +457,7 @@ Feature: CMS.Transcripts And I see status message "not found" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions @@ -488,14 +488,14 @@ Feature: CMS.Transcripts And I see status message "not found" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "video_name_1|video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_1|video_name_2" in the field "Transcript (primary)" And I clear field number 1 Then I see status message "found" - And I see value "video_name_2" in the field "HTML5 Transcript" + And I see value "video_name_2" in the field "Transcript (primary)" #27 - Scenario: Upload button for single youtube id. + Scenario: Upload button for single youtube id Given I have created a Video component And I edit the component @@ -512,7 +512,7 @@ Feature: CMS.Transcripts Then I see status message "found" #28 - Scenario: Upload button for youtube id with html5 ids. + Scenario: Upload button for youtube id with html5 ids Given I have created a Video component And I edit the component @@ -528,7 +528,7 @@ Feature: CMS.Transcripts Then I see status message "uploaded_successfully" And I clear field number 1 Then I see status message "found" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions @@ -544,14 +544,14 @@ Feature: CMS.Transcripts Then I see status message "not found" And I open tab "Advanced" - And I set value "t_not_exist" to the field "HTML5 Transcript" + And I set value "t_not_exist" to the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions And I edit the component Then I see status message "found" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" #30 Scenario: Check non-ascii (chinise) transcripts @@ -576,7 +576,7 @@ Feature: CMS.Transcripts Then I see status message "not found" And I open tab "Advanced" - And I set value "t_not_exist" to the field "HTML5 Transcript" + And I set value "t_not_exist" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "found" @@ -585,18 +585,20 @@ Feature: CMS.Transcripts And I edit the component Then I see status message "found" - And I see value "video_name_1" in the field "HTML5 Transcript" + And I see value "video_name_1" in the field "Transcript (primary)" #32 Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible w/o saving - Given I have created a Video component with subtitles "t_not_exist" + Given I have created a Video component And I edit the component And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" + Then I see status message "not found" + And I upload the transcripts file "chinese_transcripts.srt" + Then I see status message "uploaded_successfully" And I open tab "Advanced" - And I set value "" to the field "HTML5 Transcript" + And I set value "" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "not found" @@ -605,21 +607,24 @@ Feature: CMS.Transcripts And I edit the component Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" #33 Scenario: After clearing Transcripts field in the Advanced tab "not found" message should be visible with saving - Given I have created a Video component with subtitles "t_not_exist" + Given I have created a Video component And I edit the component And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" + Then I see status message "not found" + And I upload the transcripts file "chinese_transcripts.srt" + Then I see status message "uploaded_successfully" And I save changes + Then I see "好 å„ä½åŒå¦" text in the captions And I edit the component And I open tab "Advanced" - And I set value "" to the field "HTML5 Transcript" + And I set value "" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "not found" @@ -628,7 +633,7 @@ Feature: CMS.Transcripts And I edit the component Then I see status message "not found" - And I see value "" in the field "HTML5 Transcript" + And I see value "" in the field "Transcript (primary)" #34 Scenario: Video with existing subs - Advanced tab - change to another one subs - Basic tab - Found message - Save - see correct subs @@ -647,7 +652,7 @@ Feature: CMS.Transcripts And I edit the component And I open tab "Advanced" - And I set value "t_not_exist" to the field "HTML5 Transcript" + And I set value "t_not_exist" to the field "Transcript (primary)" And I open tab "Basic" Then I see status message "found" @@ -670,7 +675,7 @@ Feature: CMS.Transcripts And I edit the component And I open tab "Advanced" - And I revert the transcript field "HTML5 Transcript" + And I revert the transcript field "Transcript (primary)" And I save changes Then when I view the video it does not show the captions @@ -686,7 +691,7 @@ Feature: CMS.Transcripts And I see status message "not found" And I upload the transcripts file "test_transcripts.srt" Then I see status message "uploaded_successfully" - And I see value "video_name_1.1.2" in the field "HTML5 Transcript" + And I see value "video_name_1.1.2" in the field "Transcript (primary)" And I save changes Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py index 695c96c8b880fc659dc5428886fe5cfb95d660e0..41903afccfa062e2c5196409c7c8ff98edbf682c 100644 --- a/cms/djangoapps/contentstore/features/transcripts.py +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -103,7 +103,7 @@ def i_do_not_see_error_message(_step): @step('I see error message "([^"]*)"$') def i_see_error_message(_step, error): - assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error.strip()]) + assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error]) @step('I do not see status message$') @@ -114,7 +114,7 @@ def i_do_not_see_status_message(_step): @step('I see status message "([^"]*)"$') def i_see_status_message(_step, status): assert not world.css_visible(SELECTORS['error_bar']) - assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()]) + assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status]) DOWNLOAD_BUTTON = TRANSCRIPTS_BUTTONS["download_to_edit"][0] if world.is_css_present(DOWNLOAD_BUTTON, wait_time=1) \ diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index 07c298b0d214b94e3e73786a9a9ec59b4eec12ac..1c12915e91cecd024172704028acf5310d8e977e 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -1,6 +1,6 @@ @shard_3 -Feature: CMS.Video Component Editor - As a course author, I want to be able to create video components. +Feature: CMS Video Component Editor + As a course author, I want to be able to create video components Scenario: User can view Video metadata Given I have created a Video component @@ -17,14 +17,14 @@ Feature: CMS.Video Component Editor # Sauce Labs cannot delete cookies @skip_sauce - Scenario: Captions are hidden when "show captions" is false + Scenario: Captions are hidden when "transcript display" is false Given I have created a Video component with subtitles - And I have set "show transcript" to False + And I have set "transcript display" to False Then when I view the video it does not show the captions # Sauce Labs cannot delete cookies @skip_sauce - Scenario: Captions are shown when "show captions" is true + Scenario: Captions are shown when "transcript display" is true Given I have created a Video component with subtitles - And I have set "show transcript" to True + And I have set "transcript display" to True Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index b8c74be80af9c9c045e08c2aac8a87ff2100ebf9..ea90993d67a7b7b2f693f8336f4180ec3d87a109 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -5,7 +5,7 @@ from lettuce import world, step from terrain.steps import reload_the_page -@step('I have set "show transcript" to (.*)$') +@step('I have set "transcript display" to (.*)$') def set_show_captions(step, setting): # Prevent cookies from overriding course settings world.browser.cookies.delete('hide_captions') @@ -13,7 +13,7 @@ def set_show_captions(step, setting): world.css_click('a.edit-button') world.wait_for(lambda _driver: world.css_visible('a.save-button')) world.click_link_by_text('Advanced') - world.browser.select('Show Transcript', setting) + world.browser.select('Transcript Display', setting) world.css_click('a.save-button') @@ -42,10 +42,11 @@ def correct_video_settings(_step): ['Display Name', 'Video', False], ['Download Transcript', '', False], ['End Time', '00:00:00', False], - ['HTML5 Transcript', '', False], - ['Show Transcript', 'True', False], ['Start Time', '00:00:00', False], + ['Transcript (primary)', '', False], + ['Transcript Display', 'True', False], ['Transcript Download Allowed', 'False', False], + ['Transcript Translations', '', False], ['Video Download Allowed', 'False', False], ['Video Sources', '', False], ['Youtube ID', 'OEoXaMPEzfM', False], diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 2ead8987e9633d4075635ac9ff59f70335c28432..fc70ee3c145ca609af85d1751055e5e2d447861f 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -1,6 +1,6 @@ @shard_3 -Feature: CMS.Video Component - As a course author, I want to be able to view my created videos in Studio. +Feature: CMS Video Component + As a course author, I want to be able to view my created videos in Studio # 1 # Video Alpha Features will work in Firefox only when Firefox is the active window @@ -43,38 +43,6 @@ Feature: CMS.Video Component Then the correct Youtube video is shown # 7 - Scenario: Closed captions become visible when the mouse hovers over CC button - Given I have created a Video component with subtitles - And Make sure captions are closed - Then Captions become "invisible" - And I hover over button "CC" - Then Captions become "visible" - And I hover over button "volume" - Then Captions become "invisible" - - # 8 - # Disabled 11/26 due to flakiness in master. - # Enabled back on 11/29. - Scenario: Open captions never become invisible - Given I have created a Video component with subtitles - And Make sure captions are open - Then Captions are "visible" - And I hover over button "CC" - Then Captions are "visible" - And I hover over button "volume" - Then Captions are "visible" - - # 9 - # Disabled 11/26 due to flakiness in master. - # Enabled back on 11/29. - Scenario: Closed captions are invisible when mouse doesn't hover on CC button - Given I have created a Video component with subtitles - And Make sure captions are closed - Then Captions become "invisible" - And I hover over button "volume" - Then Captions are "invisible" - - # 10 # Disabled 11/26 due to flakiness in master. # Enabled back on 11/29. Scenario: When enter key is pressed on a caption shows an outline around it @@ -84,7 +52,7 @@ Feature: CMS.Video Component Then I press "enter" button on caption line with data-index "0" And I see caption line with data-index "0" has class "focused" - # 11 + # 8 Scenario: When start end end times are specified, a range on slider is shown Given I have created a Video component with subtitles And Make sure captions are closed diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 2dc4bccfb3977d983604e496e9f90b46d189b224..b3777aa57de2092726186307319b1e2d1652953b 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -56,6 +56,13 @@ def i_created_a_video_with_subs_with_name(_step, sub_id): world.visit(video_url) world.wait_for_xmodule() + + # update .sub filed with proper subs name (which mimics real Studio/XML behavior) + # this is needed only for that videos which are created in acceptance tests. + _step.given('I edit the component') + world.wait_for_ajax_complete() + _step.given('I save changes') + world.disable_jquery_animations() world.wait_for_present('.is-initialized') diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index c19c4d198c2f175d2caf3e5e87b1004c286c6f00..8d44b98d95d12a4fe1caf183d423ef575e896515 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -492,15 +492,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time') self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2') - def test_video_module_caption_asset_path(self): - """ - This verifies that a video caption url is as we expect it to be - """ - resp = self._test_preview(Location('i4x', 'edX', 'toy', 'video', 'sample_video', None)) - self.assertEquals(resp.status_code, 200) - content = json.loads(resp.content) - self.assertIn('data-caption-asset-path="/c4x/edX/toy/asset/subs_"', content['html']) - def _test_preview(self, location): """ Preview test case. """ direct_store = modulestore('direct') diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index a34927e3ef6001b594e51f315b49c18cedd75d32..c0c179abb863f0ae56e029bfc706d33eeb9118aa 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py @@ -3,11 +3,13 @@ import unittest from uuid import uuid4 import copy import textwrap +from mock import patch, Mock from pymongo import MongoClient from django.test.utils import override_settings from django.conf import settings +from django.utils import translation from nose.plugins.skip import SkipTest @@ -16,7 +18,7 @@ from xmodule.contentstore.content import StaticContent from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.exceptions import NotFoundError from xmodule.contentstore.django import contentstore, _CONTENTSTORE -from contentstore import transcripts_utils +from xmodule.video_module import transcripts_utils from contentstore.tests.modulestore_config import TEST_MODULESTORE TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) @@ -188,20 +190,29 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): def test_success_downloading_subs(self): - # Disabled 11/14/13 - # This test is flakey because it performs an HTTP request on an external service - # Re-enable when `requests.get` is patched using `mock.patch` - raise SkipTest - + response = textwrap.dedent("""<?xml version="1.0" encoding="utf-8" ?> + <transcript> + <text start="0" dur="0.27"></text> + <text start="0.27" dur="2.45">Test text 1.</text> + <text start="2.72">Test text 2.</text> + <text start="5.43" dur="1.73">Test text 3.</text> + </transcript> + """) good_youtube_subs = { - 0.5: 'JMD_ifUUfsU', - 1.0: 'hI10vDNYz4M', - 2.0: 'AKqURZnYqpk' + 0.5: 'good_id_1', + 1.0: 'good_id_2', + 2.0: 'good_id_3' } self.clear_subs_content(good_youtube_subs) - # Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown - transcripts_utils.download_youtube_subs(good_youtube_subs, self.course) + with patch('xmodule.video_module.transcripts_utils.requests.get') as mock_get: + mock_get.return_value = Mock(status_code=200, text=response, content=response) + # Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown + transcripts_utils.download_youtube_subs(good_youtube_subs, self.course, settings) + + mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_1'}) + mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_2'}) + mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_3'}) # Check assets status after importing subtitles. for subs_id in good_youtube_subs.values(): @@ -226,12 +237,10 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): self.assertEqual(html5_ids[2], 'baz.1.4') self.assertEqual(html5_ids[3], 'foo') - def test_fail_downloading_subs(self): + @patch('xmodule.video_module.transcripts_utils.requests.get') + def test_fail_downloading_subs(self, mock_get): - # Disabled 11/14/13 - # This test is flakey because it performs an HTTP request on an external service - # Re-enable when `requests.get` is patched using `mock.patch` - raise SkipTest + mock_get.return_value = Mock(status_code=404, text='Error 404') bad_youtube_subs = { 0.5: 'BAD_YOUTUBE_ID1', @@ -239,9 +248,8 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): 2.0: 'BAD_YOUTUBE_ID3' } self.clear_subs_content(bad_youtube_subs) - with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): - transcripts_utils.download_youtube_subs(bad_youtube_subs, self.course) + transcripts_utils.download_youtube_subs(bad_youtube_subs, self.course, settings) # Check assets status after importing subtitles. for subs_id in bad_youtube_subs.values(): @@ -267,7 +275,7 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase): self.clear_subs_content(good_youtube_subs) # Check transcripts_utils.GetTranscriptsFromYouTubeException not thrown - transcripts_utils.download_youtube_subs(good_youtube_subs, self.course) + transcripts_utils.download_youtube_subs(good_youtube_subs, self.course, settings) # Check assets status after importing subtitles. for subs_id in good_youtube_subs.values(): @@ -438,3 +446,43 @@ class TestGenerateSrtFromSjson(TestDownloadYoutubeSubs): } srt_subs = transcripts_utils.generate_srt_from_sjson(sjson_subs, 1) self.assertFalse(srt_subs) + + +class TestYoutubeTranscripts(unittest.TestCase): + """ + Tests for checking right datastructure returning when using youtube api. + """ + @patch('xmodule.video_module.transcripts_utils.requests.get') + def test_youtube_bad_status_code(self, mock_get): + mock_get.return_value = Mock(status_code=404, text='test') + youtube_id = 'bad_youtube_id' + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + transcripts_utils.get_transcripts_from_youtube(youtube_id, settings, translation) + + @patch('xmodule.video_module.transcripts_utils.requests.get') + def test_youtube_empty_text(self, mock_get): + mock_get.return_value = Mock(status_code=200, text='') + youtube_id = 'bad_youtube_id' + with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException): + transcripts_utils.get_transcripts_from_youtube(youtube_id, settings, translation) + + def test_youtube_good_result(self): + response = textwrap.dedent("""<?xml version="1.0" encoding="utf-8" ?> + <transcript> + <text start="0" dur="0.27"></text> + <text start="0.27" dur="2.45">Test text 1.</text> + <text start="2.72">Test text 2.</text> + <text start="5.43" dur="1.73">Test text 3.</text> + </transcript> + """) + expected_transcripts = { + 'start': [270, 2720, 5430], + 'end': [2720, 2720, 7160], + 'text': ['Test text 1.', 'Test text 2.', 'Test text 3.'] + } + youtube_id = 'good_youtube_id' + with patch('xmodule.video_module.transcripts_utils.requests.get') as mock_get: + mock_get.return_value = Mock(status_code=200, text=response, content=response) + transcripts = transcripts_utils.get_transcripts_from_youtube(youtube_id, settings, translation) + self.assertEqual(transcripts, expected_transcripts) + mock_get.assert_called_with('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_youtube_id'}) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 80548a27a72977dbadf2d67932b650642250b30c..4945a0e6f2bd3e92bc5d46904c8593175643263b 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -24,12 +24,11 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore import Location +from xmodule.video_module import manage_video_subtitles_save from util.json_request import expect_json, JsonResponse from util.string_utils import str_to_bool -from ..transcripts_utils import manage_video_subtitles_save - from ..utils import get_modulestore from .access import has_course_access @@ -251,6 +250,8 @@ def _save_item(request, usage_loc, item_location, data=None, children=None, meta log.error("Can't find item by location.") return JsonResponse({"error": "Can't find item by location: " + str(item_location)}, 404) + old_metadata = own_metadata(existing_item) + if publish: if publish == 'make_private': _xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location)) @@ -299,7 +300,7 @@ def _save_item(request, usage_loc, item_location, data=None, children=None, meta field.write_to(existing_item, value) if existing_item.category == 'video': - manage_video_subtitles_save(existing_item, existing_item, request.user) + manage_video_subtitles_save(existing_item, request.user, old_metadata, generate_translation=True) # commit to datastore store.update_item(existing_item, request.user.id) diff --git a/cms/djangoapps/contentstore/views/tests/test_transcripts.py b/cms/djangoapps/contentstore/views/tests/test_transcripts.py index f92e8e7f216fc07a42117c195f23171006fd87c4..9722bbfa0664dc5aa69a85bf8e0d2e39a58d313c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_transcripts.py +++ b/cms/djangoapps/contentstore/views/tests/test_transcripts.py @@ -12,7 +12,7 @@ from django.core.urlresolvers import reverse from django.test.utils import override_settings from django.conf import settings -from contentstore import transcripts_utils +from xmodule.video_module import transcripts_utils from contentstore.tests.utils import CourseTestCase from cache_toolbox.core import del_cached_content from xmodule.modulestore.django import modulestore diff --git a/cms/djangoapps/contentstore/views/transcripts_ajax.py b/cms/djangoapps/contentstore/views/transcripts_ajax.py index 01535f6021538860dac82d30050af03af0dc0857..d30e2a29c4e7dfa142517e193962d1dd17c22493 100644 --- a/cms/djangoapps/contentstore/views/transcripts_ajax.py +++ b/cms/djangoapps/contentstore/views/transcripts_ajax.py @@ -26,12 +26,11 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationErr from util.json_request import JsonResponse from xmodule.modulestore.locator import BlockUsageLocator -from ..transcripts_utils import ( +from xmodule.video_module.transcripts_utils import ( generate_subs_from_source, generate_srt_from_sjson, remove_subs_from_store, download_youtube_subs, get_transcripts_from_youtube, copy_or_rename_transcript, - save_module, manage_video_subtitles_save, TranscriptsGenerationException, GetTranscriptsFromYouTubeException, @@ -136,7 +135,7 @@ def upload_transcripts(request): return error_response(response, "Can't find transcripts in storage for {}".format(sub_attr)) item.sub = selected_name # write one of new subtitles names to item.sub attribute. - save_module(item, request.user) + item.save_with_metadata(request.user) response['subs'] = item.sub response['status'] = 'Success' else: @@ -272,7 +271,11 @@ def check_transcripts(request): #check youtube local and server transcripts for equality if transcripts_presence['youtube_server'] and transcripts_presence['youtube_local']: try: - youtube_server_subs = get_transcripts_from_youtube(youtube_id) + youtube_server_subs = get_transcripts_from_youtube( + youtube_id, + settings, + item.runtime.service(item, "i18n") + ) if json.loads(local_transcripts) == youtube_server_subs: # check transcripts for equality transcripts_presence['youtube_diff'] = False except GetTranscriptsFromYouTubeException: @@ -389,7 +392,7 @@ def choose_transcripts(request): if item.sub != html5_id: # update sub value item.sub = html5_id - save_module(item, request.user) + item.save_with_metadata(request.user) response = {'status': 'Success', 'subs': item.sub} return JsonResponse(response) @@ -415,12 +418,12 @@ def replace_transcripts(request): return error_response(response, 'YouTube id {} is not presented in request data.'.format(youtube_id)) try: - download_youtube_subs({1.0: youtube_id}, item) + download_youtube_subs({1.0: youtube_id}, item, settings) except GetTranscriptsFromYouTubeException as e: return error_response(response, e.message) item.sub = youtube_id - save_module(item, request.user) + item.save_with_metadata(request.user) response = {'status': 'Success', 'subs': item.sub} return JsonResponse(response) @@ -519,10 +522,10 @@ def save_transcripts(request): for metadata_key, value in metadata.items(): setattr(item, metadata_key, value) - save_module(item, request.user) # item becomes updated with new values + item.save_with_metadata(request.user) # item becomes updated with new values if new_sub: - manage_video_subtitles_save(None, item, request.user) + manage_video_subtitles_save(item, request.user) else: # If `new_sub` is empty, it means that user explicitly does not want to use # transcripts for current video ids and we remove all transcripts from storage. diff --git a/cms/envs/common.py b/cms/envs/common.py index 23b7de10a5dbcf56216b493751b2610e97712b2a..8b619013279bd5e959f946cc9b31fb26660b7420 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -26,7 +26,9 @@ Longer TODO: import sys import lms.envs.common -from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, enable_microsites +from lms.envs.common import ( + USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, enable_microsites, ALL_LANGUAGES +) from path import path from lms.lib.xblock.mixin import LmsBlockMixin diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 5f4e9d1063d9992cfcc3f3ccc79800ef750f7b90..5fc6486698fe68f1056723ecae65603bcaf0dcb7 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -164,6 +164,37 @@ div.video { } } + %video-button { + @include transition(none); + display: block; + font-weight: 800; + line-height: 46px; + margin: 0; + padding: 0 0 0 15px; + text-indent: -9999px; + -webkit-font-smoothing: antialiased; + box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; + color: #fff; + cursor: pointer; + border-width: 0 1px; + border-style: solid; + border-color: #000; + + &:hover { + background-color: #444; + color: #fff; + text-decoration: none; + outline: 0; + } + + &:active, + &:focus { + color: #fff; + background-color: #444; + text-decoration: none; + } + } + div.slider { @include clearfix(); @include transform(scaleY(0.5) translate3d(0, 50%, 0)); @@ -230,48 +261,33 @@ div.video { margin-bottom: 0; a { - border-bottom: none; - border-right: 1px solid #000; + @extend %video-button; + background-image: url('../images/vcr.png'); + background-position: 15px 15px ; + background-repeat: no-repeat; + border-left: none; box-shadow: 1px 0 0 #555; - cursor: pointer; - display: block; - line-height: 46px; padding: 0 lh(.75); - text-indent: -9999px; width: 14px; - background: url('../images/vcr.png') 15px 15px no-repeat; &:focus { position: relative; z-index: 10000; outline: #fff dotted thin; outline-offset: -2px; - background: #333; - } - - &:hover { - outline: 0; } &:empty { height: 46px; - background: url('../images/vcr.png') 15px 15px no-repeat; + background-position: 15px 15px; } &.play { background-position: 17px -114px; - - &:hover { - background-color: #444; - } } &.pause { background-position: 16px -50px; - - &:hover { - background-color: #444; - } } } @@ -301,16 +317,12 @@ div.video { } } - div.speeds { + .menu-container { float: left; position: relative; &.open { - & > a { - background: url('../images/open-arrow.png') 10px center no-repeat; - } - - ol.video_speeds { + .menu { display: block; opacity: 1; padding: 0; @@ -319,89 +331,15 @@ div.video { } } - & > a { - @include clearfix(); - @include transition(none); - background: url('../images/closed-arrow.png') 10px center no-repeat; - border-left: 1px solid #000; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #fff; - cursor: pointer; - display: block; - line-height: 46px; //height of play pause buttons - margin-right: 0; - padding-left: 15px; - position: relative; - -webkit-font-smoothing: antialiased; - min-width: 116px; - - @media (max-width: 1024px) { - min-width: 0; - width: 86px; - } - - h3 { - display: block; - - @media (max-width: 1024px) { - display: none; - } - } - - &:hover { - outline: 0; - opacity: 1; - background-color: #444; - } - - &:active { - opacity: 1; - background-color: #444; - } - - h3 { - color: #999; - float: left; - font-size: em(14); - font-weight: normal; - letter-spacing: 1px; - padding: 0 lh(.25) 0 lh(.5); - line-height: 46px; - text-transform: uppercase; - } - - p.active { - float: left; - font-weight: bold; - margin-bottom: 0; - padding: 0 lh(.5) 0 0; - - @media (max-width: 1024px) { - padding: 0 lh(.5) 0 lh(.5); - } - - line-height: 46px; - color: #fff; - } - } - - // fix for now - ol.video_speeds { + .menu { @include transition(none); - box-shadow: inset 1px 0 0 #555, 0 4px 0 #444; + box-shadow: inset 1px 0 0 #555, 0 1px 0 #444; background-color: #444; border: 1px solid #000; bottom: 46px; display: none; opacity: 0; position: absolute; - width: 131px; - - @media (max-width: 1024px) { - width: 101px; - } - z-index: 10; li { @@ -415,6 +353,9 @@ div.video { color: #fff; display: block; padding: lh(.5); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; &:hover { background-color: #666; @@ -423,8 +364,10 @@ div.video { } } - &.active { - font-weight: bold; + &.active{ + a { + font-weight: bold; + } } &:last-child { @@ -436,6 +379,66 @@ div.video { } } + div.speeds { + &.open { + & > a { + background-image: url('../images/open-arrow.png'); + } + } + + .menu{ + width: 131px; + + @media (max-width: 1024px) { + width: 101px; + } + } + + & > a { + @extend %video-button; + @include clearfix(); + background-image: url('../images/closed-arrow.png'); + background-position: 10px center; + background-repeat: no-repeat; + min-width: 116px; + text-indent: 0; + + @media (max-width: 1024px) { + min-width: 0; + width: 86px; + } + + h3 { + float: left; + font-size: em(14); + font-weight: normal; + letter-spacing: 1px; + padding: 0 lh(.25) 0 lh(.5); + line-height: 46px; + text-transform: uppercase; + color: #999; + + @media (max-width: 1024px) { + display: none; + } + } + + p.active { + float: left; + font-weight: bold; + margin-bottom: 0; + padding: 0 lh(.5) 0 0; + + @media (max-width: 1024px) { + padding: 0 lh(.5) 0 lh(.5); + } + + line-height: 46px; + color: #fff; + } + } + } + div.volume { float: left; position: relative; @@ -454,29 +457,14 @@ div.video { } & > a { + @extend %video-button; @include clearfix(); - @include transition(none); background-image: url('../images/volume.png'); background-position: 10px center; background-repeat: no-repeat; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #fff; - cursor: pointer; - display: block; - height: 46px; - margin-right: 0; - padding-left: 15px; - position: relative; - -webkit-font-smoothing: antialiased; + border-left: none; width: 30px; - - &:hover, &:active { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; - } + height: 46px; } .volume-slider-container { @@ -523,49 +511,24 @@ div.video { } a.add-fullscreen { - @include transition(none); + @extend %video-button; background: url(../images/fullscreen.png) center no-repeat; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #797979; - display: block; + border-left: none; float: left; - line-height: 46px; //height of play pause buttons - margin-left: 0; - padding: 0 lh(.5); - text-indent: -9999px; + padding: 0 11px; width: 30px; - - &:hover, &:active { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; - } } a.quality_control { - @include transition(none); + @extend %video-button; background: url(../images/hd.png) center no-repeat; - border-right: 1px solid #000; - box-shadow: 1px 0 0 #555, inset 1px 0 0 #555; - color: #797979; + border-left: none; display: none; float: left; - line-height: 46px; //height of play pause buttons - margin-left: 0; - padding: 0 lh(.5); - text-indent: -9999px; + padding: 0 11px; width: 30px; - &:hover { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; - } - &.active { background-color: #F44; color: #0ff; @@ -574,33 +537,26 @@ div.video { } } + div.lang { + & > a.hide-subtitles { + @extend %video-button; + @include transition(none); + box-shadow: inset 1px 0 0 #555; + background: url('../images/cc.png') center no-repeat; + border-left: none; + border-right: none; + padding: 0 11px; + width: 30px; - a.hide-subtitles { - @include transition(none); - background: url('../images/cc.png') center no-repeat; - float: left; - font-weight: 800; - line-height: 46px; //height of play pause buttons - margin-left: 0; - opacity: 1; - padding: 0 lh(.5); - position: relative; - text-indent: -9999px; - -webkit-font-smoothing: antialiased; - width: 30px; - - &:hover { - background-color: #444; - color: #fff; - text-decoration: none; - outline: 0; + &.off { + opacity: 0.7; + } } - &.off { - opacity: 0.7; + .menu.langs-list { + right: -1px; + width: 150px; } - - color: #797979; } } } diff --git a/common/lib/xmodule/xmodule/js/fixtures/video.html b/common/lib/xmodule/xmodule/js/fixtures/video.html index 89b4e360c6ec4d7f69c5de56f7856133dd6a99f0..c08b544bc1916325b030a938314f0aec3196fad6 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video.html @@ -11,7 +11,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -51,7 +54,9 @@ </div> <a href="#" class="add-fullscreen" title="Fill browser" role="button" aria-disabled="false">Fill Browser</a> <a href="#" class="quality_control" title="HD off" role="button" aria-disabled="false">HD off</a> - <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + <div class="lang menu-container"> + <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + </div> </div> </div> </section> diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_all.html b/common/lib/xmodule/xmodule/js/fixtures/video_all.html index 982aca02328b191c270d3b0309c49e97b36f0285..7a19c951a6e57d01df620be4aac1de8989fde325 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_all.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_all.html @@ -10,7 +10,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-sub="Z5KLxerq05Y" data-mp4-source="xmodule/include/fixtures/test.mp4" data-webm-source="xmodule/include/fixtures/test.webm" @@ -54,7 +57,9 @@ </div> <a href="#" class="add-fullscreen" title="Fill browser" role="button" aria-disabled="false">Fill Browser</a> <a href="#" class="quality_control" title="HD off" role="button" aria-disabled="false">HD off</a> - <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + <div class="lang menu-container"> + <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + </div> </div> </div> </section> diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html index c3c61ba3982e69f18e34edbe7d44514363f081e8..c330a0fb8f69db3e79cefb6da7de97e02ffa11b9 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_html5.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_html5.html @@ -10,7 +10,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-sub="Z5KLxerq05Y" data-mp4-source="xmodule/include/fixtures/test.mp4" data-webm-source="xmodule/include/fixtures/test.webm" diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html index ee98a3992e0946647431a2812992aa203d6808c0..1a28833f5624c7624388a4d0633e49b3ca6e24c5 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_no_captions.html @@ -11,7 +11,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html index 33d304688b8531f1b96c0e3bb9d2c0fcc00262ae..df512bd7ab6e11a8651111eea263a3a1a92be6f3 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html +++ b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html @@ -11,7 +11,10 @@ data-start="" data-end="" data-saved-video-position="0" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -51,7 +54,9 @@ </div> <a href="#" class="add-fullscreen" title="Fill browser" role="button" aria-disabled="false">Fill Browser</a> <a href="#" class="quality_control" title="HD off" role="button" aria-disabled="false">HD off</a> - <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + <div class="lang menu-container"> + <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + </div> </div> </div> </section> @@ -73,9 +78,13 @@ class="video" data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM" data-show-captions="true" + data-speed="1.0" data-start="" data-end="" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -112,7 +121,9 @@ </div> <a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a> <a href="#" class="quality_control" title="HD">HD</a> - <a href="#" class="hide-subtitles" title="Turn off captions">Captions</a> + <div class="lang menu-container"> + <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + </div> </div> </div> </section> @@ -132,9 +143,13 @@ class="video" data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM" data-show-captions="true" + data-speed="1.0" data-start="" data-end="" - data-caption-asset-path="/static/subs/" + data-transcript-language="en" + data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通è¯"}' + data-transcript-translation-url="/transcript/translation" + data-transcript-available-translations-url="/transcript/available_translations" data-autoplay="False" data-yt-test-timeout="1500" data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/" @@ -171,7 +186,9 @@ </div> <a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a> <a href="#" class="quality_control" title="HD">HD</a> - <a href="#" class="hide-subtitles" title="Turn off captions">Captions</a> + <div class="lang menu-container"> + <a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a> + </div> </div> </div> </section> diff --git a/common/lib/xmodule/xmodule/js/spec/helper.js b/common/lib/xmodule/xmodule/js/spec/helper.js index 59fe9af25e704971d55943051b1727338b7b9b1f..9847f4f27b0e43d4a21975024ee4ecfa4eacf93e 100644 --- a/common/lib/xmodule/xmodule/js/spec/helper.js +++ b/common/lib/xmodule/xmodule/js/spec/helper.js @@ -1,6 +1,4 @@ (function ($, undefined) { - var oldAjaxWithPrefix = $.ajaxWithPrefix; - // Stub YouTube API. window.YT = { Player: function () { @@ -63,42 +61,6 @@ ] }; - // For our purposes, we need to make sure that the function - // $.ajaxWithPrefix does not fail when during tests a captions file is - // requested. It is originally defined in file: - // - // common/static/coffee/src/ajax_prefix.js - // - // We will replace it with a function that does: - // - // 1.) Return a hard coded captions object if the file name contains - // 'Z5KLxerq05Y'. - // 2.) Behaves the same a as the original function in all other cases. - $.ajaxWithPrefix = function (url, settings) { - var data, success; - - if (!settings) { - settings = url; - url = settings.url; - success = settings.success; - data = settings.data; - } - - if ( - url.match(/Z5KLxerq05Y/g) || - url.match(/7tqY6eQzVhE/g) || - url.match(/cogebirgzzM/g) - ) { - if ($.isFunction(success)) { - return success(jasmine.stubbedCaption); - } else if ($.isFunction(data)) { - return data(jasmine.stubbedCaption); - } - } else { - return oldAjaxWithPrefix.apply(this, arguments); - } - }; - // Time waitsFor() should wait for before failing a test. window.WAIT_TIMEOUT = 5000; @@ -145,13 +107,16 @@ jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50']; jasmine.stubRequests = function () { - return spyOn($, 'ajax').andCallFake(function (settings) { - var match, status, callCallback; + var spy = $.ajax; - if ( - match = settings.url - .match(/youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/) - ) { + if (!($.ajax.isSpy)) { + spy = spyOn($, 'ajax'); + } + return spy.andCallFake(function (settings) { + var match = settings.url + .match(/youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/), + status, callCallback; + if (match) { status = match[1].split('_'); if (status && status[0] === 'status') { callCallback = function (callback) { @@ -177,11 +142,10 @@ } }; } - } else if ( - match = settings.url - .match(/static(\/.*)?\/subs\/(.+)\.srt\.sjson/) - ) { + } else if (settings.url == '/transcript/translation') { return settings.success(jasmine.stubbedCaption); + } else if (settings.url == '/transcript/available_translations') { + return settings.success(['uk', 'de']); } else if (settings.url.match(/.+\/problem_get$/)) { return settings.success({ html: readFixtures('problem_content.html') @@ -265,6 +229,7 @@ .data(params); } + jasmine.stubRequests(); state = new Video('#example'); state.resizer = (function () { diff --git a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js index 995c89627ef528a4e092d9d8d626000fc983751a..b0cf54bb97470f27fb0b16c74034142230b9b673 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/general_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/general_spec.js @@ -181,31 +181,6 @@ }); }); - describe('youtubeId', function () { - beforeEach(function () { - loadFixtures('video.html'); - $.cookie.andReturn('1.0'); - state = new Video('#example'); - }); - - describe('with speed', function () { - it('return the video id for given speed', function () { - expect(state.youtubeId('0.50')) - .toEqual('7tqY6eQzVhE'); - expect(state.youtubeId('1.0')) - .toEqual('cogebirgzzM'); - expect(state.youtubeId('1.50')) - .toEqual('abcdefghijkl'); - }); - }); - - describe('without speed', function () { - it('return the video id for current speed', function () { - expect(state.youtubeId()).toEqual('abcdefghijkl'); - }); - }); - }); - describe('YouTube video in FireFox will cue first', function () { var oldUserAgent; @@ -368,84 +343,6 @@ }); }); - describe('setSpeed', function () { - - describe('YT', function () { - beforeEach(function () { - loadFixtures('video.html'); - state = new Video('#example'); - }); - - it('check mapping', function () { - var map = { - '0.75': '0.50', - '1.25': '1.50' - }; - - $.each(map, function(key, expected) { - state.setSpeed(key, true); - expect(state.speed).toBe(expected); - }); - }); - }); - describe('HTML5', function () { - beforeEach(function () { - loadFixtures('video_html5.html'); - state = new Video('#example'); - }); - - describe('when new speed is available', function () { - beforeEach(function () { - state.setSpeed('0.75', true); - }); - - it('set new speed', function () { - expect(state.speed).toEqual('0.75'); - }); - - it('save setting for new speed', function () { - - expect(state.storage.getItem('general_speed')).toBe('0.75'); - expect(state.storage.getItem('speed', true)).toBe('0.75'); - }); - }); - - describe('when new speed is not available', function () { - beforeEach(function () { - state.setSpeed('1.75'); - }); - - it('set speed to 1.0x', function () { - expect(state.speed).toEqual('1.0'); - }); - }); - - it('check mapping', function () { - var map = { - '0.25': '0.75', - '0.50': '0.75', - '2.0': '1.50' - }; - - $.each(map, function(key, expected) { - state.setSpeed(key, true); - expect(state.speed).toBe(expected); - }); - }); - }); - }); - - describe('getDuration', function () { - beforeEach(function () { - loadFixtures('video.html'); - state = new Video('#example'); - }); - - it('return duration for current video', function () { - expect(state.getDuration()).toEqual(400); - }); - }); - describe('log', function () { beforeEach(function () { loadFixtures('video_html5.html'); diff --git a/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js b/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js index 6177d4303c02c4aade0822170141f1890b52cc88..020de1f38440b6eea54ec81fd49f07fbe5ba4646 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/initialize_spec.js @@ -6,8 +6,14 @@ require( ['video/01_initialize.js'], function (Initialize) { describe('Initialize', function () { + var state = {}; + + afterEach(function () { + state = {}; + }); + describe('saveState function', function () { - var state, videoPlayerCurrentTime, newCurrentTime, speed; + var videoPlayerCurrentTime, newCurrentTime, speed; // We make sure that `currentTime` is a float. We need to test // that Math.round() is called. @@ -40,10 +46,6 @@ function (Initialize) { spyOn(Time, 'formatFull').andCallThrough(); }); - afterEach(function () { - state = undefined; - }); - it('data is not an object, async is true', function () { itSpec({ asyncVal: true, @@ -161,8 +163,211 @@ function (Initialize) { }); } }); - }); + describe('getCurrentLanguage', function () { + var msg; + + beforeEach(function () { + state.config = {}; + state.config.transcriptLanguages = { + 'de': 'German', + 'en': 'English', + 'uk': 'Ukrainian', + }; + }); + + it ('returns current language', function () { + var expected; + + state.lang = 'de'; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBe('de'); + }); + + msg = 'returns `en`, if language isn\'t available for the video'; + it (msg, function () { + var expected; + + state.lang = 'zh'; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBe('en'); + }); + + msg = 'returns any available language, if current and `en` ' + + 'languages aren\'t available for the video'; + it (msg, function () { + var expected; + + state.lang = 'zh'; + state.config.transcriptLanguages = { + 'de': 'German', + 'uk': 'Ukrainian', + }; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBe('uk'); + }); + + it ('returns `null`, if transcript unavailable', function () { + var expected; + + state.lang = 'zh'; + state.config.transcriptLanguages = {}; + expected = Initialize.prototype.getCurrentLanguage.call(state); + expect(expected).toBeNull(); + }); + }); + + describe('getDuration', function () { + beforeEach(function () { + state = { + speed: '1.50', + metadata: { + 'testId': { + duration: 400 + }, + 'videoId': { + duration: 100 + } + }, + videos: { + '1.0': 'testId', + '1.50': 'videoId' + }, + youtubeId: Initialize.prototype.youtubeId + }; + }); + + it('returns duration for current video', function () { + var expected = Initialize.prototype.getDuration.call(state); + + expect(expected).toEqual(100); + }); + + var msg = 'returns duration for the 1.0 speed as a fallback'; + it(msg, function () { + var expected; + + state.speed = '2.0'; + expected = Initialize.prototype.getDuration.call(state); + + expect(expected).toEqual(400); + }); + }); + + describe('youtubeId', function () { + beforeEach(function () { + state = { + speed: '1.50', + videos: { + '0.50': '7tqY6eQzVhE', + '1.0': 'cogebirgzzM', + '1.50': 'abcdefghijkl' + } + }; + }); + + describe('with speed', function () { + it('return the video id for given speed', function () { + $.each(state.videos, function(speed, videoId) { + var expected = Initialize.prototype.youtubeId.call( + state, speed + ); + + expect(videoId).toBe(expected); + }); + }); + }); + + describe('without speed', function () { + it('return the video id for current speed', function () { + var expected = Initialize.prototype.youtubeId.call(state); + + expect(expected).toEqual('abcdefghijkl'); + }); + }); + + describe('speed is absent in the list of video speeds', function () { + it('return the video id for 1.0x speed', function () { + var expected = Initialize.prototype.youtubeId.call(state, '0.0'); + + expect(expected).toEqual('cogebirgzzM'); + }); + }); + }); + + describe('setSpeed', function () { + describe('YT', function () { + beforeEach(function () { + state = { + speeds: ['0.25', '0.50', '1.0', '1.50', '2.0'], + storage: jasmine.createSpyObj('storage', ['setItem']) + }; + }); + + it('check mapping', function () { + var map = { + '0.75': '0.50', + '1.25': '1.50' + }; + + $.each(map, function(key, expected) { + Initialize.prototype.setSpeed.call(state, key); + expect(state.speed).toBe(expected); + }); + }); + }); + + describe('HTML5', function () { + beforeEach(function () { + state = { + speeds: ['0.75', '1.0', '1.25', '1.50'], + storage: jasmine.createSpyObj('storage', ['setItem']) + }; + }); + + describe('when new speed is available', function () { + beforeEach(function () { + Initialize.prototype.setSpeed.call(state, '0.75', true); + }); + + it('set new speed', function () { + expect(state.speed).toEqual('0.75'); + }); + + it('save setting for new speed', function () { + expect(state.storage.setItem.calls[0].args) + .toEqual(['speed', '0.75', true]); + + expect(state.storage.setItem.calls[1].args) + .toEqual(['general_speed', '0.75']); + }); + }); + + describe('when new speed is not available', function () { + beforeEach(function () { + Initialize.prototype.setSpeed.call(state, '1.75'); + }); + + it('set speed to 1.0x', function () { + expect(state.speed).toEqual('1.0'); + }); + }); + + it('check mapping', function () { + var map = { + '0.25': '0.75', + '0.50': '0.75', + '2.0': '1.50' + }; + + $.each(map, function(key, expected) { + Initialize.prototype.setSpeed.call(state, key, true); + expect(state.speed).toBe(expected); + }); + }); + }); + }); + }); }); }(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 6ad591d74eada1b1c47d1184982890d045b436eb..a9e5236c93bc5d6ed023d63e23382355343d9260 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -9,6 +9,7 @@ state = jasmine.initializePlayer(); videoControl = state.videoControl; + $.fn.scrollTo.reset(); }); afterEach(function () { @@ -28,12 +29,7 @@ describe('always', function () { beforeEach(function () { spyOn($, 'ajaxWithPrefix').andCallThrough(); - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); it('create the caption element', function () { @@ -64,8 +60,12 @@ runs(function () { expect($.ajaxWithPrefix).toHaveBeenCalledWith({ - url: state.videoCaption.captionURL(), + url: '/transcript/translation', notifyOnError: false, + data: { + videoId: 'Z5KLxerq05Y', + language: 'en' + }, success: jasmine.any(Function), error: jasmine.any(Function) }); @@ -100,23 +100,98 @@ expect($('.subtitles')).toHandleWith( 'DOMMouseScroll', state.videoCaption.onMovement ); + }); + + it('bind the scroll', function () { + expect($('.subtitles')) + .toHandleWith('scroll', state.videoControl.showControls); + }); + + }); + + describe('renderLanguageMenu', function () { + describe('is rendered', function () { + it('if languages more than 1', function () { + state = jasmine.initializePlayer(); + var transcripts = state.config.transcriptLanguages, + langCodes = _.keys(transcripts), + langLabels = _.values(transcripts); + + expect($('.langs-list')).toExist(); + expect($('.langs-list')).toHandle('click'); + + + $('.langs-list li').each(function(index) { + var code = $(this).data('lang-code'), + link = $(this).find('a'), + label = link.text(); + + expect(code).toBeInArray(langCodes); + expect(label).toBeInArray(langLabels); + }); + }); + + it('when clicking on link with new language', function () { + state = jasmine.initializePlayer(); + var Caption = state.videoCaption, + link = $('.langs-list li[data-lang-code="de"] a'); + + spyOn(Caption, 'fetchCaption'); + spyOn(state.storage, 'setItem'); + + state.lang = 'en'; + link.trigger('click'); + + expect(Caption.fetchCaption).toHaveBeenCalled(); + expect(state.lang).toBe('de'); + expect(state.storage.setItem) + .toHaveBeenCalledWith('language', 'de'); + expect($('.langs-list li.active').length).toBe(1); + }); + + it('when clicking on link with current language', function () { + state = jasmine.initializePlayer(); + var Caption = state.videoCaption, + link = $('.langs-list li[data-lang-code="en"] a'); + + spyOn(Caption, 'fetchCaption'); + spyOn(state.storage, 'setItem'); + + state.lang = 'en'; + link.trigger('click'); + + expect(Caption.fetchCaption).not.toHaveBeenCalled(); + expect(state.lang).toBe('en'); + expect(state.storage.setItem) + .not.toHaveBeenCalledWith('language', 'en'); + expect($('.langs-list li.active').length).toBe(1); + }); + + it('open the language toggle on hover', function () { + state = jasmine.initializePlayer(); + $('.lang').mouseenter(); + expect($('.lang')).toHaveClass('open'); + $('.lang').mouseleave(); + expect($('.lang')).not.toHaveClass('open'); + }); }); - it('bind the scroll', function () { - expect($('.subtitles')) - .toHandleWith('scroll', state.videoCaption.autoShowCaptions); - expect($('.subtitles')) - .toHandleWith('scroll', videoControl.showControls); + describe('is not rendered', function () { + it('if just 1 language', function () { + state = jasmine.initializePlayer(null, { + 'transcriptLanguages': {"en": "English"} + }); + + expect($('.langs-list')).not.toExist(); + expect($('.lang')).not.toHandle('mouseenter'); + expect($('.lang')).not.toHandle('mouseleave'); + }); }); }); describe('when on a non touch-based device', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); it('render the caption', function () { @@ -142,35 +217,46 @@ .toBe(true); }); + it('bind all the caption link', function () { + var handlerList = ['captionMouseOverOut', 'captionClick', + 'captionMouseDown', 'captionFocus', 'captionBlur', + 'captionKeyDown' + ]; + + $.each(handlerList, function(index, handler) { + spyOn(state.videoCaption, handler); + }); $('.subtitles li[data-index]').each( function (index, link) { - expect($(link)).toHandleWith( - 'mouseover', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mouseout', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mousedown', state.videoCaption.captionMouseDown - ); - expect($(link)).toHandleWith( - 'click', state.videoCaption.captionClick - ); - expect($(link)).toHandleWith( - 'focus', state.videoCaption.captionFocus - ); - expect($(link)).toHandleWith( - 'blur', state.videoCaption.captionBlur - ); - expect($(link)).toHandleWith( - 'keydown', state.videoCaption.captionKeyDown - ); + + $(link).trigger('mouseover'); + expect(state.videoCaption.captionMouseOverOut).toHaveBeenCalled(); + + state.videoCaption.captionMouseOverOut.reset(); + $(link).trigger('mouseout'); + expect(state.videoCaption.captionMouseOverOut).toHaveBeenCalled(); + + $(this).click(); + expect(state.videoCaption.captionClick).toHaveBeenCalled(); + + $(this).trigger('mousedown'); + expect(state.videoCaption.captionMouseDown).toHaveBeenCalled(); + + $(this).trigger('focus'); + expect(state.videoCaption.captionFocus).toHaveBeenCalled(); + + $(this).trigger('blur'); + expect(state.videoCaption.captionBlur).toHaveBeenCalled(); + + $(this).trigger('keydown'); + expect(state.videoCaption.captionKeyDown).toHaveBeenCalled(); }); }); it('set rendered to true', function () { + state = jasmine.initializePlayer(); expect(state.videoCaption.rendered).toBeTruthy(); }); }); @@ -180,9 +266,6 @@ window.onTouchBasedDevice.andReturn(['iPad']); state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - $.fn.scrollTo.reset(); }); @@ -200,12 +283,9 @@ describe('when no captions file was specified', function () { beforeEach(function () { - loadFixtures('video_all.html'); - - // Unspecify the captions file. - $('#example').find('#video_id').data('sub', ''); - - state = new Video('#example'); + state = jasmine.initializePlayer('video_all.html', { + 'sub': '' + }); }); it('captions panel is not shown', function () { @@ -218,6 +298,7 @@ beforeEach(function () { jasmine.Clock.useMock(); spyOn(window, 'clearTimeout'); + state = jasmine.initializePlayer(); }); describe('when cursor is outside of the caption box', function () { @@ -313,8 +394,254 @@ }); }); + it('reRenderCaption', function () { + var Caption = state.videoCaption, + li; + + Caption.captions = ['test']; + Caption.start = [500]; + + spyOn(Caption, 'addPaddings'); + + Caption.reRenderCaption(); + li = $('ol.subtitles li'); + + expect(Caption.addPaddings).toHaveBeenCalled(); + expect(li.length).toBe(1); + expect(li).toHaveData('start', '500'); + }); + + describe('fetchCaption', function () { + var Caption, msg; + + beforeEach(function () { + state = jasmine.initializePlayer(); + Caption = state.videoCaption; + spyOn($, 'ajaxWithPrefix').andCallThrough(); + spyOn(Caption, 'reRenderCaption'); + spyOn(Caption, 'renderCaption'); + spyOn(Caption, 'bindHandlers'); + spyOn(Caption, 'updatePlayTime'); + spyOn(Caption, 'hideCaptions'); + spyOn(state, 'youtubeId').andReturn('Z5KLxerq05Y'); + }); + + it('do not fetch captions, if 1.0 speed is absent', function () { + state.youtubeId.andReturn(void(0)); + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).not.toHaveBeenCalled(); + expect(Caption.hideCaptions).not.toHaveBeenCalled(); + }); + + it('show caption on language change', function () { + Caption.loaded = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(false); + }); + + msg = 'use cookie to show/hide captions if they have not been ' + + 'loaded yet'; + it(msg, function () { + Caption.loaded = false; + state.hide_captions = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(false, false); + + Caption.loaded = false; + Caption.hideCaptions.reset(); + state.hide_captions = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(true, false); + }); + + it('on success: on touch devices', function () { + state.isTouch = true; + Caption.loaded = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).not.toHaveBeenCalled(); + expect(Caption.reRenderCaption).not.toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + msg = 'on success: change language on touch devices when ' + + 'captions have not been rendered yet'; + it(msg, function () { + state.isTouch = true; + Caption.loaded = true; + Caption.rendered = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).not.toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).not.toHaveBeenCalled(); + expect(Caption.reRenderCaption).not.toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + it('on success: re-render on touch devices', function () { + state.isTouch = true; + Caption.loaded = true; + Caption.rendered = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).not.toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).toHaveBeenCalled(); + expect(Caption.reRenderCaption).toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + it('on success: rendered correct', function () { + Caption.loaded = false; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).toHaveBeenCalled(); + expect(Caption.renderCaption).toHaveBeenCalled(); + expect(Caption.updatePlayTime).not.toHaveBeenCalled(); + expect(Caption.reRenderCaption).not.toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + it('on success: re-rendered correct', function () { + Caption.loaded = true; + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.bindHandlers).not.toHaveBeenCalled(); + expect(Caption.renderCaption).not.toHaveBeenCalled(); + expect(Caption.updatePlayTime).toHaveBeenCalled(); + expect(Caption.reRenderCaption).toHaveBeenCalled(); + expect(Caption.loaded).toBeTruthy(); + }); + + msg = 'on error: captions are hidden if there are no transcripts'; + it(msg, function () { + spyOn(Caption, 'fetchAvailableTranslations'); + $.ajax.andCallFake(function (settings) { + settings.error([]); + }); + + state.config.transcriptLanguages = {}; + + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchAvailableTranslations).not.toHaveBeenCalled(); + expect(Caption.hideCaptions.mostRecentCall.args) + .toEqual([true, false]); + expect(Caption.hideSubtitlesEl).toBeHidden(); + }); + + msg = 'on error: fetch available translations if there are ' + + 'additional transcripts'; + xit(msg, function () { + $.ajax + .andCallFake(function (settings) { + settings.error([]); + }); + + state.config.transcriptLanguages = { + 'en': 'English', + 'uk': 'Ukrainian', + }; + + spyOn(Caption, 'fetchAvailableTranslations'); + Caption.fetchCaption(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchAvailableTranslations).toHaveBeenCalled(); + expect(Caption.hideCaptions).not.toHaveBeenCalled(); + }); + }); + + describe('fetchAvailableTranslations', function () { + var Caption, msg; + + beforeEach(function () { + state = jasmine.initializePlayer(); + Caption = state.videoCaption; + spyOn($, 'ajaxWithPrefix').andCallThrough(); + spyOn(Caption, 'hideCaptions'); + spyOn(Caption, 'fetchCaption'); + spyOn(Caption, 'renderLanguageMenu'); + }); + + it('request created with correct parameters', function () { + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalledWith({ + url: '/transcript/available_translations', + notifyOnError: false, + success: jasmine.any(Function), + error: jasmine.any(Function) + }); + }); + + msg = 'on succes: language menu is rendered if translations available'; + it(msg, function () { + state.config.transcriptLanguages = { + 'en': 'English', + 'uk': 'Ukrainian', + 'de': 'German' + }; + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchCaption).toHaveBeenCalled(); + expect(state.config.transcriptLanguages).toEqual({ + 'uk': 'Ukrainian', + 'de': 'German' + }); + expect(Caption.renderLanguageMenu).toHaveBeenCalledWith({ + 'uk': 'Ukrainian', + 'de': 'German' + }); + }); + + msg = 'on succes: language menu isn\'t rendered if translations unavailable'; + it(msg, function () { + state.config.transcriptLanguages = { + 'en': 'English', + 'ru': 'Russian' + }; + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.fetchCaption).not.toHaveBeenCalled(); + expect(state.config.transcriptLanguages).toEqual({}); + expect(Caption.renderLanguageMenu).not.toHaveBeenCalled(); + }); + + msg = 'on error: captions are hidden if there are no transcript'; + it(msg, function () { + $.ajax.andCallFake(function (settings) { + settings.error(); + }); + Caption.fetchAvailableTranslations(); + + expect($.ajaxWithPrefix).toHaveBeenCalled(); + expect(Caption.hideCaptions).toHaveBeenCalledWith(true, false); + expect(Caption.hideSubtitlesEl).toBeHidden(); + }); + }); + describe('search', function () { it('return a correct caption index', function () { + state = jasmine.initializePlayer(); expect(state.videoCaption.search(0)).toEqual(-1); expect(state.videoCaption.search(3120)).toEqual(1); expect(state.videoCaption.search(6270)).toEqual(2); @@ -328,13 +655,7 @@ describe('when the caption was not rendered', function () { beforeEach(function () { window.onTouchBasedDevice.andReturn(['iPad']); - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - state.videoCaption.play(); }); @@ -359,34 +680,6 @@ expect($('.subtitles li:last')).toBe('.spacing'); }); - it('bind all the caption link', function () { - $('.subtitles li[data-index]').each( - function (index, link) { - - expect($(link)).toHandleWith( - 'mouseover', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mouseout', state.videoCaption.captionMouseOverOut - ); - expect($(link)).toHandleWith( - 'mousedown', state.videoCaption.captionMouseDown - ); - expect($(link)).toHandleWith( - 'click', state.videoCaption.captionClick - ); - expect($(link)).toHandleWith( - 'focus', state.videoCaption.captionFocus - ); - expect($(link)).toHandleWith( - 'blur', state.videoCaption.captionBlur - ); - expect($(link)).toHandleWith( - 'keydown', state.videoCaption.captionKeyDown - ); - }); - }); - it('set rendered to true', function () { expect(state.videoCaption.rendered).toBeTruthy(); }); @@ -399,6 +692,7 @@ describe('pause', function () { beforeEach(function () { + state = jasmine.initializePlayer(); state.videoCaption.playing = true; state.videoCaption.pause(); }); @@ -409,6 +703,10 @@ }); describe('updatePlayTime', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + }); + describe('when the video speed is 1.0x', function () { beforeEach(function () { state.videoSpeedControl.currentSpeed = '1.0'; @@ -475,11 +773,7 @@ describe('resize', function () { beforeEach(function () { state = jasmine.initializePlayer(); - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - $('.subtitles li[data-index=1]').addClass('current'); state.videoCaption.resize(); }); @@ -542,10 +836,6 @@ xdescribe('scrollCaption', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); describe('when frozen', function () { @@ -590,6 +880,10 @@ // Disabled 10/9/13 due to flakiness in master xdescribe('seekPlayer', function () { + beforeEach(function () { + state = jasmine.initializePlayer(); + }); + describe('when the video speed is 1.0x', function () { beforeEach(function () { state.videoSpeedControl.currentSpeed = '1.0'; @@ -603,12 +897,6 @@ describe('when the video speed is not 1.0x', function () { beforeEach(function () { - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - state.videoSpeedControl.currentSpeed = '0.75'; $('.subtitles li[data-start="14910"]').trigger('click'); }); @@ -622,12 +910,6 @@ function () { beforeEach(function () { - state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - state.videoSpeedControl.currentSpeed = '0.75'; state.currentPlayerMode = 'flash'; $('.subtitles li[data-start="14910"]').trigger('click'); @@ -642,11 +924,6 @@ describe('toggle', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); - spyOn(state.videoPlayer, 'log'); $('.subtitles li[data-index=1]').addClass('current'); }); @@ -722,10 +999,6 @@ describe('caption accessibility', function () { beforeEach(function () { state = jasmine.initializePlayer(); - - videoControl = state.videoControl; - - $.fn.scrollTo.reset(); }); describe('when getting focus through TAB key', function () { 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 3dfba3008d7613450dde471d41698b8583202886..ec4f62f7e2650fd5021d91ab72d85855cd5e1b4d 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 @@ -1,4 +1,10 @@ -(function (undefined) { +(function (requirejs, require, define, undefined) { + +'use strict'; + +require( +['video/03_video_player.js'], +function (VideoPlayer) { describe('VideoPlayer', function () { var state, oldOTBD; @@ -11,7 +17,9 @@ afterEach(function () { $('source').remove(); window.onTouchBasedDevice = oldOTBD; - state.storage.clear(); + if (state.storage) { + state.storage.clear(); + } }); describe('constructor', function () { @@ -39,8 +47,8 @@ expect(state.videoCaption).toBeDefined(); expect(state.youtubeId('1.0')).toEqual('Z5KLxerq05Y'); expect(state.speed).toEqual('1.50'); - expect(state.config.captionAssetPath) - .toEqual('/static/subs/'); + expect(state.config.transcriptTranslationUrl) + .toEqual('/transcript/translation'); }); it('create video speed control', function () { @@ -307,7 +315,7 @@ }); waitsFor(function () { - duration = state.videoPlayer.duration(); + var duration = state.videoPlayer.duration(); return duration > 0 && state.videoPlayer.isPlaying(); }, 'video begins playing', WAIT_TIMEOUT); @@ -379,85 +387,33 @@ }); }); - describe('onSpeedChange', function () { + describe('when the video is not playing', function () { beforeEach(function () { state = jasmine.initializePlayer(); - state.videoEl = $('video, iframe'); - spyOn(state.videoPlayer, 'updatePlayTime').andCallThrough(); spyOn(state, 'setSpeed').andCallThrough(); spyOn(state.videoPlayer, 'log').andCallThrough(); spyOn(state.videoPlayer.player, 'setPlaybackRate').andCallThrough(); + spyOn(state.videoPlayer, 'setPlaybackRate').andCallThrough(); }); - describe('always', function () { - beforeEach(function () { - - state.videoPlayer.currentTime = 60; - state.videoPlayer.onSpeedChange('0.75', false); - }); - - it('check if speed_change_video is logged', function () { - expect(state.videoPlayer.log).toHaveBeenCalledWith( - 'speed_change_video', - { - current_time: state.videoPlayer.currentTime, - old_speed: '1.50', - new_speed: '0.75' - } - ); - }); - - it('convert the current time to the new speed', function () { - expect(state.videoPlayer.currentTime).toEqual(60); - }); - - it('set video speed to the new speed', function () { - expect(state.setSpeed).toHaveBeenCalledWith('0.75', true); - }); - }); - - describe('when the video is playing', function () { - beforeEach(function () { - state.videoPlayer.currentTime = 60; - state.videoPlayer.play(); - state.videoPlayer.onSpeedChange('0.75', false); - }); - - it('trigger updatePlayTime event', function () { - expect(state.videoPlayer.player.setPlaybackRate) - .toHaveBeenCalledWith('0.75'); - }); + it('video has a correct speed', function () { + state.speed = '2.0'; + state.videoPlayer.onPlay(); + expect(state.videoPlayer.setPlaybackRate) + .toHaveBeenCalledWith('2.0'); + state.videoPlayer.onPlay(); + expect(state.videoPlayer.setPlaybackRate.calls.length) + .toEqual(1); }); - describe('when the video is not playing', function () { - beforeEach(function () { - state.videoPlayer.onSpeedChange('0.75', false); - }); - - it('trigger updatePlayTime event', function () { - expect(state.videoPlayer.player.setPlaybackRate) - .toHaveBeenCalledWith('0.75'); - }); - - it('video has a correct speed', function () { - spyOn(state.videoPlayer, 'onSpeedChange'); - state.speed = '2.0'; - state.videoPlayer.onPlay(); - expect(state.videoPlayer.onSpeedChange) - .toHaveBeenCalledWith('2.0'); - state.videoPlayer.onPlay(); - expect(state.videoPlayer.onSpeedChange.calls.length).toEqual(1); - }); - - it('video has a correct volume', function () { - spyOn(state.videoPlayer.player, 'setVolume'); - state.currentVolume = '0.26'; - state.videoPlayer.onPlay(); - expect(state.videoPlayer.player.setVolume) - .toHaveBeenCalledWith('0.26'); - }); + it('video has a correct volume', function () { + spyOn(state.videoPlayer.player, 'setVolume'); + state.currentVolume = '0.26'; + state.videoPlayer.onPlay(); + expect(state.videoPlayer.player.setVolume) + .toHaveBeenCalledWith('0.26'); }); }); @@ -789,7 +745,7 @@ state.el.addClass('video-fullscreen'); state.videoControl.fullScreenState = true; - isFullScreen = true; + state.videoControl.isFullScreen = true; state.videoControl.fullScreenEl.attr('title', 'Exit-fullscreen'); state.videoControl.toggleFullScreen(jQuery.Event('click')); @@ -931,20 +887,6 @@ }); }); - describe('playback rate', function () { - beforeEach(function () { - state = jasmine.initializePlayer(); - - state.videoEl = $('video, iframe'); - - state.videoPlayer.player.setPlaybackRate(1.5); - }); - - it('set the player playback rate', function () { - expect(state.videoPlayer.player.video.playbackRate).toEqual(1.5); - }); - }); - describe('volume', function () { beforeEach(function () { state = jasmine.initializePlayer(); @@ -1023,7 +965,7 @@ }); waitsFor(function () { - duration = state.videoPlayer.duration(); + var duration = state.videoPlayer.duration(); return duration > 0 && state.videoPlayer.isPlaying(); },'Video does not play.' , WAIT_TIMEOUT); @@ -1034,6 +976,108 @@ }); }); }); + + describe('onSpeedChange', function () { + beforeEach(function () { + state = { + el: $(document), + speed: '1.50', + setSpeed: jasmine.createSpy(), + saveState: jasmine.createSpy(), + videoPlayer: { + currentTime: 60, + log: jasmine.createSpy(), + updatePlayTime: jasmine.createSpy(), + setPlaybackRate: jasmine.createSpy(), + player: jasmine.createSpyObj('player', ['setPlaybackRate']) + } + }; + }); + + describe('always', function () { + it('check if speed_change_video is logged', function () { + VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false); + expect(state.videoPlayer.log).toHaveBeenCalledWith( + 'speed_change_video', + { + current_time: state.videoPlayer.currentTime, + old_speed: '1.50', + new_speed: '0.75' + } + ); + }); + + it('convert the current time to the new speed', function () { + state.currentPlayerMode = 'flash'; + VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false); + expect(state.videoPlayer.currentTime).toBe('120.000'); + }); + + it('set video speed to the new speed', function () { + VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false); + expect(state.setSpeed).toHaveBeenCalledWith('0.75', true); + expect(state.saveState).toHaveBeenCalledWith(true, { + speed: '0.75' + }); + expect(state.videoPlayer.setPlaybackRate) + .toHaveBeenCalledWith('0.75'); + }); + }); + }); + + describe('setPlaybackRate', function () { + beforeEach(function () { + state = { + youtubeId: jasmine.createSpy().andReturn('videoId'), + videoPlayer: { + currentTime: 60, + isPlaying: jasmine.createSpy(), + updatePlayTime: jasmine.createSpy(), + setPlaybackRate: jasmine.createSpy(), + player: jasmine.createSpyObj('player', [ + 'setPlaybackRate', 'loadVideoById', 'cueVideoById' + ]) + } + }; + }); + + it('in Flash mode and video is playing', function () { + state.currentPlayerMode = 'flash'; + state.videoPlayer.isPlaying.andReturn(true); + VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); + expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.player.loadVideoById) + .toHaveBeenCalledWith('videoId', 60); + }); + + it('in Flash mode and video not started', function () { + state.currentPlayerMode = 'flash'; + state.videoPlayer.isPlaying.andReturn(false); + VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); + expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.player.cueVideoById) + .toHaveBeenCalledWith('videoId', 60); + }); + + it('in HTML5 mode', function () { + state.currentPlayerMode = 'html5'; + VideoPlayer.prototype.setPlaybackRate.call(state, '0.75'); + expect(state.videoPlayer.player.setPlaybackRate).toHaveBeenCalledWith('0.75'); + }); + + it('Youtube video in FF, with new speed equal 1.0', function () { + state.currentPlayerMode = 'html5'; + state.videoType = 'youtube'; + state.browserIsFirefox = true; + + state.videoPlayer.isPlaying.andReturn(false); + VideoPlayer.prototype.setPlaybackRate.call(state, '1.0'); + expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(60); + expect(state.videoPlayer.player.cueVideoById) + .toHaveBeenCalledWith('videoId', 60); + }); + }); }); +}); -}).call(this); +}(RequireJS.requirejs, RequireJS.require, RequireJS.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 8ab4e0b7857f4cee81a88642668ad363895bbec8..7cea30aa9e5f327786b10952d2d4183da9b3f1b1 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -57,9 +57,11 @@ function (VideoPlayer, VideoStorage) { }); }); }, + methodsDict = { bindTo: bindTo, fetchMetadata: fetchMetadata, + getCurrentLanguage: getCurrentLanguage, getDuration: getDuration, getVideoMetadata: getVideoMetadata, initialize: initialize, @@ -305,6 +307,11 @@ function (VideoPlayer, VideoStorage) { value || '1.0'; }, + 'transcriptLanguage': function (value) { + return storage.getItem('language') || + value || + 'en'; + }, 'ytTestTimeout': function (value) { value = parseInt(value, 10); @@ -432,6 +439,7 @@ function (VideoPlayer, VideoStorage) { this.config.endTime = null; } + this.lang = this.config.transcriptLanguage; this.speed = Number( this.config.speed || this.config.generalSpeed ).toFixed(2).replace(/\.00$/, '.0'); @@ -631,17 +639,16 @@ function (VideoPlayer, VideoStorage) { function setSpeed(newSpeed, updateStorage) { // Possible speeds for each player type. - // flash = [0.75, 1, 1.25, 1.5] - // html5 = [0.75, 1, 1.25, 1.5] - // youtube html5 = [0.25, 0.5, 1, 1.5, 2] + // HTML5 = [0.75, 1, 1.25, 1.5] + // Youtube Flash = [0.75, 1, 1.25, 1.5] + // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2] var map = { - '0.25': '0.75', - '0.50': '0.75', - '0.75': '0.50', - '1.25': '1.50', - '2.0': '1.50' - }, - useSession = true; + '0.25': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.75': '0.50', // HTML5 or Youtube Flash -> Youtube HTML5 + '1.25': '1.50', // HTML5 or Youtube Flash -> Youtube HTML5 + '2.0': '1.50' // Youtube HTML5 -> HTML5 or Youtube Flash + }; if (_.contains(this.speeds, newSpeed)) { this.speed = newSpeed; @@ -712,6 +719,24 @@ function (VideoPlayer, VideoStorage) { } } + function getCurrentLanguage() { + var keys = _.keys(this.config.transcriptLanguages); + + if (keys.length) { + if (!_.contains(keys, this.lang)) { + if (_.contains(keys, 'en')) { + this.lang = 'en'; + } else { + this.lang = keys.pop(); + } + } + } else { + return null; + } + + return this.lang; + } + /* * The trigger() function will assume that the @objChain is a complete * chain with a method (function) at the end. It will call this function. diff --git a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js index bd80c92aeaa904a279dd04029b345c7b508e507c..b225d0783ef356d0d2d1ce49918b1263c2141c25 100644 --- a/common/lib/xmodule/xmodule/js/src/video/03_video_player.js +++ b/common/lib/xmodule/xmodule/js/src/video/03_video_player.js @@ -5,30 +5,16 @@ define( 'video/03_video_player.js', ['video/02_html5_video.js', 'video/00_resizer.js'], function (HTML5Video, Resizer) { - var dfd = $.Deferred(); - - // VideoPlayer() function - what this module "exports". - return function (state) { - - state.videoPlayer = {}; - - _makeFunctionsPublic(state); - _initialize(state); - // No callbacks to DOM events (click, mousemove, etc.). - - return dfd.promise(); - }; - - // *************************************************************** - // Private functions start here. - // *************************************************************** - - // function _makeFunctionsPublic(state) - // - // Functions which will be accessible via 'state' object. When called, - // these functions will get the 'state' object as a context. - function _makeFunctionsPublic(state) { - var methodsDict = { + var dfd = $.Deferred(), + VideoPlayer = function (state) { + state.videoPlayer = {}; + _makeFunctionsPublic(state); + _initialize(state); + // No callbacks to DOM events (click, mousemove, etc.). + + return dfd.promise(); + }, + methodsDict = { duration: duration, handlePlaybackQualityChange: handlePlaybackQualityChange, isPlaying: isPlaying, @@ -46,10 +32,25 @@ function (HTML5Video, Resizer) { onVolumeChange: onVolumeChange, pause: pause, play: play, + setPlaybackRate: setPlaybackRate, update: update, updatePlayTime: updatePlayTime }; + VideoPlayer.prototype = methodsDict; + + // VideoPlayer() function - what this module "exports". + return VideoPlayer; + + // *************************************************************** + // Private functions start here. + // *************************************************************** + + // function _makeFunctionsPublic(state) + // + // Functions which will be accessible via 'state' object. When called, + // these functions will get the 'state' object as a context. + function _makeFunctionsPublic(state) { state.bindTo(methodsDict, state.videoPlayer, state); } @@ -70,7 +71,7 @@ function (HTML5Video, Resizer) { $(window).on('unload', state.saveState); if (state.currentPlayerMode !== 'flash') { - state.videoPlayer.onSpeedChange(state.speed); + state.videoPlayer.setPlaybackRate(state.speed); } state.videoPlayer.player.setVolume(state.currentVolume); }); @@ -325,33 +326,10 @@ function (HTML5Video, Resizer) { } } - function onSpeedChange(newSpeed) { + function setPlaybackRate(newSpeed) { var time = this.videoPlayer.currentTime, methodName, youtubeId; - if (this.currentPlayerMode === 'flash') { - this.videoPlayer.currentTime = Time.convert( - time, - parseFloat(this.speed), - newSpeed - ); - } - - newSpeed = parseFloat(newSpeed).toFixed(2).replace(/\.00$/, '.0'); - - if (this.speed != newSpeed) { - this.videoPlayer.log( - 'speed_change_video', - { - current_time: time, - old_speed: this.speed, - new_speed: newSpeed - } - ); - } - - this.setSpeed(newSpeed, true); - if ( this.currentPlayerMode === 'html5' && !( @@ -377,7 +355,33 @@ function (HTML5Video, Resizer) { this.videoPlayer.player[methodName](youtubeId, time); this.videoPlayer.updatePlayTime(time); } + } + + function onSpeedChange(newSpeed) { + var time = this.videoPlayer.currentTime, + isFlash = this.currentPlayerMode === 'flash'; + if (isFlash) { + this.videoPlayer.currentTime = Time.convert( + time, + parseFloat(this.speed), + newSpeed + ); + } + + newSpeed = parseFloat(newSpeed).toFixed(2).replace(/\.00$/, '.0'); + + this.videoPlayer.log( + 'speed_change_video', + { + current_time: time, + old_speed: this.speed, + new_speed: newSpeed + } + ); + + this.setSpeed(newSpeed, true); + this.videoPlayer.setPlaybackRate(newSpeed); this.el.trigger('speedchange', arguments); this.saveState(true, { speed: newSpeed }); diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index edea7bcdba081699bd2c7c7ea93ebdbc50bb4538..06ff56e180dc8ec18a7bf05e74a2538d096c4175 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -43,8 +43,7 @@ function () { // these functions will get the 'state' object as a context. function _makeFunctionsPublic(state) { var methodsDict = { - autoHideCaptions: autoHideCaptions, - autoShowCaptions: autoShowCaptions, + addPaddings: addPaddings, bindHandlers: bindHandlers, bottomSpacingHeight: bottomSpacingHeight, calculateOffset: calculateOffset, @@ -55,8 +54,8 @@ function () { captionKeyDown: captionKeyDown, captionMouseDown: captionMouseDown, captionMouseOverOut: captionMouseOverOut, - captionURL: captionURL, fetchCaption: fetchCaption, + fetchAvailableTranslations: fetchAvailableTranslations, hideCaptions: hideCaptions, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, @@ -65,6 +64,8 @@ function () { play: play, renderCaption: renderCaption, renderElements: renderElements, + renderLanguageMenu: renderLanguageMenu, + reRenderCaption: reRenderCaption, resize: resize, scrollCaption: scrollCaption, search: search, @@ -105,14 +106,24 @@ function () { * and the CC button will be hidden. */ function renderElements() { - this.videoCaption.loaded = false; + var Caption = this.videoCaption, + languages = this.config.transcriptLanguages; - this.videoCaption.subtitlesEl = this.el.find('ol.subtitles'); - this.videoCaption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); + Caption.loaded = false; + Caption.subtitlesEl = this.el.find('ol.subtitles'); + Caption.container = this.el.find('.lang'); + Caption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); - if (!this.videoCaption.fetchCaption()) { - this.videoCaption.hideCaptions(true); - this.videoCaption.hideSubtitlesEl.hide(); + if (_.keys(languages).length) { + Caption.renderLanguageMenu(languages); + + if (!Caption.fetchCaption()) { + Caption.hideCaptions(true); + Caption.hideSubtitlesEl.hide(); + } + } else { + Caption.hideCaptions(true, false); + Caption.hideSubtitlesEl.hide(); } } @@ -121,64 +132,77 @@ function () { // Bind any necessary function callbacks to DOM events (click, // mousemove, etc.). function bindHandlers() { - $(window).bind('resize', this.videoCaption.resize); - this.videoCaption.hideSubtitlesEl.on( - 'click', this.videoCaption.toggle - ); + var self = this, + Caption = this.videoCaption; - this.videoCaption.subtitlesEl - .on( - 'mouseenter', - this.videoCaption.onMouseEnter - ).on( - 'mouseleave', - this.videoCaption.onMouseLeave - ).on( - 'mousemove', - this.videoCaption.onMovement - ).on( - 'mousewheel', - this.videoCaption.onMovement - ).on( - 'DOMMouseScroll', - this.videoCaption.onMovement - ); + $(window).bind('resize', Caption.resize); + Caption.hideSubtitlesEl.on({ + 'click': Caption.toggle + }); - if ((this.videoType === 'html5') && (this.config.autohideHtml5)) { - this.el.on({ - mousemove: this.videoCaption.autoShowCaptions, - keydown: this.videoCaption.autoShowCaptions + Caption.subtitlesEl.on({ + mouseenter: Caption.onMouseEnter, + mouseleave: Caption.onMouseLeave, + mousemove: Caption.onMovement, + mousewheel: Caption.onMovement, + DOMMouseScroll: Caption.onMovement + }); + + if (Caption.showLanguageMenu) { + Caption.container.on({ + mouseenter: onContainerMouseEnter, + mouseleave: onContainerMouseLeave }); + } - // Moving slider on subtitles is not a mouse move, but captions and - // controls should be shown. - this.videoCaption.subtitlesEl - .on( - 'scroll', this.videoCaption.autoShowCaptions - ) - .on( - 'scroll', this.videoControl.showControls - ); - } else if (!this.config.autohideHtml5) { - this.videoCaption.subtitlesEl.on({ - keydown: this.videoCaption.autoShowCaptions, - focus: this.videoCaption.autoShowCaptions, + this.el.on('speedchange', function () { + if (self.currentPlayerMode === 'flash') { + Caption.fetchCaption(); + } + }); + + if ((this.videoType === 'html5') && (this.config.autohideHtml5)) { + Caption.subtitlesEl.on('scroll', this.videoControl.showControls); + } + } - // Moving slider on subtitles is not a mouse move, but captions - // should not be auto-hidden. - scroll: this.videoCaption.autoShowCaptions, + function onContainerMouseEnter(event) { + event.preventDefault(); - mouseout: this.videoCaption.autoHideCaptions, - blur: this.videoCaption.autoHideCaptions - }); + $(event.currentTarget).addClass('open'); + } - this.videoCaption.hideSubtitlesEl.on({ - mousemove: this.videoCaption.autoShowCaptions, + function onContainerMouseLeave(event) { + event.preventDefault(); - mouseout: this.videoCaption.autoHideCaptions, - blur: this.videoCaption.autoHideCaptions - }); + $(event.currentTarget).removeClass('open'); + } + + function onMouseEnter() { + if (this.videoCaption.frozen) { + clearTimeout(this.videoCaption.frozen); } + + this.videoCaption.frozen = setTimeout( + this.videoCaption.onMouseLeave, + this.config.captionsFreezeTime + ); + } + + function onMouseLeave() { + if (this.videoCaption.frozen) { + clearTimeout(this.videoCaption.frozen); + } + + this.videoCaption.frozen = null; + + if (this.videoCaption.playing) { + this.videoCaption.scrollCaption(); + } + } + + function onMovement() { + this.videoCaption.onMouseEnter(); } /** @@ -201,8 +225,8 @@ function () { * specified. */ function fetchCaption() { - var _this = this; - + var self = this, + Caption = self.videoCaption; // Check whether the captions file was specified. This is the point // where we either stop with the caption panel (so that a white empty // panel to the right of the video will not be shown), or carry on @@ -211,30 +235,50 @@ function () { return false; } - this.videoCaption.hideCaptions(this.hide_captions); + if (Caption.loaded) { + Caption.hideCaptions(false); + } else { + Caption.hideCaptions(this.hide_captions, false); + } + + if (Caption.fetchXHR && Caption.fetchXHR.abort) { + Caption.fetchXHR.abort(); + } // Fetch the captions file. If no file was specified, or if an error // occurred, then we hide the captions panel, and the "CC" button - $.ajaxWithPrefix({ - url: _this.videoCaption.captionURL(), + Caption.fetchXHR = $.ajaxWithPrefix({ + url: self.config.transcriptTranslationUrl, notifyOnError: false, + data: { + videoId: this.youtubeId(), + language: this.getCurrentLanguage() + }, success: function (captions) { - _this.videoCaption.captions = captions.text; - _this.videoCaption.start = captions.start; - _this.videoCaption.loaded = true; - - if (_this.isTouch) { - _this.videoCaption.subtitlesEl.find('li').html( - gettext( - 'Caption will be displayed when ' + - 'you start playing the video.' - ) - ); + Caption.captions = captions.text; + Caption.start = captions.start; + + if (Caption.loaded) { + if (Caption.rendered) { + Caption.reRenderCaption(); + Caption.updatePlayTime(self.videoPlayer.currentTime); + } } else { - _this.videoCaption.renderCaption(); + if (self.isTouch) { + Caption.subtitlesEl.find('li').html( + gettext( + 'Caption will be displayed when ' + + 'you start playing the video.' + ) + ); + } else { + Caption.renderCaption(); + } + + Caption.bindHandlers(); } - _this.videoCaption.bindHandlers(); + Caption.loaded = true; }, error: function (jqXHR, textStatus, errorThrown) { console.log('[Video info]: ERROR while fetching captions.'); @@ -242,70 +286,47 @@ function () { '[Video info]: STATUS:', textStatus + ', MESSAGE:', '' + errorThrown ); - - _this.videoCaption.hideCaptions(true, false); - _this.videoCaption.hideSubtitlesEl.hide(); + // If initial list of languages has more than 1 item, check + // for availability other transcripts. + if (_.keys(self.config.transcriptLanguages).length > 1) { + Caption.fetchAvailableTranslations(); + } else { + Caption.hideCaptions(true, false); + Caption.hideSubtitlesEl.hide(); + } } }); return true; } - function captionURL() { - return '' + this.config.captionAssetPath + - this.youtubeId('1.0') + '.srt.sjson'; - } - - function autoShowCaptions(event) { - if (!this.captionsShowLock) { - if (!this.captionsHidden) { - return; - } - - this.captionsShowLock = true; - - if (this.captionState === 'invisible') { - this.videoCaption.subtitlesEl.show(); - this.captionState = 'visible'; - } else if (this.captionState === 'hiding') { - this.videoCaption.subtitlesEl - .stop(true, false).css('opacity', 1).show(); - this.captionState = 'visible'; - } else if (this.captionState === 'visible') { - clearTimeout(this.captionHideTimeout); - } - - if (this.config.autohideHtml5) { - this.captionHideTimeout = setTimeout( - this.videoCaption.autoHideCaptions, - this.videoCaption.fadeOutTimeout - ); - } - - this.captionsShowLock = false; - } - } - - function autoHideCaptions() { - var _this; - - this.captionHideTimeout = null; - - if (!this.captionsHidden) { - return; - } + function fetchAvailableTranslations() { + var self = this, + Caption = this.videoCaption; - this.captionState = 'hiding'; - - _this = this; - - this.videoCaption.subtitlesEl - .fadeOut( - this.videoCaption.fadeOutTimeout, - function () { - _this.captionState = 'invisible'; + return $.ajaxWithPrefix({ + url: self.config.transcriptAvailableTranslationsUrl, + notifyOnError: false, + success: function (response) { + var currentLanguages = self.config.transcriptLanguages, + newLanguages = _.pick(currentLanguages, response); + + // Update property with available currently translations. + self.config.transcriptLanguages = newLanguages; + // Remove an old language menu. + Caption.container.find('.langs-list').remove(); + + if (_.keys(newLanguages).length) { + // And try again to fetch transcript. + Caption.fetchCaption(); + Caption.renderLanguageMenu(newLanguages); } - ); + }, + error: function (jqXHR, textStatus, errorThrown) { + Caption.hideCaptions(true, false); + Caption.hideSubtitlesEl.hide(); + } + }); } function resize() { @@ -320,100 +341,136 @@ function () { this.videoCaption.setSubtitlesHeight(); } - function onMouseEnter() { - if (this.videoCaption.frozen) { - clearTimeout(this.videoCaption.frozen); - } - - this.videoCaption.frozen = setTimeout( - this.videoCaption.onMouseLeave, - this.config.captionsFreezeTime - ); - } + function renderLanguageMenu(languages) { + var self = this, + menu = $('<ol class="langs-list menu">'), + currentLang = this.getCurrentLanguage(); - function onMouseLeave() { - if (this.videoCaption.frozen) { - clearTimeout(this.videoCaption.frozen); + if (_.keys(languages).length < 2) { + return false; } - this.videoCaption.frozen = null; + this.videoCaption.showLanguageMenu = true; - if (this.videoCaption.playing) { - this.videoCaption.scrollCaption(); - } - } + $.each(languages, function(code, label) { + var li = $('<li data-lang-code="' + code + '" />'), + link = $('<a href="javascript:void(0);">' + label + '</a>'); - function onMovement() { - if (!this.config.autohideHtml5) { - this.videoCaption.autoShowCaptions(); - } + if (currentLang === code) { + li.addClass('active'); + } - this.videoCaption.onMouseEnter(); - } + li.append(link); + menu.append(li); + }); - function renderCaption() { - var container = $('<ol>'), - _this = this, - autohideHtml5 = this.config.autohideHtml5; + this.videoCaption.container.append(menu); - this.container.after(this.videoCaption.subtitlesEl); - this.el.find('.video-controls .secondary-controls') - .append(this.videoCaption.hideSubtitlesEl); + menu.on('click', 'a', function (e) { + var el = $(e.currentTarget).parent(), + Caption = self.videoCaption, + langCode = el.data('lang-code'); - this.videoCaption.setSubtitlesHeight(); + if (self.lang !== langCode) { + self.lang = langCode; + self.storage.setItem('language', langCode); + el .addClass('active') + .siblings('li') + .removeClass('active'); - if ((this.videoType === 'html5' && autohideHtml5) || !autohideHtml5) { - this.videoCaption.fadeOutTimeout = this.config.fadeOutTimeout; - this.videoCaption.subtitlesEl.addClass('html5'); - } + Caption.fetchCaption(); + } + }); + } - $.each(this.videoCaption.captions, function(index, text) { + function buildCaptions (container, captions, start) { + var fragment = document.createDocumentFragment(); + + $.each(captions, function(index, text) { var liEl = $('<li>'); liEl.html(text); liEl.attr({ 'data-index': index, - 'data-start': _this.videoCaption.start[index], + 'data-start': start[index], 'tabindex': 0 }); - container.append(liEl); + fragment.appendChild(liEl[0]); }); - this.videoCaption.subtitlesEl - .html(container.html()) - .find('li[data-index]') - .on({ - mouseover: this.videoCaption.captionMouseOverOut, - mouseout: this.videoCaption.captionMouseOverOut, - mousedown: this.videoCaption.captionMouseDown, - click: this.videoCaption.captionClick, - focus: this.videoCaption.captionFocus, - blur: this.videoCaption.captionBlur, - keydown: this.videoCaption.captionKeyDown - }); + container.append([fragment]); + } + + function renderCaption() { + var Caption = this.videoCaption, + events = ['mouseover', 'mouseout', 'mousedown', 'click', 'focus', + 'blur', 'keydown'].join(' '); + + Caption.setSubtitlesHeight(); + + buildCaptions(Caption.subtitlesEl, Caption.captions, Caption.start); + + Caption.subtitlesEl.on(events, 'li[data-index]', function (event) { + switch (event.type) { + case 'mouseover': + case 'mouseout': + Caption.captionMouseOverOut(event); + break; + case 'mousedown': + Caption.captionMouseDown(event); + break; + case 'click': + Caption.captionClick(event); + break; + case 'focusin': + Caption.captionFocus(event); + break; + case 'focusout': + Caption.captionBlur(event); + break; + case 'keydown': + Caption.captionKeyDown(event); + break; + } + }); // Enables or disables automatic scrolling of the captions when the // video is playing. This feature has to be disabled when tabbing // through them as it interferes with that action. Initially, have this // flag enabled as we assume mouse use. Then, if the first caption // (through forward tabbing) or the last caption (through backwards - // tabbing) gets the focus, disable that feature. Renable it if tabbing + // tabbing) gets the focus, disable that feature. Re-enable it if tabbing // then cycles out of the the captions. - this.videoCaption.autoScrolling = true; + Caption.autoScrolling = true; // Keeps track of where the focus is situated in the array of captions. // Used to implement the automatic scrolling behavior and decide if the // outline around a caption has to be hidden or shown on a mouseenter // or mouseleave. Initially, no caption has the focus, set the // index to -1. - this.videoCaption.currentCaptionIndex = -1; + Caption.currentCaptionIndex = -1; // Used to track if the focus is coming from a click or tabbing. This // has to be known to decide if, when a caption gets the focus, an // outline has to be drawn (tabbing) or not (mouse click). - this.videoCaption.isMouseFocus = false; + Caption.isMouseFocus = false; + Caption.addPaddings(); + Caption.rendered = true; + } - // Set top and bottom spacing heigh and make sure they are taken out of + function reRenderCaption() { + var Caption = this.videoCaption; + + Caption.currentIndex = null; + Caption.rendered = false; + Caption.subtitlesEl.empty(); + buildCaptions(Caption.subtitlesEl, Caption.captions, Caption.start); + Caption.addPaddings(); + Caption.rendered = true; + } + + function addPaddings() { + // Set top and bottom spacing height and make sure they are taken out of // the tabbing order. this.videoCaption.subtitlesEl .prepend( @@ -426,8 +483,6 @@ function () { .height(this.videoCaption.bottomSpacingHeight()) .attr('tabindex', -1) ); - - this.videoCaption.rendered = true; } // On mouseOver, hide the outline of a caption that has been tabbed to. @@ -487,6 +542,7 @@ function () { function captionBlur(event) { var caption = $(event.target), captionIndex = parseInt(caption.attr('data-index'), 10); + caption.removeClass('focused'); // If we are on first or last index, we have to turn automatic scroll // on again when losing focus. There is no way to know in what @@ -494,8 +550,7 @@ function () { // tabbing back out of the captions or on the last element and tabbing // forward out of the captions. if (captionIndex === 0 || - captionIndex === this.videoCaption.captions.length-1) { - this.videoCaption.autoHideCaptions(); + captionIndex === this.videoCaption.captions.length - 1) { this.videoCaption.autoScrolling = true; } @@ -661,25 +716,9 @@ function () { event.preventDefault(); if (this.el.hasClass('closed')) { - this.videoCaption.autoShowCaptions(); this.videoCaption.hideCaptions(false); } else { this.videoCaption.hideCaptions(true); - - // In the case when captions are not auto-hidden based on mouse - // movement anywhere on the video, we must hide them explicitly - // after the "CC" button has been clicked (to hide captions). - // - // Otherwise, in order for the captions to disappear again, the - // user must move the mouse button over the "CC" button, or over - // the captions themselves. In this case, an "autoShow" will be - // triggered, and after a timeout, an "autoHide". - if (!this.config.autohideHtml5) { - this.captionHideTimeout = setTimeout( - this.videoCaption.autoHideCaptions(), - 0 - ); - } } } @@ -751,31 +790,23 @@ function () { function setSubtitlesHeight() { var height = 0; - if ( - ((this.videoType === 'html5') && (this.config.autohideHtml5)) || - (!this.config.autohideHtml5) - ){ - // on page load captionHidden = undefined - if ( - ( - this.captionsHidden === undefined && - this.hide_captions === true - ) || - (this.captionsHidden === true) - ) { - // In case of html5 autoshowing subtitles, we adjust height of - // subs, by height of scrollbar. - height = this.videoControl.el.height() + - 0.5 * this.videoControl.sliderEl.height(); - // Height of videoControl does not contain height of slider. - // css is set to absolute, to avoid yanking when slider - // autochanges its height. - } + // on page load captionHidden = undefined + if ((this.captionsHidden === undefined && this.hide_captions) || + this.captionsHidden === true + ) { + // In case of html5 autoshowing subtitles, we adjust height of + // subs, by height of scrollbar. + height = this.videoControl.el.height() + + 0.5 * this.videoControl.sliderEl.height(); + // Height of videoControl does not contain height of slider. + // css is set to absolute, to avoid yanking when slider + // autochanges its height. } + this.videoCaption.subtitlesEl.css({ maxHeight: this.videoCaption.captionHeight() - height }); - } + } }); }(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 4dce45af91c8c93548bb5e14f84e4c778d0ca46c..7d677928402e7c3d622eb14dec659b5f2df223bc 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -113,7 +113,7 @@ class ModelsTest(unittest.TestCase): def test_load_class(self): vc = XModuleDescriptor.load_class('video') - vc_str = "<class 'xmodule.video_module.VideoDescriptor'>" + vc_str = "<class 'xmodule.video_module.video_module.VideoDescriptor'>" self.assertEqual(str(vc), vc_str) diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py index 0ab8c0ced7d86d5ecb794d7eb723ed02c7e23101..be17e900fccc53eb719aa1bcdc9ae2d32c1a1516 100644 --- a/common/lib/xmodule/xmodule/tests/test_video.py +++ b/common/lib/xmodule/xmodule/tests/test_video.py @@ -20,7 +20,7 @@ from mock import Mock from . import LogicTest from lxml import etree from xmodule.modulestore import Location -from xmodule.video_module import VideoDescriptor, _create_youtube_string +from xmodule.video_module import VideoDescriptor, create_youtube_string from .test_import import DummySystem from xblock.field_data import DictFieldData from xblock.fields import ScopeIds @@ -150,7 +150,7 @@ class VideoDescriptorTest(unittest.TestCase): descriptor.youtube_id_1_25 = '1EeWXzPdhSA' descriptor.youtube_id_1_5 = 'rABDYkeK0x8' expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" - self.assertEqual(_create_youtube_string(descriptor), expected) + self.assertEqual(create_youtube_string(descriptor), expected) def test_create_youtube_string_missing(self): """ @@ -165,7 +165,7 @@ class VideoDescriptorTest(unittest.TestCase): descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8' descriptor.youtube_id_1_25 = '1EeWXzPdhSA' expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA" - self.assertEqual(_create_youtube_string(descriptor), expected) + self.assertEqual(create_youtube_string(descriptor), expected) class VideoDescriptorImportTestCase(unittest.TestCase): @@ -193,6 +193,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): <source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.ogg"/> <track src="http://www.example.com/track"/> + <transcript language="ua" src="ukrainian_translation.srt" /> + <transcript language="ge" src="german_translation.srt" /> </video> ''' location = Location(["i4x", "edX", "video", "default", @@ -215,7 +217,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): 'track': 'http://www.example.com/track', 'download_track': True, 'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'], - 'data': '' + 'data': '', + 'transcripts': {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'} }) def test_from_xml(self): @@ -230,6 +233,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): end_time="00:01:00"> <source src="http://www.example.com/source.mp4"/> <track src="http://www.example.com/track"/> + <transcript language="ua" src="ukrainian_translation.srt" /> + <transcript language="ge" src="german_translation.srt" /> </video> ''' output = VideoDescriptor.from_xml(xml_data, module_system, Mock()) @@ -245,7 +250,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): 'download_track': False, 'download_video': False, 'html5_sources': ['http://www.example.com/source.mp4'], - 'data': '' + 'data': '', + 'transcripts': {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}, }) def test_from_xml_missing_attributes(self): @@ -304,7 +310,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): 'download_track': True, 'download_video': True, 'html5_sources': ['http://www.example.com/source.mp4'], - 'data': '' + 'data': '', + 'transcripts': {}, }) def test_from_xml_no_attributes(self): @@ -326,7 +333,8 @@ class VideoDescriptorImportTestCase(unittest.TestCase): 'download_track': False, 'download_video': False, 'html5_sources': [], - 'data': '' + 'data': '', + 'transcripts': {}, }) def test_from_xml_double_quotes(self): @@ -508,6 +516,7 @@ class VideoExportTestCase(unittest.TestCase): desc.download_track = True desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'] desc.download_video = True + desc.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'} xml = desc.definition_to_xml(None) # We don't use the `resource_fs` parameter expected = etree.fromstring('''\ @@ -515,9 +524,10 @@ class VideoExportTestCase(unittest.TestCase): <source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.ogg"/> <track src="http://www.example.com/track"/> + <transcript language="ge" src="german_translation.srt" /> + <transcript language="ua" src="ukrainian_translation.srt" /> </video> ''') - self.assertXmlEqual(expected, xml) def test_export_to_xml_empty_end_time(self): diff --git a/common/lib/xmodule/xmodule/video_module/__init__.py b/common/lib/xmodule/xmodule/video_module/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3fba59844aeb7208cc06394ef5653f74dfa45bd8 --- /dev/null +++ b/common/lib/xmodule/xmodule/video_module/__init__.py @@ -0,0 +1,10 @@ +""" +Container for video module and it's utils. +""" + +# Disable wildcard-import warnings. +# pylint: disable=W0401 + +from .transcripts_utils import * +from .video_utils import * +from .video_module import * diff --git a/cms/djangoapps/contentstore/transcripts_utils.py b/common/lib/xmodule/xmodule/video_module/transcripts_utils.py similarity index 61% rename from cms/djangoapps/contentstore/transcripts_utils.py rename to common/lib/xmodule/xmodule/video_module/transcripts_utils.py index c80d027e881011948232bcf23d9c3f659b51323c..b416012d1c98c0723693545c22d960d112a1116f 100644 --- a/cms/djangoapps/contentstore/transcripts_utils.py +++ b/common/lib/xmodule/xmodule/video_module/transcripts_utils.py @@ -2,6 +2,7 @@ Utility functions for transcripts. ++++++++++++++++++++++++++++++++++ """ +import os import copy import json import requests @@ -9,29 +10,27 @@ import logging from pysrt import SubRipTime, SubRipItem, SubRipFile from lxml import etree -from cache_toolbox.core import del_cached_content -from django.conf import settings -from django.utils.translation import ugettext as _ - from xmodule.exceptions import NotFoundError from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore -from xmodule.modulestore import Location -from .utils import get_modulestore log = logging.getLogger(__name__) -class TranscriptsGenerationException(Exception): +class TranscriptException(Exception): # pylint disable=C0111 + pass + + +class TranscriptsGenerationException(Exception): # pylint disable=C0111 pass -class GetTranscriptsFromYouTubeException(Exception): +class GetTranscriptsFromYouTubeException(Exception): # pylint disable=C0111 pass -class TranscriptsRequestValidationException(Exception): +class TranscriptsRequestValidationException(Exception): # pylint disable=C0111 pass @@ -42,7 +41,7 @@ def generate_subs(speed, source_speed, source_subs): Args: `speed`: float, for this speed subtitles will be generated, `source_speed`: float, speed of source_subs - `soource_subs`: dict, existing subtitles for speed `source_speed`. + `source_subs`: dict, existing subtitles for speed `source_speed`. Returns: `subs`: dict, actual subtitles. @@ -64,30 +63,27 @@ def generate_subs(speed, source_speed, source_subs): return subs -def save_subs_to_store(subs, subs_id, item): +def save_subs_to_store(subs, subs_id, item, language='en'): """ Save transcripts into `StaticContent`. Args: `subs_id`: str, subtitles id `item`: video module instance + `language`: two chars str ('uk'), language of translation of transcripts Returns: location of saved subtitles. """ filedata = json.dumps(subs, indent=2) mime_type = 'application/json' - filename = 'subs_{0}.srt.sjson'.format(subs_id) - - content_location = StaticContent.compute_location( - item.location.org, item.location.course, filename - ) + filename = subs_filename(subs_id, language) + content_location = asset_location(item.location, filename) content = StaticContent(content_location, filename, mime_type, filedata) contentstore().save(content) - del_cached_content(content_location) return content_location -def get_transcripts_from_youtube(youtube_id): +def get_transcripts_from_youtube(youtube_id, settings, i18n): """ Gets transcripts from youtube for youtube_id. @@ -96,6 +92,8 @@ def get_transcripts_from_youtube(youtube_id): Returns (status, transcripts): bool, dict. """ + _ = i18n.ugettext + utf8_parser = etree.XMLParser(encoding='utf-8') youtube_api = copy.deepcopy(settings.YOUTUBE_API) @@ -127,7 +125,7 @@ def get_transcripts_from_youtube(youtube_id): return {'start': sub_starts, 'end': sub_ends, 'text': sub_texts} -def download_youtube_subs(youtube_subs, item): +def download_youtube_subs(youtube_subs, item, settings): """ Download transcripts from Youtube and save them to assets. @@ -138,6 +136,9 @@ def download_youtube_subs(youtube_subs, item): Returns: None, if transcripts were successfully downloaded and saved. Otherwise raises GetTranscriptsFromYouTubeException. """ + i18n = item.runtime.service(item, "i18n") + _ = i18n.ugettext + highest_speed = highest_speed_subs = None missed_speeds = [] # Iterate from lowest to highest speed and try to do download transcripts @@ -146,7 +147,7 @@ def download_youtube_subs(youtube_subs, item): if not youtube_id: continue try: - subs = get_transcripts_from_youtube(youtube_id) + subs = get_transcripts_from_youtube(youtube_id, settings, i18n) if not subs: # if empty subs are returned raise GetTranscriptsFromYouTubeException except GetTranscriptsFromYouTubeException: @@ -187,24 +188,19 @@ def download_youtube_subs(youtube_subs, item): ) -def remove_subs_from_store(subs_id, item): +def remove_subs_from_store(subs_id, item, lang='en'): """ Remove from store, if transcripts content exists. """ - filename = 'subs_{0}.srt.sjson'.format(subs_id) - content_location = StaticContent.compute_location( - item.location.org, item.location.course, filename - ) try: - content = contentstore().find(content_location) + content = asset(item.location, subs_id, lang) contentstore().delete(content.get_id()) - del_cached_content(content.location) log.info("Removed subs %s from store", subs_id) except NotFoundError: pass -def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item): +def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item, language='en'): """Generate transcripts from source files (like SubRip format, etc.) and save them to assets for `item` module. We expect, that speed of source subs equal to 1 @@ -213,15 +209,17 @@ def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item): :param subs_type: type of source subs: "srt", ... :param subs_filedata:unicode, content of source subs. :param item: module object. + :param language: str, language of translation of transcripts :returns: True, if all subs are generated and saved successfully. """ + _ = item.runtime.service(item, "i18n").ugettext if subs_type != 'srt': raise TranscriptsGenerationException(_("We support only SubRip (*.srt) transcripts format.")) try: srt_subs_obj = SubRipFile.from_string(subs_filedata) - except Exception as e: + except Exception as ex: msg = _("Something wrong with SubRip transcripts file during parsing. Inner message is {error_message}").format( - error_message=e.message + error_message=ex.message ) raise TranscriptsGenerationException(msg) if not srt_subs_obj: @@ -245,7 +243,8 @@ def generate_subs_from_source(speed_subs, subs_type, subs_filedata, item): save_subs_to_store( generate_subs(speed, 1, subs), subs_id, - item + item, + language ) return subs @@ -279,15 +278,6 @@ def generate_srt_from_sjson(sjson_subs, speed): return output -def save_module(item, user): - """ - Proceed with additional save operations. - """ - item.save() - store = get_modulestore(Location(item.id)) - store.update_item(item, user.id if user else None) - - def copy_or_rename_transcript(new_name, old_name, item, delete_old=False, user=None): """ Renames `old_name` transcript file in storage to `new_name`. @@ -302,7 +292,7 @@ def copy_or_rename_transcript(new_name, old_name, item, delete_old=False, user=N transcripts = contentstore().find(content_location).data save_subs_to_store(json.loads(transcripts), new_name, item) item.sub = new_name - save_module(item, user) + item.save_with_metadata(user) if delete_old: remove_subs_from_store(old_name, item) @@ -316,7 +306,7 @@ def get_html5_ids(html5_sources): return html5_ids -def manage_video_subtitles_save(old_item, new_item, user): +def manage_video_subtitles_save(item, user, old_metadata=None, generate_translation=False): """ Does some specific things, that can be done only on save. @@ -324,6 +314,12 @@ def manage_video_subtitles_save(old_item, new_item, user): If value of `sub` field of `new_item` is cleared, transcripts should be removed. + `item` is video module instance with updated values of fields, + but actually have not been saved to store yet. + + `old_metadata` contains old values of XFields. + + # 1. If value of `sub` field of `new_item` is different from values of video fields of `new_item`, and `new_item.sub` file is present, then code in this function creates copies of `new_item.sub` file with new names. That names are equal to values of video fields of `new_item` @@ -331,23 +327,28 @@ def manage_video_subtitles_save(old_item, new_item, user): This whole action ensures that after user changes video fields, proper `sub` files, corresponding to new values of video fields, will be presented in system. - old_item is not used here, but is added for future changes. + # 2. Generate transcripts translation only when user clicks `save` button, not while switching tabs. + a) delete sjson translation for those languages, which were removed from `item.transcripts`. + Note: we are not deleting old SRT files to give user more flexibility. + b) For all SRT files in`item.transcripts` regenerate new SJSON files. + (To avoid confusing situation if you attempt to correct a translation by uploading + a new version of the SRT file with same name). """ # 1. - html5_ids = get_html5_ids(new_item.html5_sources) - possible_video_id_list = [new_item.youtube_id_1_0] + html5_ids - sub_name = new_item.sub + html5_ids = get_html5_ids(item.html5_sources) + possible_video_id_list = [item.youtube_id_1_0] + html5_ids + sub_name = item.sub for video_id in possible_video_id_list: if not video_id: continue if not sub_name: - remove_subs_from_store(video_id, new_item) + remove_subs_from_store(video_id, item) continue # copy_or_rename_transcript changes item.sub of module try: # updates item.sub with `video_id`, if it is successful. - copy_or_rename_transcript(video_id, sub_name, new_item, user=user) + copy_or_rename_transcript(video_id, sub_name, item, user=user) except NotFoundError: # subtitles file `sub_name` is not presented in the system. Nothing to copy or rename. log.debug( @@ -355,3 +356,121 @@ def manage_video_subtitles_save(old_item, new_item, user): "original file does not exist.", sub_name, video_id ) + + # 2. + if generate_translation: + old_langs = set(old_metadata.get('transcripts', {})) if old_metadata else set() + new_langs = set(item.transcripts) + + for lang in old_langs.difference(new_langs): # 2a + for video_id in possible_video_id_list: + if video_id: + remove_subs_from_store(video_id, item, lang) + + reraised_message = '' + for lang in new_langs: # 2b + try: + generate_sjson_for_all_speeds( + item, + item.transcripts[lang], + {speed: subs_id for subs_id, speed in youtube_speed_dict(item).iteritems()}, + lang, + ) + except TranscriptException as ex: + item.transcripts.pop(lang) # remove key from transcripts because proper srt file does not exist in assets. + reraised_message += ' ' + ex.message + if reraised_message: + item.save_with_metadata(user) + raise TranscriptException(reraised_message) + + +def youtube_speed_dict(item): + """ + Returns {speed: youtube_ids, ...} dict for existing youtube_ids + """ + yt_ids = [item.youtube_id_0_75, item.youtube_id_1_0, item.youtube_id_1_25, item.youtube_id_1_5] + yt_speeds = [0.75, 1.00, 1.25, 1.50] + youtube_ids = {p[0]: p[1] for p in zip(yt_ids, yt_speeds) if p[0]} + return youtube_ids + + +def subs_filename(subs_id, lang='en'): + """ + Generate proper filename for storage. + """ + if lang == 'en': + return 'subs_{0}.srt.sjson'.format(subs_id) + else: + return '{0}_subs_{1}.srt.sjson'.format(lang, subs_id) + + +def asset_location(location, filename): + """ + Return asset location. + + `location` is module location. + """ + return StaticContent.compute_location( + location.org, location.course, filename + ) + + +def asset(location, subs_id, lang='en', filename=None): + """ + Get asset from contentstore, asset location is built from subs_id and lang. + + `location` is module location. + """ + return contentstore().find( + asset_location( + location, + subs_filename(subs_id, lang) if not filename else filename + ) + ) + + +def generate_sjson_for_all_speeds(item, user_filename, result_subs_dict, lang): + """ + Generates sjson from srt for given lang. + + `item` is module object. + """ + try: + srt_transcripts = contentstore().find(asset_location(item.location, user_filename)) + except NotFoundError as ex: + raise TranscriptException("{}: Can't find uploaded transcripts: {}".format(ex.message, user_filename)) + + if not lang: + lang = item.transcript_language + + generate_subs_from_source( + result_subs_dict, + os.path.splitext(user_filename)[1][1:], + srt_transcripts.data.decode('utf8'), + item, + lang + ) + + +def get_or_create_sjson(item): + """ + Get sjson if already exists, otherwise generate it. + + Generate sjson with subs_id name, from user uploaded srt. + Subs_id is extracted from srt filename, which was set by user. + + Raises: + TranscriptException: when srt subtitles do not exist, + and exceptions from generate_subs_from_source. + + `item` is module object. + """ + user_filename = item.transcripts[item.transcript_language] + user_subs_id = os.path.splitext(user_filename)[0] + source_subs_id, result_subs_dict = user_subs_id, {1.0: user_subs_id} + try: + sjson_transcript = asset(item.location, source_subs_id, item.transcript_language).data + except (NotFoundError): # generating sjson from srt + generate_sjson_for_all_speeds(item, user_filename, result_subs_dict, item.transcript_language) + sjson_transcript = asset(item.location, source_subs_id, item.transcript_language).data + return sjson_transcript diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py similarity index 67% rename from common/lib/xmodule/xmodule/video_module.py rename to common/lib/xmodule/xmodule/video_module/video_module.py index 77204d9fbf5695db167a8d1cfb2f282a00eff155..d840601545072dd617e8ed8fdcdd94f0aa964df2 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module/video_module.py @@ -10,15 +10,17 @@ in-browser HTML5 video method (when in HTML5 mode). in XML. """ +import os import json import logging +from operator import itemgetter from lxml import etree from pkg_resources import resource_string import datetime import copy from webob import Response -from pysrt import SubRipTime, SubRipItem +from collections import OrderedDict from django.conf import settings @@ -26,12 +28,19 @@ from xmodule.x_module import XModule, module_attr from xmodule.editing_module import TabsEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field -from xmodule.contentstore.django import contentstore -from xmodule.contentstore.content import StaticContent from xmodule.exceptions import NotFoundError from xblock.core import XBlock -from xblock.fields import Scope, String, Float, Boolean, List, ScopeIds +from xblock.fields import Scope, String, Float, Boolean, List, Dict, ScopeIds from xmodule.fields import RelativeTime +from .transcripts_utils import ( + generate_srt_from_sjson, + asset, + get_or_create_sjson, + TranscriptException, + generate_sjson_for_all_speeds, + youtube_speed_dict +) +from .video_utils import create_youtube_string from xmodule.modulestore.inheritance import InheritanceKeyValueStore from xblock.runtime import KvsFieldData @@ -51,12 +60,6 @@ class VideoFields(object): scope=Scope.user_state, default=datetime.timedelta(seconds=0) ) - show_captions = Boolean( - help="This controls whether or not captions are shown by default.", - display_name="Show Transcript", - scope=Scope.settings, - default=True - ) # TODO: This should be moved to Scope.content, but this will # require data migration to support the old video module. youtube_id_1_0 = String( @@ -130,10 +133,29 @@ class VideoFields(object): ) sub = String( help="The name of the timed transcript track (for non-Youtube videos).", - display_name="HTML5 Transcript", + display_name="Transcript (primary)", scope=Scope.settings, default="" ) + show_captions = Boolean( + help="This controls whether or not captions are shown by default.", + display_name="Transcript Display", + scope=Scope.settings, + default=True + ) + # Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'} + transcripts = Dict( + help="Add additional transcripts in other languages", + display_name="Transcript Translations", + scope=Scope.settings, + default={} + ) + transcript_language = String( + help="Preferred language for transcript", + display_name="Preferred language for transcript", + scope=Scope.preferences, + default="en" + ) speed = Float( help="The last speed that was explicitly set by user for the video.", scope=Scope.user_state, @@ -163,30 +185,31 @@ class VideoModule(VideoFields, XModule): # To make sure that js files are called in proper order we use numerical # index. We do that to avoid issues that occurs in tests. + module = __name__.replace('.video_module', '', 2) js = { 'js': [ - resource_string(__name__, 'js/src/video/00_video_storage.js'), - resource_string(__name__, 'js/src/video/00_resizer.js'), - resource_string(__name__, 'js/src/video/01_initialize.js'), - resource_string(__name__, 'js/src/video/025_focus_grabber.js'), - resource_string(__name__, 'js/src/video/02_html5_video.js'), - resource_string(__name__, 'js/src/video/03_video_player.js'), - resource_string(__name__, 'js/src/video/04_video_control.js'), - resource_string(__name__, 'js/src/video/05_video_quality_control.js'), - resource_string(__name__, 'js/src/video/06_video_progress_slider.js'), - resource_string(__name__, 'js/src/video/07_video_volume_control.js'), - resource_string(__name__, 'js/src/video/08_video_speed_control.js'), - resource_string(__name__, 'js/src/video/09_video_caption.js'), - resource_string(__name__, 'js/src/video/10_main.js') + resource_string(module, 'js/src/video/00_video_storage.js'), + resource_string(module, 'js/src/video/00_resizer.js'), + resource_string(module, 'js/src/video/01_initialize.js'), + resource_string(module, 'js/src/video/025_focus_grabber.js'), + resource_string(module, 'js/src/video/02_html5_video.js'), + resource_string(module, 'js/src/video/03_video_player.js'), + resource_string(module, 'js/src/video/04_video_control.js'), + resource_string(module, 'js/src/video/05_video_quality_control.js'), + resource_string(module, 'js/src/video/06_video_progress_slider.js'), + resource_string(module, 'js/src/video/07_video_volume_control.js'), + resource_string(module, 'js/src/video/08_video_speed_control.js'), + resource_string(module, 'js/src/video/09_video_caption.js'), + resource_string(module, 'js/src/video/10_main.js') ] } - css = {'scss': [resource_string(__name__, 'css/video/display.scss')]} + css = {'scss': [resource_string(module, 'css/video/display.scss')]} js_module_name = "Video" def handle_ajax(self, dispatch, data): - accepted_keys = ['speed', 'saved_video_position'] - + accepted_keys = ['speed', 'saved_video_position', 'transcript_language'] if dispatch == 'save_user_state': + for key in data: if hasattr(self, key) and key in accepted_keys: if key == 'saved_video_position': @@ -206,7 +229,6 @@ class VideoModule(VideoFields, XModule): def get_html(self): track_url = None - caption_asset_path = "/static/subs/" get_ext = lambda filename: filename.rpartition('.')[-1] sources = {get_ext(src): src for src in self.html5_sources} @@ -221,7 +243,26 @@ class VideoModule(VideoFields, XModule): if self.track: track_url = self.track elif self.sub: - track_url = self.runtime.handler_url(self, 'download_transcript') + track_url = self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/download' + + if self.transcript_language in self.transcripts: + transcript_language = self.transcript_language + elif self.sub: + transcript_language = 'en' + elif self.transcripts: + transcript_language = self.transcripts.keys()[0] + else: + # this for the case, when for currently selected video, + # there are no translations and English subtitles are not set by instructor. + transcript_language = 'null' + + all_languages = {i[0]: i[1] for i in settings.ALL_LANGUAGES} + languages = {lang: all_languages[lang] for lang in self.transcripts} + if self.sub: + languages.update({'en': 'English'}) + + # OrderedDict for easy testing of rendered context in tests + transcript_languages = OrderedDict(sorted(languages.items(), key=itemgetter(1))) return self.system.render_template('video.html', { 'ajax_url': self.system.ajax_url + '/save_user_state', @@ -230,7 +271,6 @@ class VideoModule(VideoFields, XModule): # isn't on the filesystem 'data_dir': getattr(self, 'data_dir', None), 'display_name': self.display_name_with_default, - 'caption_asset_path': caption_asset_path, 'end': self.end_time.total_seconds(), 'id': self.location.html_id(), 'show_captions': json.dumps(self.show_captions), @@ -241,68 +281,164 @@ class VideoModule(VideoFields, XModule): 'start': self.start_time.total_seconds(), 'sub': self.sub, 'track': track_url, - 'youtube_streams': _create_youtube_string(self), + 'youtube_streams': create_youtube_string(self), # TODO: Later on the value 1500 should be taken from some global # configuration setting field. 'yt_test_timeout': 1500, 'yt_test_url': settings.YOUTUBE_TEST_URL, + 'transcript_language': transcript_language, + 'transcript_languages': json.dumps(transcript_languages), + 'transcript_translation_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/translation', + 'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript').rstrip('/?') + '/available_translations', }) - def get_transcript(self, subs_id): - ''' + def get_transcript(self): + """ Returns transcript in *.srt format. - Args: - `subs_id`: str, subtitles id - Raises: - NotFoundError if cannot find transcript file in storage. - ValueError if transcript file is empty or incorrect JSON. - KeyError if transcript file has incorrect format. - ''' - - filename = 'subs_{0}.srt.sjson'.format(subs_id) - content_location = StaticContent.compute_location( - self.location.org, self.location.course, filename - ) - sjson_transcripts = contentstore().find(content_location) - str_subs = _generate_srt_from_sjson(json.loads(sjson_transcripts.data), speed=1.0) + """ + lang = self.transcript_language + subs_id = self.sub if lang == 'en' else self.youtube_id_1_0 + data = asset(self.location, subs_id, lang).data + str_subs = generate_srt_from_sjson(json.loads(data), speed=1.0) if not str_subs: log.debug('generate_srt_from_sjson produces no subtitles') raise ValueError return str_subs - @XBlock.handler - def download_transcript(self, __, ___): + def transcript(self, request, dispatch): """ - This is called to get transcript file without timecodes to student. + Entry point for transcript handlers. + + Request GET should contains 2-char language code for `download` + and additionally `videoId` for `translation`. + + Dispatches: + `download`: returns SRT file. + `translation`: returns jsoned translation text. + `available_translations`: returns list of languages, for which SRT files exist. For 'en' check if SJSON exists. """ - try: - subs = self.get_transcript(self.sub) - except (NotFoundError): - log.debug("Can't find content in storage for %s transcript", self.sub) - return Response(status=404) - except (ValueError, KeyError): - log.debug("Invalid transcript JSON.") - return Response(status=400) - - response = Response( - subs, - headerlist=[ - ('Content-Disposition', 'attachment; filename="{0}.srt"'.format(self.sub)), - ]) - response.content_type="application/x-subrip" + if dispatch == 'translation': + if 'language' not in request.GET or 'videoId' not in request.GET: + log.info("Invalid /transcript GET parameters.") + return Response(status=400) + + lang = request.GET.get('language') + if lang not in ['en'] + self.transcripts.keys(): + log.info("Video: transcript facilities are not available for given language.") + return Response(status=404) + if lang != self.transcript_language: + self.transcript_language = lang + + try: + transcript = self.translation(request.GET.get('videoId')) + except TranscriptException as ex: + log.info(ex.message) + response = Response(status=404) + else: + response = Response(transcript) + response.content_type = 'application/json' + + elif dispatch == 'download': + try: + subs = self.get_transcript() + except (NotFoundError, ValueError, KeyError): + log.debug("Video@download exception") + response = Response(status=404) + else: + response = Response( + subs, + headerlist=[ + ('Content-Disposition', 'attachment; filename="{0}.srt"'.format(self.transcript_language)), + ] + ) + response.content_type = "application/x-subrip" + + elif dispatch == 'available_translations': + available_translations = [] + if self.sub: # check if sjson exists for 'en'. + try: + asset(self.location, self.sub, 'en') + except NotFoundError: + passs + else: + available_translations = ['en'] + for lang in self.transcripts: + try: + asset(self.location, None, None, self.transcripts[lang]) + except NotFoundError: + continue + available_translations.append(lang) + if available_translations: + response = Response(json.dumps(available_translations)) + response.content_type = 'application/json' + else: + response = Response(status=404) + else: # unknown dispatch + log.debug("Dispatch is not allowed") + response = Response(status=404) return response + def translation(self, subs_id): + """ + This is called to get transcript file for specific language. + + subs_id: str: must be on of: self.sub or one of youtube_ids. + + Logic flow: + + If english -> give back `sub` subtitles: + Return what we have in contentstore for given subs_id, + We should not regenerate needed transcripts, if, for example, they present for youtube 1.0 speed, + and we need for other speeds. Such generation should be done in transcripts workflow. + If non-english: + a) extract subs_id from srt file name + if non-youtube: + b) try to find sjson by subs_id and return if sucessful + c) otherwise generate sjson from srt and return it. + if youtube: + b) try to find sjson by subs_id and return if sucessful + c) generate sjson from srt for all youtube speeds + + Filenames naming: + en: subs_videoid.srt.sjson + non_en: uk_subs_videoid.srt.sjson + """ + if self.transcript_language == 'en': + return asset(self.location, subs_id).data + + if not self.youtube_id_1_0: # Non-youtube (HTML5) case: + return get_or_create_sjson(self) + + # Youtube case: + youtube_ids = youtube_speed_dict(self) + assert subs_id in youtube_ids + + try: + sjson_transcript = asset(self.location, subs_id, self.transcript_language).data + except (NotFoundError): + log.info("Can't find content in storage for %s transcript: generating.", subs_id) + generate_sjson_for_all_speeds( + self, + self.transcripts[self.transcript_language], + {speed: subs_id for subs_id, speed in youtube_ids.iteritems()}, + self.transcript_language + ) + sjson_transcript = asset(self.location, subs_id, self.transcript_language).data + return sjson_transcript class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor): """Descriptor for `VideoModule`.""" module_class = VideoModule - download_transcript = module_attr('download_transcript') + transcript = module_attr('transcript') tabs = [ { @@ -317,7 +453,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor ] def __init__(self, *args, **kwargs): - ''' + """ Mostly handles backward compatibility issues. `source` is deprecated field. @@ -327,7 +463,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor b) If `source` is cleared it is not shown anymore. c) If `source` exists and `source` in `html5_sources`, do not show `source` field. `download_video` field has value True. - ''' + """ super(VideoDescriptor, self).__init__(*args, **kwargs) # For backwards compatibility -- if we've got XML data, parse # it out and set the metadata fields @@ -358,6 +494,13 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor if not download_track['explicitly_set'] and self.track: self.download_track = True + def save_with_metadata(self, user): + """ + Save module with updated metadata to database." + """ + self.save() + self.runtime.modulestore.update_item(self, user.id if user else None) + @property def editable_metadata_fields(self): editable_fields = super(VideoDescriptor, self).editable_metadata_fields @@ -408,7 +551,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor Returns an xml string representing this module. """ xml = etree.Element('video') - youtube_string = _create_youtube_string(self) + youtube_string = create_youtube_string(self) # Mild workaround to ensure that tests pass -- if a field # is set to its default value, we don't need to write it out. if youtube_string and youtube_string != '1.00:OEoXaMPEzfM': @@ -440,11 +583,18 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor ele.set('src', self.track) xml.append(ele) + # sorting for easy testing of resulting xml + for transcript_language in sorted(self.transcripts.keys()): + ele = etree.Element('transcript') + ele.set('language', transcript_language) + ele.set('src', self.transcripts[transcript_language]) + xml.append(ele) + return xml def get_context(self): """ - Extend context by data for transcripts basic tab. + Extend context by data for transcript basic tab. """ _context = super(VideoDescriptor, self).get_context() @@ -503,7 +653,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor youtube_id = deserialize_field(cls.youtube_id_1_0, pieces[1]) ret[speed] = youtube_id except (ValueError, IndexError): - log.warning('Invalid YouTube ID: %s' % video) + log.warning('Invalid YouTube ID: %s', video) return ret @classmethod @@ -527,7 +677,6 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor 'from': 'start_time', 'to': 'end_time' } - sources = xml.findall('source') if sources: field_data['html5_sources'] = [ele.get('src') for ele in sources] @@ -536,6 +685,10 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor if track is not None: field_data['track'] = track.get('src') + transcripts = xml.findall('transcript') + if transcripts: + field_data['transcripts'] = {tr.get('language'): tr.get('src') for tr in transcripts} + for attr, value in xml.items(): if attr in compat_keys: attr = compat_keys[attr] @@ -572,80 +725,3 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor field_data['download_track'] = True return field_data - - -def _create_youtube_string(module): - """ - Create a string of Youtube IDs from `module`'s metadata - attributes. Only writes a speed if an ID is present in the - module. Necessary for backwards compatibility with XML-based - courses. - """ - youtube_ids = [ - module.youtube_id_0_75, - module.youtube_id_1_0, - module.youtube_id_1_25, - module.youtube_id_1_5 - ] - youtube_speeds = ['0.75', '1.00', '1.25', '1.50'] - return ','.join([':'.join(pair) - for pair - in zip(youtube_speeds, youtube_ids) - if pair[1]]) - - -def _generate_subs(speed, source_speed, source_subs): - """ - Generate transcripts from one speed to another speed. - - Args: - `speed`: float, for this speed subtitles will be generated, - `source_speed`: float, speed of source_subs - `soource_subs`: dict, existing subtitles for speed `source_speed`. - - Returns: - `subs`: dict, actual subtitles. - """ - if speed == source_speed: - return source_subs - - coefficient = 1.0 * speed / source_speed - subs = { - 'start': [ - int(round(timestamp * coefficient)) for - timestamp in source_subs['start'] - ], - 'end': [ - int(round(timestamp * coefficient)) for - timestamp in source_subs['end'] - ], - 'text': source_subs['text']} - return subs - - -def _generate_srt_from_sjson(sjson_subs, speed): - """Generate transcripts with speed = 1.0 from sjson to SubRip (*.srt). - - :param sjson_subs: "sjson" subs. - :param speed: speed of `sjson_subs`. - :returns: "srt" subs. - """ - - output = '' - - equal_len = len(sjson_subs['start']) == len(sjson_subs['end']) == len(sjson_subs['text']) - if not equal_len: - return output - - sjson_speed_1 = _generate_subs(speed, 1, sjson_subs) - - for i in range(len(sjson_speed_1['start'])): - item = SubRipItem( - index=i, - start=SubRipTime(milliseconds=sjson_speed_1['start'][i]), - end=SubRipTime(milliseconds=sjson_speed_1['end'][i]), - text=sjson_speed_1['text'][i] - ) - output += (unicode(item)) - output += '\n' - return output diff --git a/common/lib/xmodule/xmodule/video_module/video_utils.py b/common/lib/xmodule/xmodule/video_module/video_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..cf28865164b7d2122240e5759051f9b0e4515c36 --- /dev/null +++ b/common/lib/xmodule/xmodule/video_module/video_utils.py @@ -0,0 +1,25 @@ +""" +Module containts utils specific for video_module but not for transcripts. +""" + + +def create_youtube_string(module): + """ + Create a string of Youtube IDs from `module`'s metadata + attributes. Only writes a speed if an ID is present in the + module. Necessary for backwards compatibility with XML-based + courses. + """ + youtube_ids = [ + module.youtube_id_0_75, + module.youtube_id_1_0, + module.youtube_id_1_25, + module.youtube_id_1_5 + ] + youtube_speeds = ['0.75', '1.00', '1.25', '1.50'] + return ','.join([ + ':'.join(pair) + for pair + in zip(youtube_speeds, youtube_ids) + if pair[1] + ]) diff --git a/common/test/data/uploads/zh_subs_OEoXaMPEzfM.srt.sjson b/common/test/data/uploads/zh_subs_OEoXaMPEzfM.srt.sjson new file mode 100644 index 0000000000000000000000000000000000000000..73558337fa39795ee387229a0e1e704b9dc52441 --- /dev/null +++ b/common/test/data/uploads/zh_subs_OEoXaMPEzfM.srt.sjson @@ -0,0 +1,17 @@ +{ + "start": [ + 270, + 2720, + 5430 + ], + "end": [ + 2720, + 5430, + 7160 + ], + "text": [ + "好 å„ä½åŒå¦", + "我们今天è¦è®²çš„题目是", + "从算ç¹åˆ°ENIAC" + ] +} diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature index 57aacfbf47aa01881d54fd3df6bb7fe7766f109f..6ea5c5c9b2cac0d1f6eb9fbd07ee3a947ca8dbea 100644 --- a/lms/djangoapps/courseware/features/video.feature +++ b/lms/djangoapps/courseware/features/video.feature @@ -1,6 +1,6 @@ @shard_2 -Feature: LMS.Video component - As a student, I want to view course videos in LMS. +Feature: LMS Video component + As a student, I want to view course videos in LMS # 0 Scenario: Video component stores position correctly when page is reloaded @@ -58,7 +58,7 @@ Feature: LMS.Video component And error message has correct text # 8 - Scenario: Video component stores speed correctly when each video is in separate sequence. + Scenario: Video component stores speed correctly when each video is in separate sequence Given I am registered for the course "test_course" And it has a video "A" in "Youtube" mode in position "1" of sequential And a video "B" in "Youtube" mode in position "2" of sequential @@ -78,3 +78,15 @@ Feature: LMS.Video component Then video "B" should start playing at speed "0.50" When I open video "C" Then video "C" should start playing at speed "1.0" + + # 9 + Scenario: Language menu in Video component works correctly + Given the course has a Video component in Youtube mode: + | transcripts | sub | + | {"zh": "OEoXaMPEzfM"} | OEoXaMPEzfM | + And I make sure captions are closed + And I see video menu "language" with correct items + And I select language with code "zh" + Then I see "好 å„ä½åŒå¦" text in the captions + And I select language with code "en" + And I see "Hi, welcome to Edx." text in the captions diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py index 7a710cbd74c061f0d685ad69d2563c9575ae28ad..986d811684191c3e359702eec77d123d1f4f918a 100644 --- a/lms/djangoapps/courseware/features/video.py +++ b/lms/djangoapps/courseware/features/video.py @@ -1,8 +1,19 @@ +# -*- coding: utf-8 -*- #pylint: disable=C0111 from lettuce import world, step +import json from common import i_am_registered_for_the_course, section_location, visit_scenario_item from django.utils.translation import ugettext as _ +from django.conf import settings +from cache_toolbox.core import del_cached_content +from xmodule.contentstore.content import StaticContent +import os +from functools import partial +from xmodule.contentstore.django import contentstore +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT +LANGUAGES = settings.ALL_LANGUAGES + ############### ACTIONS #################### @@ -14,16 +25,23 @@ HTML5_SOURCES = [ HTML5_SOURCES_INCORRECT = [ 'https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp99', ] - VIDEO_BUTTONS = { 'CC': '.hide-subtitles', 'volume': '.volume', 'play': '.video_control.play', 'pause': '.video_control.pause', } +VIDEO_MENUS = { + 'language': '.lang .menu', + 'speed': '.speed .menu', +} -# We should wait 300 ms for event handler invocation + 200ms for safety. -DELAY = 0.5 +VIDEO_BUTTONS = { + 'CC': '.hide-subtitles', + 'volume': '.volume', + 'play': '.video_control.play', + 'pause': '.video_control.pause', +} coursenum = 'test_course' sequence = {} @@ -33,20 +51,20 @@ def does_not_autoplay(_step, video_type): assert(world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False') -@step('the course has a Video component in (.*) mode$') +@step('the course has a Video component in (.*) mode(?:\:)?$') def view_video(_step, player_mode): i_am_registered_for_the_course(_step, coursenum) # Make sure we have a video - add_video_to_course(coursenum, player_mode.lower()) + add_video_to_course(coursenum, player_mode.lower(), _step.hashes) visit_scenario_item('SECTION') -@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential$') +@step('a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential(?:\:)?$') def add_video(_step, player_id, player_mode, position): sequence[player_id] = position - add_video_to_course(coursenum, player_mode.lower(), display_name=player_id) + add_video_to_course(coursenum, player_mode.lower(), _step.hashes, display_name=player_id) @step('I open the section with videos$') @@ -70,49 +88,55 @@ def check_video_speed(_step, player_id, speed): speed_css = '.speeds p.active' assert world.css_has_text(speed_css, '{0}x'.format(speed)) -def add_video_to_course(course, player_mode, display_name='Video'): + +def add_video_to_course(course, player_mode, hashes, display_name='Video'): category = 'video' kwargs = { 'parent_location': section_location(course), 'category': category, - 'display_name': display_name + 'display_name': display_name, + 'metadata': {}, } if player_mode == 'html5': - kwargs.update({ - 'metadata': { - 'youtube_id_1_0': '', - 'youtube_id_0_75': '', - 'youtube_id_1_25': '', - 'youtube_id_1_5': '', - 'html5_sources': HTML5_SOURCES - } + kwargs['metadata'].update({ + 'youtube_id_1_0': '', + 'youtube_id_0_75': '', + 'youtube_id_1_25': '', + 'youtube_id_1_5': '', + 'html5_sources': HTML5_SOURCES }) if player_mode == 'youtube_html5': - kwargs.update({ - 'metadata': { - 'html5_sources': HTML5_SOURCES - } + kwargs['metadata'].update({ + 'html5_sources': HTML5_SOURCES }) if player_mode == 'youtube_html5_unsupported_video': - kwargs.update({ - 'metadata': { - 'html5_sources': HTML5_SOURCES_INCORRECT - } + kwargs['metadata'].update({ + 'html5_sources': HTML5_SOURCES_INCORRECT }) if player_mode == 'html5_unsupported_video': - kwargs.update({ - 'metadata': { - 'youtube_id_1_0': '', - 'youtube_id_0_75': '', - 'youtube_id_1_25': '', - 'youtube_id_1_5': '', - 'html5_sources': HTML5_SOURCES_INCORRECT - } + kwargs['metadata'].update({ + 'youtube_id_1_0': '', + 'youtube_id_0_75': '', + 'youtube_id_1_25': '', + 'youtube_id_1_5': '', + 'html5_sources': HTML5_SOURCES_INCORRECT }) - world.ItemFactory.create(**kwargs) + if hashes: + kwargs['metadata'].update(hashes[0]) + + if 'transcripts' in kwargs['metadata']: + kwargs['metadata']['transcripts'] = json.loads(kwargs['metadata']['transcripts']) + + if 'sub' in kwargs['metadata']: + _upload_file(kwargs['metadata']['sub'], 'en', world.scenario_dict['COURSE'].location) + + for lang, videoId in kwargs['metadata']['transcripts'].items(): + _upload_file(videoId, lang, world.scenario_dict['COURSE'].location) + + world.scenario_dict['VIDEO'] = world.ItemFactory.create(**kwargs) @step('youtube server is up and response time is (.*) seconds$') @@ -152,6 +176,92 @@ def error_message_has_correct_text(_step): assert world.css_has_text(selector, text) +@step('I make sure captions are (.+)$') +def set_captions_visibility_state(_step, captions_state): + SELECTOR = '.closed .subtitles' + if world.is_css_not_present(SELECTOR): + if captions_state == 'closed': + world.css_find('.hide-subtitles').click() + else: + if captions_state != 'closed': + world.css_find('.hide-subtitles').click() + + +@step('I see video menu "([^"]*)" with correct items$') +def i_see_menu(_step, menu): + _open_menu(menu) + menu_items = world.css_find(VIDEO_MENUS[menu] + ' li') + Video = world.scenario_dict['VIDEO'] + transcripts = dict(Video.transcripts) + if Video.sub: + transcripts.update({ + 'en': Video.sub + }) + + languages = {i[0]: i[1] for i in LANGUAGES} + transcripts = {k: languages[k] for k in transcripts} + + for code, label in transcripts.items(): + assert any([i.text == label for i in menu_items]) + assert any([i['data-lang-code'] == code for i in menu_items]) + + +@step('I see "([^"]*)" text in the captions$') +def check_text_in_the_captions(_step, text): + assert world.browser.is_text_present(text.strip()) + + +@step('I select language with code "([^"]*)"$') +def select_language(_step, code): + _open_menu("language") + selector = VIDEO_MENUS["language"] + ' li[data-lang-code={code}]'.format( + code=code + ) + item = world.css_find(selector) + + item.click() + + assert world.css_has_class(selector, 'active') + assert len(world.css_find(VIDEO_MENUS["language"] + ' li.active')) == 1 + assert world.css_visible('.subtitles') + world.wait_for_ajax_complete() + + +@step('I click on video button "([^"]*)"$') +def click_button(_step, button): + world.css_find(VIDEO_BUTTONS[button]).click() + + +def _upload_file(videoId, lang, location): + if lang == 'en': + filename = 'subs_{0}.srt.sjson'.format(videoId) + else: + filename = '{0}_subs_{1}.srt.sjson'.format(lang, videoId) + + path = os.path.join(TEST_ROOT, 'uploads/', filename) + f = open(os.path.abspath(path)) + mime_type = "application/json" + + content_location = StaticContent.compute_location( + location.org, location.course, filename + ) + + sc_partial = partial(StaticContent, content_location, filename, mime_type) + content = sc_partial(f.read()) + + (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail( + content, + tempfile_path=None + ) + del_cached_content(thumbnail_location) + + if thumbnail_content is not None: + content.thumbnail_location = thumbnail_location + + contentstore().save(content) + del_cached_content(content.location) + + def _navigate_to_an_item_in_a_sequence(number): sequence_css = 'a[data-element="{0}"]'.format(number) world.css_click(sequence_css) @@ -165,7 +275,6 @@ def _change_video_speed(speed): @step('I click video button "([^"]*)"$') def click_button_video(_step, button_type): - world.wait(DELAY) world.wait_for_ajax_complete() button = button_type.strip() world.css_click(VIDEO_BUTTONS[button]) @@ -184,3 +293,9 @@ def seek_video_to_n_seconds(_step, seconds): time = float(seconds.strip()) jsCode = "$('.video').data('video-player-state').videoPlayer.onSlideSeek({{time: {0:f}}})".format(time) world.browser.execute_script(jsCode) + + +def _open_menu(menu): + world.browser.execute_script("$('{selector}').parent().addClass('open')".format( + selector=VIDEO_MENUS[menu] + )) diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index fcd92515ef5679e196a946a9362d85d742897d20..6271024483389222b9ac744a438ad710e9aee728 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -90,9 +90,11 @@ class BaseTestXmodule(ModuleStoreTestCase): self.item_descriptor._field_data = LmsFieldData(self.item_descriptor._field_data, student_data) self.item_descriptor.xmodule_runtime = self.new_module_runtime() - self.item_module = self.item_descriptor - self.item_url = Location(self.item_module.location).url() + #self.item_module = self.item_descriptor.xmodule_runtime.xmodule_instance + #self.item_module is None at this time + + self.item_url = Location(self.item_descriptor.location).url() def setup_course(self): self.course = CourseFactory.create(data=self.COURSE_DATA) @@ -130,7 +132,7 @@ class BaseTestXmodule(ModuleStoreTestCase): self.assertTrue(all(self.login_statuses)) def setUp(self): - self.setup_course(); + self.setup_course() self.initialize_module(metadata=self.METADATA, data=self.DATA) def get_url(self, dispatch): diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py index adc89fbbbd11bad54a1b08d71414d83c9473ad34..e98b4b5ec95d266c60c04a99c9574a567beb226b 100644 --- a/lms/djangoapps/courseware/tests/test_lti_integration.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -27,8 +27,8 @@ class TestLTI(BaseTestXmodule): mocked_signature_after_sign = u'my_signature%3D' mocked_decoded_signature = u'my_signature=' - lti_id = self.item_module.lti_id - module_id = unicode(urllib.quote(self.item_module.id)) + lti_id = self.item_descriptor.lti_id + module_id = unicode(urllib.quote(self.item_descriptor.id)) user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id) sourcedId = "{id}:{resource_link}:{user_id}".format( @@ -38,9 +38,9 @@ class TestLTI(BaseTestXmodule): ) lis_outcome_service_url = 'https://{host}{path}'.format( - host=self.item_descriptor.xmodule_runtime.hostname, - path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?') - ) + host=self.item_descriptor.xmodule_runtime.hostname, + path=self.item_descriptor.xmodule_runtime.handler_url(self.item_descriptor, 'grade_handler', thirdparty=True).rstrip('/?') + ) self.correct_headers = { u'user_id': user_id, u'oauth_callback': u'about:blank', @@ -63,13 +63,13 @@ class TestLTI(BaseTestXmodule): saved_sign = oauthlib.oauth1.Client.sign self.expected_context = { - 'display_name': self.item_module.display_name, + 'display_name': self.item_descriptor.display_name, 'input_fields': self.correct_headers, - 'element_class': self.item_module.category, - 'element_id': self.item_module.location.html_id(), + 'element_class': self.item_descriptor.category, + 'element_id': self.item_descriptor.location.html_id(), 'launch_url': 'http://www.example.com', # default value 'open_in_a_new_page': True, - 'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'preview_handler').rstrip('/?'), + 'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_descriptor, 'preview_handler').rstrip('/?'), } def mocked_sign(self, *args, **kwargs): @@ -92,11 +92,11 @@ class TestLTI(BaseTestXmodule): self.addCleanup(patcher.stop) def test_lti_constructor(self): - generated_content = self.item_module.render('student_view').content + generated_content = self.item_descriptor.render('student_view').content expected_content = self.runtime.render_template('lti.html', self.expected_context) self.assertEqual(generated_content, expected_content) def test_lti_preview_handler(self): - generated_content = self.item_module.preview_handler(None, None).body + generated_content = self.item_descriptor.preview_handler(None, None).body expected_content = self.runtime.render_template('lti_form.html', self.expected_context) self.assertEqual(generated_content, expected_content) diff --git a/lms/djangoapps/courseware/tests/test_video_handlers.py b/lms/djangoapps/courseware/tests/test_video_handlers.py new file mode 100644 index 0000000000000000000000000000000000000000..731f11b2d17cf23c5ef6e43d7656b42ab7cb8b03 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_video_handlers.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +"""Video xmodule tests in mongo.""" + +from mock import patch +import os +import tempfile +import textwrap +import json +from datetime import timedelta +from webob import Request + +from xmodule.contentstore.content import StaticContent +from xmodule.modulestore import Location +from xmodule.contentstore.django import contentstore +from . import BaseTestXmodule +from .test_video_xml import SOURCE_XML +from cache_toolbox.core import del_cached_content +from xmodule.exceptions import NotFoundError + + +def _create_srt_file(content=None): + """ + Create srt file in filesystem. + """ + content = content or textwrap.dedent(""" + 0 + 00:00:00,12 --> 00:00:00,100 + Привіт, edX вітає ваÑ. + """) + srt_file = tempfile.NamedTemporaryFile(suffix=".srt") + srt_file.content_type = 'application/x-subrip' + srt_file.write(content) + srt_file.seek(0) + return srt_file + + +def _clear_assets(location): + """ + Clear all assets for location. + """ + store = contentstore() + + content_location = StaticContent.compute_location( + location.org, location.course, location.name + ) + + assets, __ = store.get_all_content_for_course(content_location) + for asset in assets: + asset_location = Location(asset["_id"]) + del_cached_content(asset_location) + id = StaticContent.get_id_from_location(asset_location) + store.delete(id) + + +def _get_subs_id(filename): + basename = os.path.splitext(os.path.basename(filename))[0] + return basename.replace('subs_', '').replace('.srt', '') + + +def _create_file(content=''): + """ + Create temporary subs_somevalue.srt.sjson file. + """ + sjson_file = tempfile.NamedTemporaryFile(prefix="subs_", suffix=".srt.sjson") + sjson_file.content_type = 'application/json' + sjson_file.write(textwrap.dedent(content)) + sjson_file.seek(0) + return sjson_file + + +def _upload_sjson_file(subs_file, location, default_filename='subs_{}.srt.sjson'): + filename = default_filename.format(_get_subs_id(subs_file.name)) + _upload_file(subs_file, location, filename) + + +def _upload_file(subs_file, location, filename): + mime_type = subs_file.content_type + content_location = StaticContent.compute_location( + location.org, location.course, filename + ) + content = StaticContent(content_location, filename, mime_type, subs_file.read()) + contentstore().save(content) + del_cached_content(content.location) + + +class TestVideo(BaseTestXmodule): + """Integration tests: web client + mongo.""" + CATEGORY = "video" + DATA = SOURCE_XML + METADATA = {} + + def test_handle_ajax_wrong_dispatch(self): + responses = { + user.username: self.clients[user.username].post( + self.get_url('whatever'), + {}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + for user in self.users + } + + self.assertEqual( + set([ + response.status_code + for _, response in responses.items() + ]).pop(), + 404) + + def test_handle_ajax(self): + + data = [ + {'speed': 2.0}, + {'saved_video_position': "00:00:10"}, + {'transcript_language': json.dumps('uk')}, + ] + for sample in data: + response = self.clients[self.users[0].username].post( + self.get_url('save_user_state'), + sample, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(response.status_code, 200) + + self.assertEqual(self.item_descriptor.speed, None) + self.item_descriptor.handle_ajax('save_user_state', {'speed': json.dumps(2.0)}) + self.assertEqual(self.item_descriptor.speed, 2.0) + self.assertEqual(self.item_descriptor.global_speed, 2.0) + + self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0)) + self.item_descriptor.handle_ajax('save_user_state', {'saved_video_position': "00:00:10"}) + self.assertEqual(self.item_descriptor.saved_video_position, timedelta(0, 10)) + + self.assertEqual(self.item_descriptor.transcript_language, 'en') + self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': json.dumps("uk")}) + self.assertEqual(self.item_descriptor.transcript_language, 'uk') + + def tearDown(self): + _clear_assets(self.item_descriptor.location) + + +class TestVideoTranscriptTranslation(TestVideo): + """ + Test video handlers that provide translation transcripts. + """ + + non_en_file = _create_srt_file() + DATA = """ + <video show_captions="true" + display_name="A Name" + > + <source src="example.mp4"/> + <source src="example.webm"/> + <transcript language="uk" src="{}"/> + </video> + """.format(os.path.split(non_en_file.name)[1]) + + MODEL_DATA = { + 'data': DATA + } + + def setUp(self): + super(TestVideoTranscriptTranslation, self).setUp() + self.item_descriptor.render('student_view') + self.item = self.item_descriptor.xmodule_runtime.xmodule_instance + + def test_language_is_not_supported(self): + request = Request.blank('/download?language=ru') + response = self.item.transcript(request=request, dispatch='download') + self.assertEqual(response.status, '404 Not Found') + + def test_download_transcript_not_exist(self): + request = Request.blank('/download?language=en') + response = self.item.transcript(request=request, dispatch='download') + self.assertEqual(response.status, '404 Not Found') + + @patch('xmodule.video_module.VideoModule.get_transcript', return_value='Subs!') + def test_download_exist(self, __): + request = Request.blank('/download?language=en') + response = self.item.transcript(request=request, dispatch='download') + self.assertEqual(response.body, 'Subs!') + + def test_translation_fails(self): + # No videoId + request = Request.blank('/translation?language=ru') + response = self.item.transcript(request=request, dispatch='translation') + self.assertEqual(response.status, '400 Bad Request') + + # Language is not in available languages + request = Request.blank('/translation?language=ru&videoId=12345') + response = self.item.transcript(request=request, dispatch='translation') + self.assertEqual(response.status, '404 Not Found') + + def test_translaton_en_success(self): + subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]} + good_sjson = _create_file(json.dumps(subs)) + _upload_sjson_file(good_sjson, self.item_descriptor.location) + subs_id = _get_subs_id(good_sjson.name) + + self.item.sub = subs_id + request = Request.blank('/translation?language=en&videoId={}'.format(subs_id)) + response = self.item.transcript(request=request, dispatch='translation') + self.assertDictEqual(json.loads(response.body), subs) + + def test_translaton_non_en_non_youtube_success(self): + subs = { + u'end': [100], + u'start': [12], + u'text': [ + u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.' + ] + } + self.non_en_file.seek(0) + _upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1]) + subs_id = _get_subs_id(self.non_en_file.name) + + # manually clean youtube_id_1_0, as it has default value + self.item.youtube_id_1_0 = "" + request = Request.blank('/translation?language=uk&videoId={}'.format(subs_id)) + response = self.item.transcript(request=request, dispatch='translation') + self.assertDictEqual(json.loads(response.body), subs) + + def test_translation_non_en_youtube(self): + subs = { + u'end': [100], + u'start': [12], + u'text': [ + u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.' + ]} + self.non_en_file.seek(0) + _upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1]) + subs_id = _get_subs_id(self.non_en_file.name) + + # youtube 1_0 request, will generate for all speeds for existing ids + self.item.youtube_id_1_0 = subs_id + self.item.youtube_id_0_75 = '0_75' + request = Request.blank('/translation?language=uk&videoId={}'.format(subs_id)) + response = self.item.transcript(request=request, dispatch='translation') + self.assertDictEqual(json.loads(response.body), subs) + + # 0_75 subs are exist + request = Request.blank('/translation?language=uk&videoId={}'.format('0_75')) + response = self.item.transcript(request=request, dispatch='translation') + calculated_0_75 = { + u'end': [75], + u'start': [9], + u'text': [ + u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.' + ] + } + self.assertDictEqual(json.loads(response.body), calculated_0_75) + # 1_5 will be generated from 1_0 + self.item.youtube_id_1_5 = '1_5' + request = Request.blank('/translation?language=uk&videoId={}'.format('1_5')) + response = self.item.transcript(request=request, dispatch='translation') + calculated_1_5 = { + u'end': [150], + u'start': [18], + u'text': [ + u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.' + ] + } + self.assertDictEqual(json.loads(response.body), calculated_1_5) + + +class TestVideoTranscriptsDownload(TestVideo): + """ + Make sure that `get_transcript` method works correctly + """ + + DATA = """ + <video show_captions="true" + display_name="A Name" + > + <source src="example.mp4"/> + <source src="example.webm"/> + </video> + """ + MODEL_DATA = { + 'data': DATA + } + METADATA = {} + + def setUp(self): + super(TestVideoTranscriptsDownload, self).setUp() + self.item_descriptor.render('student_view') + self.item = self.item_descriptor.xmodule_runtime.xmodule_instance + + def test_good_transcript(self): + good_sjson = _create_file(content=textwrap.dedent("""\ + { + "start": [ + 270, + 2720 + ], + "end": [ + 2720, + 5430 + ], + "text": [ + "Hi, welcome to Edx.", + "Let's start with what is on your screen right now." + ] + } + """)) + + _upload_sjson_file(good_sjson, self.item.location) + self.item.sub = _get_subs_id(good_sjson.name) + text = self.item.get_transcript() + expected_text = textwrap.dedent("""\ + 0 + 00:00:00,270 --> 00:00:02,720 + Hi, welcome to Edx. + + 1 + 00:00:02,720 --> 00:00:05,430 + Let's start with what is on your screen right now. + + """) + + self.assertEqual(text, expected_text) + + def test_not_found_error(self): + with self.assertRaises(NotFoundError): + self.item.get_transcript() + + def test_value_error(self): + good_sjson = _create_file(content='bad content') + + _upload_sjson_file(good_sjson, self.item.location) + self.item.sub = _get_subs_id(good_sjson.name) + + with self.assertRaises(ValueError): + self.item.get_transcript() + + def test_key_error(self): + good_sjson = _create_file(content=""" + { + "start": [ + 270, + 2720 + ], + "end": [ + 2720, + 5430 + ] + } + """) + + _upload_sjson_file(good_sjson, self.item.location) + self.item.sub = _get_subs_id(good_sjson.name) + + with self.assertRaises(KeyError): + self.item.get_transcript() diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 552f591d0573b71816ac32f7697d2969340ab0e8..80849286eaa9d4b335a7dca7635ea23546b45d4f 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -1,46 +1,13 @@ # -*- coding: utf-8 -*- """Video xmodule tests in mongo.""" - from mock import patch, PropertyMock -import os -import tempfile -import textwrap -from functools import partial - -from xmodule.contentstore.content import StaticContent -from xmodule.modulestore import Location -from xmodule.contentstore.django import contentstore +import json + from . import BaseTestXmodule from .test_video_xml import SOURCE_XML +from .test_video_handlers import TestVideo from django.conf import settings -from xmodule.video_module import _create_youtube_string -from cache_toolbox.core import del_cached_content -from xmodule.exceptions import NotFoundError - -class TestVideo(BaseTestXmodule): - """Integration tests: web client + mongo.""" - CATEGORY = "video" - DATA = SOURCE_XML - METADATA = {} - - def test_handle_ajax_dispatch(self): - responses = { - user.username: self.clients[user.username].post( - self.get_url('whatever'), - {}, - HTTP_X_REQUESTED_WITH='XMLHttpRequest') - for user in self.users - } - - self.assertEqual( - set([ - response.status_code - for _, response in responses.items() - ]).pop(), - 404) - - def tearDown(self): - _clear_assets(self.item_module.location) +from xmodule.video_module import create_youtube_string class TestVideoYouTube(TestVideo): @@ -48,7 +15,7 @@ class TestVideoYouTube(TestVideo): def test_video_constructor(self): """Make sure that all parameters extracted correctly from xml""" - context = self.item_module.render('student_view').content + context = self.item_descriptor.render('student_view').content sources = { 'main': u'example.mp4', @@ -58,12 +25,12 @@ class TestVideoYouTube(TestVideo): expected_context = { 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', + 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), 'data_dir': getattr(self, 'data_dir', None), - 'caption_asset_path': '/static/subs/', - 'show_captions': 'true', 'display_name': u'A Name', 'end': 3610.0, - 'id': self.item_module.location.html_id(), + 'id': self.item_descriptor.location.html_id(), + 'show_captions': 'true', 'sources': sources, 'speed': 'null', 'general_speed': 1.0, @@ -71,15 +38,21 @@ class TestVideoYouTube(TestVideo): 'saved_video_position': 0.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, - 'youtube_streams': _create_youtube_string(self.item_module), - 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False), + 'youtube_streams': create_youtube_string(self.item_descriptor), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_language': 'en', + 'transcript_languages': '{"en": "English", "uk": "Ukrainian"}', + 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/translation', + 'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/available_translations', } - self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context), + self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context), ) @@ -111,15 +84,14 @@ class TestVideoNonYouTube(TestVideo): u'webm': u'example.webm', } - context = self.item_module.render('student_view').content + context = self.item_descriptor.render('student_view').content expected_context = { 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'data_dir': getattr(self, 'data_dir', None), - 'caption_asset_path': '/static/subs/', 'show_captions': 'true', 'display_name': u'A Name', 'end': 3610.0, - 'id': self.item_module.location.html_id(), + 'id': self.item_descriptor.location.html_id(), 'sources': sources, 'speed': 'null', 'general_speed': 1.0, @@ -131,11 +103,19 @@ class TestVideoNonYouTube(TestVideo): 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_language': 'en', + 'transcript_languages': '{"en": "English"}', + 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/translation', + 'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/available_translations', } self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context), + self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context), ) @@ -192,7 +172,6 @@ class TestGetHtmlMethod(BaseTestXmodule): expected_context = { 'data_dir': getattr(self, 'data_dir', None), - 'caption_asset_path': '/static/subs/', 'show_captions': 'true', 'display_name': u'A Name', 'end': 3610.0, @@ -222,20 +201,30 @@ class TestGetHtmlMethod(BaseTestXmodule): ) self.initialize_module(data=DATA) - track_url = self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'download_transcript') + track_url = self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/download' - context = self.item_module.render('student_view').content + context = self.item_descriptor.render('student_view').content expected_context.update({ + 'transcript_languages': '{"en": "English"}' if self.item_descriptor.sub else '{}', + 'transcript_language': 'en' if self.item_descriptor.sub else json.dumps(None), + 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/translation', + 'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/available_translations', 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'track': track_url if data['expected_track_url'] == u'a_sub_file.srt.sjson' else data['expected_track_url'], 'sub': data['sub'], - 'id': self.item_module.location.html_id(), + 'id': self.item_descriptor.location.html_id(), }) self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context), + self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context), ) def test_get_html_source(self): @@ -301,7 +290,6 @@ class TestGetHtmlMethod(BaseTestXmodule): expected_context = { 'data_dir': getattr(self, 'data_dir', None), - 'caption_asset_path': '/static/subs/', 'show_captions': 'true', 'display_name': u'A Name', 'end': 3610.0, @@ -317,6 +305,8 @@ class TestGetHtmlMethod(BaseTestXmodule): 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), 'yt_test_timeout': 1500, 'yt_test_url': 'https://gdata.youtube.com/feeds/api/videos/', + 'transcript_language': 'en', + 'transcript_languages': '{"en": "English"}', } for data in cases: @@ -326,17 +316,23 @@ class TestGetHtmlMethod(BaseTestXmodule): sources=data['sources'] ) self.initialize_module(data=DATA) - context = self.item_module.render('student_view').content + context = self.item_descriptor.render('student_view').content expected_context.update({ + 'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/translation', + 'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url( + self.item_descriptor, 'transcript' + ).rstrip('/?') + '/available_translations', 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state', 'sources': data['result'], - 'id': self.item_module.location.html_id(), + 'id': self.item_descriptor.location.html_id(), }) self.assertEqual( context, - self.item_module.xmodule_runtime.render_template('video.html', expected_context) + self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context) ) @@ -361,9 +357,9 @@ class TestVideoDescriptorInitialization(BaseTestXmodule): fields = self.item_descriptor.editable_metadata_fields self.assertIn('source', fields) - self.assertEqual(self.item_module.source, 'http://example.org/video.mp4') - self.assertTrue(self.item_module.download_video) - self.assertTrue(self.item_module.source_visible) + self.assertEqual(self.item_descriptor.source, 'http://example.org/video.mp4') + self.assertTrue(self.item_descriptor.download_video) + self.assertTrue(self.item_descriptor.source_visible) def test_source_in_html5sources(self): metadata = { @@ -375,10 +371,10 @@ class TestVideoDescriptorInitialization(BaseTestXmodule): fields = self.item_descriptor.editable_metadata_fields self.assertNotIn('source', fields) - self.assertTrue(self.item_module.download_video) - self.assertFalse(self.item_module.source_visible) + self.assertTrue(self.item_descriptor.download_video) + self.assertFalse(self.item_descriptor.source_visible) - @patch('xmodule.x_module.XModuleDescriptor.editable_metadata_fields', new_callable=PropertyMock) + @patch('xmodule.video_module.VideoDescriptor.editable_metadata_fields', new_callable=PropertyMock) def test_download_video_is_explicitly_set(self, mock_editable_fields): mock_editable_fields.return_value = { 'download_video': { @@ -445,9 +441,9 @@ class TestVideoDescriptorInitialization(BaseTestXmodule): fields = self.item_descriptor.editable_metadata_fields self.assertIn('source', fields) - self.assertFalse(self.item_module.download_video) - self.assertTrue(self.item_module.source_visible) - self.assertTrue(self.item_module.download_track) + self.assertFalse(self.item_descriptor.download_video) + self.assertTrue(self.item_descriptor.source_visible) + self.assertTrue(self.item_descriptor.download_track) def test_source_is_empty(self): metadata = { @@ -459,152 +455,4 @@ class TestVideoDescriptorInitialization(BaseTestXmodule): fields = self.item_descriptor.editable_metadata_fields self.assertNotIn('source', fields) - self.assertFalse(self.item_module.download_video) - - -class TestVideoGetTranscriptsMethod(TestVideo): - """ - Make sure that `get_transcript` method works correctly - """ - - DATA = """ - <video show_captions="true" - display_name="A Name" - > - <source src="example.mp4"/> - <source src="example.webm"/> - </video> - """ - MODEL_DATA = { - 'data': DATA - } - METADATA = {} - - def test_good_transcript(self): - self.item_module.render('student_view') - item = self.item_descriptor.xmodule_runtime.xmodule_instance - - good_sjson = _create_file(content=textwrap.dedent("""\ - { - "start": [ - 270, - 2720 - ], - "end": [ - 2720, - 5430 - ], - "text": [ - "Hi, welcome to Edx.", - "Let's start with what is on your screen right now." - ] - } - """)) - - _upload_file(good_sjson, self.item_module.location) - subs_id = _get_subs_id(good_sjson.name) - - text = item.get_transcript(subs_id) - expected_text = textwrap.dedent("""\ - 0 - 00:00:00,270 --> 00:00:02,720 - Hi, welcome to Edx. - - 1 - 00:00:02,720 --> 00:00:05,430 - Let's start with what is on your screen right now. - - """) - - self.assertEqual(text, expected_text) - - def test_not_found_error(self): - self.item_module.render('student_view') - item = self.item_descriptor.xmodule_runtime.xmodule_instance - - with self.assertRaises(NotFoundError): - item.get_transcript('wrong') - - def test_value_error(self): - self.item_module.render('student_view') - item = self.item_descriptor.xmodule_runtime.xmodule_instance - - good_sjson = _create_file(content='bad content') - - _upload_file(good_sjson, self.item_module.location) - subs_id = _get_subs_id(good_sjson.name) - - with self.assertRaises(ValueError): - item.get_transcript(subs_id) - - def test_key_error(self): - self.item_module.render('student_view') - item = self.item_descriptor.xmodule_runtime.xmodule_instance - - good_sjson = _create_file(content=""" - { - "start": [ - 270, - 2720 - ], - "end": [ - 2720, - 5430 - ] - } - """) - - _upload_file(good_sjson, self.item_module.location) - subs_id = _get_subs_id(good_sjson.name) - - with self.assertRaises(KeyError): - item.get_transcript(subs_id) - - -def _clear_assets(location): - store = contentstore() - - content_location = StaticContent.compute_location( - location.org, location.course, location.name - ) - - assets, __ = store.get_all_content_for_course(content_location) - for asset in assets: - asset_location = Location(asset["_id"]) - id = StaticContent.get_id_from_location(asset_location) - store.delete(id) - -def _get_subs_id(filename): - basename = os.path.splitext(os.path.basename(filename))[0] - return basename.replace('subs_', '').replace('.srt', '') - -def _create_file(content=''): - sjson_file = tempfile.NamedTemporaryFile(prefix="subs_", suffix=".srt.sjson") - sjson_file.content_type = 'application/json' - sjson_file.write(textwrap.dedent(content)) - sjson_file.seek(0) - - return sjson_file - -def _upload_file(file, location): - filename = 'subs_{}.srt.sjson'.format(_get_subs_id(file.name)) - mime_type = file.content_type - - content_location = StaticContent.compute_location( - location.org, location.course, filename - ) - - sc_partial = partial(StaticContent, content_location, filename, mime_type) - content = sc_partial(file.read()) - - (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail( - content, - tempfile_path=None - ) - del_cached_content(thumbnail_location) - - if thumbnail_content is not None: - content.thumbnail_location = thumbnail_location - - contentstore().save(content) - del_cached_content(content.location) + self.assertFalse(self.item_descriptor.download_video) diff --git a/lms/djangoapps/courseware/tests/test_video_xml.py b/lms/djangoapps/courseware/tests/test_video_xml.py index 81573f6c10e9b8ec6cf086c433e5f4d54a96c3ef..cea2bc865c3edae6cf99424e74dfb56ed3a82852 100644 --- a/lms/djangoapps/courseware/tests/test_video_xml.py +++ b/lms/djangoapps/courseware/tests/test_video_xml.py @@ -32,6 +32,7 @@ SOURCE_XML = """ > <source src="example.mp4"/> <source src="example.webm"/> + <transcript language="uk" src="ukrainian_translation.srt" /> </video> """ diff --git a/lms/djangoapps/courseware/tests/test_word_cloud.py b/lms/djangoapps/courseware/tests/test_word_cloud.py index 53e7c208506776d83f7248e73cbcd327c12c33d7..3d2a235e41964564f24b8c33f21f1460e73763a3 100644 --- a/lms/djangoapps/courseware/tests/test_word_cloud.py +++ b/lms/djangoapps/courseware/tests/test_word_cloud.py @@ -242,12 +242,12 @@ class TestWordCloud(BaseTestXmodule): def test_word_cloud_constructor(self): """Make sure that all parameters extracted correclty from xml""" - fragment = self.runtime.render(self.item_module, 'student_view') + fragment = self.runtime.render(self.item_descriptor, 'student_view') expected_context = { - 'ajax_url': self.item_module.xmodule_runtime.ajax_url, - 'element_class': self.item_module.location.category, - 'element_id': self.item_module.location.html_id(), + 'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url, + 'element_class': self.item_descriptor.location.category, + 'element_id': self.item_descriptor.location.html_id(), 'num_inputs': 5, # default value 'submitted': False # default value } diff --git a/lms/envs/common.py b/lms/envs/common.py index d8d571cf9b27eb81f2e713ad0766524c8dcfcd70..29929616e720a298cbccd5216ce13d94c4a957f0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1287,3 +1287,193 @@ MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60 ##### LMS DEADLINE DISPLAY TIME_ZONE ####### TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC' + + +# Source: +# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1 +ALL_LANGUAGES = ( + [u"aa", u"Afar"], + [u"ab", u"Abkhazian"], + [u"af", u"Afrikaans"], + [u"ak", u"Akan"], + [u"sq", u"Albanian"], + [u"am", u"Amharic"], + [u"ar", u"Arabic"], + [u"an", u"Aragonese"], + [u"hy", u"Armenian"], + [u"as", u"Assamese"], + [u"av", u"Avaric"], + [u"ae", u"Avestan"], + [u"ay", u"Aymara"], + [u"az", u"Azerbaijani"], + [u"ba", u"Bashkir"], + [u"bm", u"Bambara"], + [u"eu", u"Basque"], + [u"be", u"Belarusian"], + [u"bn", u"Bengali"], + [u"bh", u"Bihari languages"], + [u"bi", u"Bislama"], + [u"bs", u"Bosnian"], + [u"br", u"Breton"], + [u"bg", u"Bulgarian"], + [u"my", u"Burmese"], + [u"ca", u"Catalan; Valencian"], + [u"ch", u"Chamorro"], + [u"ce", u"Chechen"], + [u"zh", u"Chinese"], + [u"cu", u"Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic"], + [u"cv", u"Chuvash"], + [u"kw", u"Cornish"], + [u"co", u"Corsican"], + [u"cr", u"Cree"], + [u"cs", u"Czech"], + [u"da", u"Danish"], + [u"dv", u"Divehi; Dhivehi; Maldivian"], + [u"nl", u"Dutch; Flemish"], + [u"dz", u"Dzongkha"], + [u"en", u"English"], + [u"eo", u"Esperanto"], + [u"et", u"Estonian"], + [u"ee", u"Ewe"], + [u"fo", u"Faroese"], + [u"fj", u"Fijian"], + [u"fi", u"Finnish"], + [u"fr", u"French"], + [u"fy", u"Western Frisian"], + [u"ff", u"Fulah"], + [u"ka", u"Georgian"], + [u"de", u"German"], + [u"gd", u"Gaelic; Scottish Gaelic"], + [u"ga", u"Irish"], + [u"gl", u"Galician"], + [u"gv", u"Manx"], + [u"el", u"Greek, Modern (1453-)"], + [u"gn", u"Guarani"], + [u"gu", u"Gujarati"], + [u"ht", u"Haitian; Haitian Creole"], + [u"ha", u"Hausa"], + [u"he", u"Hebrew"], + [u"hz", u"Herero"], + [u"hi", u"Hindi"], + [u"ho", u"Hiri Motu"], + [u"hr", u"Croatian"], + [u"hu", u"Hungarian"], + [u"ig", u"Igbo"], + [u"is", u"Icelandic"], + [u"io", u"Ido"], + [u"ii", u"Sichuan Yi; Nuosu"], + [u"iu", u"Inuktitut"], + [u"ie", u"Interlingue; Occidental"], + [u"ia", u"Interlingua (International Auxiliary Language Association)"], + [u"id", u"Indonesian"], + [u"ik", u"Inupiaq"], + [u"it", u"Italian"], + [u"jv", u"Javanese"], + [u"ja", u"Japanese"], + [u"kl", u"Kalaallisut; Greenlandic"], + [u"kn", u"Kannada"], + [u"ks", u"Kashmiri"], + [u"kr", u"Kanuri"], + [u"kk", u"Kazakh"], + [u"km", u"Central Khmer"], + [u"ki", u"Kikuyu; Gikuyu"], + [u"rw", u"Kinyarwanda"], + [u"ky", u"Kirghiz; Kyrgyz"], + [u"kv", u"Komi"], + [u"kg", u"Kongo"], + [u"ko", u"Korean"], + [u"kj", u"Kuanyama; Kwanyama"], + [u"ku", u"Kurdish"], + [u"lo", u"Lao"], + [u"la", u"Latin"], + [u"lv", u"Latvian"], + [u"li", u"Limburgan; Limburger; Limburgish"], + [u"ln", u"Lingala"], + [u"lt", u"Lithuanian"], + [u"lb", u"Luxembourgish; Letzeburgesch"], + [u"lu", u"Luba-Katanga"], + [u"lg", u"Ganda"], + [u"mk", u"Macedonian"], + [u"mh", u"Marshallese"], + [u"ml", u"Malayalam"], + [u"mi", u"Maori"], + [u"mr", u"Marathi"], + [u"ms", u"Malay"], + [u"mg", u"Malagasy"], + [u"mt", u"Maltese"], + [u"mn", u"Mongolian"], + [u"na", u"Nauru"], + [u"nv", u"Navajo; Navaho"], + [u"nr", u"Ndebele, South; South Ndebele"], + [u"nd", u"Ndebele, North; North Ndebele"], + [u"ng", u"Ndonga"], + [u"ne", u"Nepali"], + [u"nn", u"Norwegian Nynorsk; Nynorsk, Norwegian"], + [u"nb", u"BokmÃ¥l, Norwegian; Norwegian BokmÃ¥l"], + [u"no", u"Norwegian"], + [u"ny", u"Chichewa; Chewa; Nyanja"], + [u"oc", u"Occitan (post 1500); Provençal"], + [u"oj", u"Ojibwa"], + [u"or", u"Oriya"], + [u"om", u"Oromo"], + [u"os", u"Ossetian; Ossetic"], + [u"pa", u"Panjabi; Punjabi"], + [u"fa", u"Persian"], + [u"pi", u"Pali"], + [u"pl", u"Polish"], + [u"pt", u"Portuguese"], + [u"ps", u"Pushto; Pashto"], + [u"qu", u"Quechua"], + [u"rm", u"Romansh"], + [u"ro", u"Romanian; Moldavian; Moldovan"], + [u"rn", u"Rundi"], + [u"ru", u"Russian"], + [u"sg", u"Sango"], + [u"sa", u"Sanskrit"], + [u"si", u"Sinhala; Sinhalese"], + [u"sk", u"Slovak"], + [u"sl", u"Slovenian"], + [u"se", u"Northern Sami"], + [u"sm", u"Samoan"], + [u"sn", u"Shona"], + [u"sd", u"Sindhi"], + [u"so", u"Somali"], + [u"st", u"Sotho, Southern"], + [u"es", u"Spanish; Castilian"], + [u"sc", u"Sardinian"], + [u"sr", u"Serbian"], + [u"ss", u"Swati"], + [u"su", u"Sundanese"], + [u"sw", u"Swahili"], + [u"sv", u"Swedish"], + [u"ty", u"Tahitian"], + [u"ta", u"Tamil"], + [u"tt", u"Tatar"], + [u"te", u"Telugu"], + [u"tg", u"Tajik"], + [u"tl", u"Tagalog"], + [u"th", u"Thai"], + [u"bo", u"Tibetan"], + [u"ti", u"Tigrinya"], + [u"to", u"Tonga (Tonga Islands)"], + [u"tn", u"Tswana"], + [u"ts", u"Tsonga"], + [u"tk", u"Turkmen"], + [u"tr", u"Turkish"], + [u"tw", u"Twi"], + [u"ug", u"Uighur; Uyghur"], + [u"uk", u"Ukrainian"], + [u"ur", u"Urdu"], + [u"uz", u"Uzbek"], + [u"ve", u"Venda"], + [u"vi", u"Vietnamese"], + [u"vo", u"Volapük"], + [u"cy", u"Welsh"], + [u"wa", u"Walloon"], + [u"wo", u"Wolof"], + [u"xh", u"Xhosa"], + [u"yi", u"Yiddish"], + [u"yo", u"Yoruba"], + [u"za", u"Zhuang; Chuang"], + [u"zu", u"Zulu"] +) diff --git a/lms/templates/video.html b/lms/templates/video.html index 00ef4f52bb3e10c5e31590838c1ce32153d467c7..423c5af709020d64624ebd2fbe63e158441699b6 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -25,10 +25,13 @@ data-saved-video-position="${saved_video_position}" data-start="${start}" data-end="${end}" - data-caption-asset-path="${caption_asset_path}" + data-transcript-language="${transcript_language}" + data-transcript-languages='${transcript_languages}' data-autoplay="${autoplay}" data-yt-test-timeout="${yt_test_timeout}" data-yt-test-url="${yt_test_url}" + data-transcript-translation-url="${transcript_translation_url}" + data-transcript-available-translations-url="${transcript_available_translations_url}" ## For now, the option "data-autohide-html5" is hard coded. This option ## either enables or disables autohiding of controls and captions on mouse @@ -67,12 +70,12 @@ <li><div class="vidtime">0:00 / 0:00</div></li> </ul> <div class="secondary-controls"> - <div class="speeds"> + <div class="speeds menu-container"> <a href="#" title="${_('Speeds')}" role="button" aria-disabled="false"> <h3>${_('Speed')}</h3> <p class="active"></p> </a> - <ol class="video_speeds" role="menu"></ol> + <ol class="video_speeds menu" role="menu"></ol> </div> <div class="volume"> <a href="#" title="${_('Volume')}" role="button" aria-disabled="false"></a> @@ -83,7 +86,10 @@ <a href="#" class="add-fullscreen" title="${_('Fill browser')}" role="button" aria-disabled="false">${_('Fill browser')}</a> <a href="#" class="quality_control" title="${_('HD off')}" role="button" aria-disabled="false">${_('HD off')}</a> - <a href="#" class="hide-subtitles" title="${_('Turn off captions')}" role="button" aria-disabled="false">${_('Turn off captions')}</a> + <div class="lang menu-container"> + <a href="#" class="hide-subtitles" title="${_('Turn off captions')}" role="button" aria- + disabled="false">${_('Turn off captions')}</a> + </div> </div> </div> </section>