Commit 25407ef3 by Alexander Kryklia

BLD-642: Allow multiple transcripts with video.

parent 0b262e7c
......@@ -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.
......
......@@ -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'):
......
@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
......
......@@ -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) \
......
@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
......@@ -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],
......
@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
......
......@@ -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')
......
......@@ -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')
......
......@@ -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'})
......@@ -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)
......
......@@ -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
......
......@@ -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.
......
......@@ -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
......
......@@ -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;
}
}
}
......
......@@ -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>
......
......@@ -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>
......
......@@ -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"
......
......@@ -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/"
......
......@@ -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>
......
(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 () {
......
......@@ -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');
......
......@@ -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));
......@@ -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 () {
......
(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));
......@@ -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.
......
......@@ -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 });
......
......@@ -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));
......@@ -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)
......
......@@ -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):
......
"""
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 *
......@@ -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
......@@ -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
"""
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]
])
{
"start": [
270,
2720,
5430
],
"end": [
2720,
5430,
7160
],
"text": [
"好 各位同学",
"我们今天要讲的题目是",
"从算筹到ENIAC"
]
}
@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
# -*- 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]
))
......@@ -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):
......
......@@ -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)
# -*- 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&#39;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&#39;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()
# -*- 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&#39;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&#39;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)
......@@ -32,6 +32,7 @@ SOURCE_XML = """
>
<source src="example.mp4"/>
<source src="example.webm"/>
<transcript language="uk" src="ukrainian_translation.srt" />
</video>
"""
......
......@@ -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
}
......
......@@ -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"]
)
......@@ -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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment