Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
b292f6c2
Commit
b292f6c2
authored
Mar 07, 2014
by
Anton Stupak
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #2792 from edx/anton/fix-cc-button
Anton/fix cc button
parents
78fe797e
65adb7b2
Show whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
286 additions
and
213 deletions
+286
-213
common/lib/xmodule/xmodule/js/spec/video/general_spec.js
+2
-26
common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
+49
-16
common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
+0
-1
common/lib/xmodule/xmodule/js/src/video/01_initialize.js
+0
-6
common/lib/xmodule/xmodule/js/src/video/03_video_player.js
+3
-4
common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+9
-12
common/lib/xmodule/xmodule/video_module/video_module.py
+36
-25
lms/djangoapps/courseware/features/video.feature
+50
-11
lms/djangoapps/courseware/features/video.py
+100
-90
lms/djangoapps/courseware/tests/test_video_handlers.py
+37
-22
No files found.
common/lib/xmodule/xmodule/js/spec/video/general_spec.js
View file @
b292f6c2
...
@@ -75,32 +75,8 @@
...
@@ -75,32 +75,8 @@
expect
(
state
.
el
).
toBe
(
'#video_id'
);
expect
(
state
.
el
).
toBe
(
'#video_id'
);
});
});
it
(
'parse the videos if subtitles exist'
,
function
()
{
it
(
'doesn
\'
t have `videos` dictionary'
,
function
()
{
var
sub
=
'Z5KLxerq05Y'
;
expect
(
state
.
videos
).
toBeUndefined
();
expect
(
state
.
videos
).
toEqual
({
'0.75'
:
sub
,
'1.0'
:
sub
,
'1.25'
:
sub
,
'1.50'
:
sub
});
});
it
(
'parse the videos if subtitles do not exist'
,
function
()
{
var
sub
=
''
;
$
(
'#example'
).
find
(
'.video'
).
data
(
'sub'
,
''
);
state
=
new
window
.
Video
(
'#example'
);
expect
(
state
.
videos
).
toEqual
({
'0.75'
:
sub
,
'1.0'
:
sub
,
'1.25'
:
sub
,
'1.50'
:
sub
});
});
});
it
(
'parse Html5 sources'
,
function
()
{
it
(
'parse Html5 sources'
,
function
()
{
...
...
common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
View file @
b292f6c2
...
@@ -7,8 +7,6 @@
...
@@ -7,8 +7,6 @@
window
.
onTouchBasedDevice
=
jasmine
.
createSpy
(
'onTouchBasedDevice'
)
window
.
onTouchBasedDevice
=
jasmine
.
createSpy
(
'onTouchBasedDevice'
)
.
andReturn
(
null
);
.
andReturn
(
null
);
state
=
jasmine
.
initializePlayer
();
videoControl
=
state
.
videoControl
;
$
.
fn
.
scrollTo
.
reset
();
$
.
fn
.
scrollTo
.
reset
();
});
});
...
@@ -29,18 +27,20 @@
...
@@ -29,18 +27,20 @@
describe
(
'always'
,
function
()
{
describe
(
'always'
,
function
()
{
beforeEach
(
function
()
{
beforeEach
(
function
()
{
spyOn
(
$
,
'ajaxWithPrefix'
).
andCallThrough
();
spyOn
(
$
,
'ajaxWithPrefix'
).
andCallThrough
();
state
=
jasmine
.
initializePlayer
();
});
});
it
(
'create the caption element'
,
function
()
{
it
(
'create the caption element'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
expect
(
$
(
'.video'
)).
toContain
(
'ol.subtitles'
);
expect
(
$
(
'.video'
)).
toContain
(
'ol.subtitles'
);
});
});
it
(
'add caption control to video player'
,
function
()
{
it
(
'add caption control to video player'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
expect
(
$
(
'.video'
)).
toContain
(
'a.hide-subtitles'
);
expect
(
$
(
'.video'
)).
toContain
(
'a.hide-subtitles'
);
});
});
it
(
'add ARIA attributes to caption control'
,
function
()
{
it
(
'add ARIA attributes to caption control'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
var
captionControl
=
$
(
'a.hide-subtitles'
);
var
captionControl
=
$
(
'a.hide-subtitles'
);
expect
(
captionControl
).
toHaveAttrs
({
expect
(
captionControl
).
toHaveAttrs
({
'role'
:
'button'
,
'role'
:
'button'
,
...
@@ -49,7 +49,11 @@
...
@@ -49,7 +49,11 @@
});
});
});
});
it
(
'fetch the caption'
,
function
()
{
it
(
'fetch the caption in HTML5 mode'
,
function
()
{
runs
(
function
()
{
state
=
jasmine
.
initializePlayer
();
});
waitsFor
(
function
()
{
waitsFor
(
function
()
{
if
(
state
.
videoCaption
.
loaded
===
true
)
{
if
(
state
.
videoCaption
.
loaded
===
true
)
{
return
true
;
return
true
;
...
@@ -62,29 +66,62 @@
...
@@ -62,29 +66,62 @@
expect
(
$
.
ajaxWithPrefix
).
toHaveBeenCalledWith
({
expect
(
$
.
ajaxWithPrefix
).
toHaveBeenCalledWith
({
url
:
'/transcript/translation'
,
url
:
'/transcript/translation'
,
notifyOnError
:
false
,
notifyOnError
:
false
,
data
:
{
data
:
jasmine
.
any
(
Object
),
videoId
:
'Z5KLxerq05Y'
,
success
:
jasmine
.
any
(
Function
),
error
:
jasmine
.
any
(
Function
)
});
expect
(
$
.
ajaxWithPrefix
.
mostRecentCall
.
args
[
0
].
data
)
.
toEqual
({
language
:
'en'
language
:
'en'
},
});
});
});
it
(
'fetch the caption in Youtube mode'
,
function
()
{
runs
(
function
()
{
state
=
jasmine
.
initializePlayerYouTube
();
});
waitsFor
(
function
()
{
if
(
state
.
videoCaption
.
loaded
===
true
)
{
return
true
;
}
return
false
;
},
'Expect captions to be loaded.'
,
WAIT_TIMEOUT
);
runs
(
function
()
{
expect
(
$
.
ajaxWithPrefix
).
toHaveBeenCalledWith
({
url
:
'/transcript/translation'
,
notifyOnError
:
false
,
data
:
jasmine
.
any
(
Object
),
success
:
jasmine
.
any
(
Function
),
success
:
jasmine
.
any
(
Function
),
error
:
jasmine
.
any
(
Function
)
error
:
jasmine
.
any
(
Function
)
});
});
expect
(
$
.
ajaxWithPrefix
.
mostRecentCall
.
args
[
0
].
data
)
.
toEqual
({
language
:
'en'
,
videoId
:
'abcdefghijkl'
});
});
});
});
});
it
(
'bind window resize event'
,
function
()
{
it
(
'bind window resize event'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
expect
(
$
(
window
)).
toHandleWith
(
expect
(
$
(
window
)).
toHandleWith
(
'resize'
,
state
.
videoCaption
.
resize
'resize'
,
state
.
videoCaption
.
resize
);
);
});
});
it
(
'bind the hide caption button'
,
function
()
{
it
(
'bind the hide caption button'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
expect
(
$
(
'.hide-subtitles'
)).
toHandleWith
(
expect
(
$
(
'.hide-subtitles'
)).
toHandleWith
(
'click'
,
state
.
videoCaption
.
toggle
'click'
,
state
.
videoCaption
.
toggle
);
);
});
});
it
(
'bind the mouse movement'
,
function
()
{
it
(
'bind the mouse movement'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
expect
(
$
(
'.subtitles'
)).
toHandleWith
(
expect
(
$
(
'.subtitles'
)).
toHandleWith
(
'mouseover'
,
state
.
videoCaption
.
onMouseEnter
'mouseover'
,
state
.
videoCaption
.
onMouseEnter
);
);
...
@@ -103,6 +140,7 @@
...
@@ -103,6 +140,7 @@
});
});
it
(
'bind the scroll'
,
function
()
{
it
(
'bind the scroll'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
expect
(
$
(
'.subtitles'
))
expect
(
$
(
'.subtitles'
))
.
toHandleWith
(
'scroll'
,
state
.
videoControl
.
showControls
);
.
toHandleWith
(
'scroll'
,
state
.
videoControl
.
showControls
);
});
});
...
@@ -284,7 +322,8 @@
...
@@ -284,7 +322,8 @@
describe
(
'when no captions file was specified'
,
function
()
{
describe
(
'when no captions file was specified'
,
function
()
{
beforeEach
(
function
()
{
beforeEach
(
function
()
{
state
=
jasmine
.
initializePlayer
(
'video_all.html'
,
{
state
=
jasmine
.
initializePlayer
(
'video_all.html'
,
{
'sub'
:
''
'sub'
:
''
,
'transcriptLanguages'
:
{},
});
});
});
});
...
@@ -395,6 +434,8 @@
...
@@ -395,6 +434,8 @@
});
});
it
(
'reRenderCaption'
,
function
()
{
it
(
'reRenderCaption'
,
function
()
{
state
=
jasmine
.
initializePlayer
();
var
Caption
=
state
.
videoCaption
,
var
Caption
=
state
.
videoCaption
,
li
;
li
;
...
@@ -426,14 +467,6 @@
...
@@ -426,14 +467,6 @@
spyOn
(
state
,
'youtubeId'
).
andReturn
(
'Z5KLxerq05Y'
);
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
()
{
it
(
'show caption on language change'
,
function
()
{
Caption
.
loaded
=
true
;
Caption
.
loaded
=
true
;
Caption
.
fetchCaption
();
Caption
.
fetchCaption
();
...
...
common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js
View file @
b292f6c2
...
@@ -45,7 +45,6 @@ function (VideoPlayer) {
...
@@ -45,7 +45,6 @@ function (VideoPlayer) {
it
(
'create video caption'
,
function
()
{
it
(
'create video caption'
,
function
()
{
expect
(
state
.
videoCaption
).
toBeDefined
();
expect
(
state
.
videoCaption
).
toBeDefined
();
expect
(
state
.
youtubeId
(
'1.0'
)).
toEqual
(
'Z5KLxerq05Y'
);
expect
(
state
.
speed
).
toEqual
(
'1.50'
);
expect
(
state
.
speed
).
toEqual
(
'1.50'
);
expect
(
state
.
config
.
transcriptTranslationUrl
)
expect
(
state
.
config
.
transcriptTranslationUrl
)
.
toEqual
(
'/transcript/translation'
);
.
toEqual
(
'/transcript/translation'
);
...
...
common/lib/xmodule/xmodule/js/src/video/01_initialize.js
View file @
b292f6c2
...
@@ -202,12 +202,6 @@ function (VideoPlayer, VideoStorage) {
...
@@ -202,12 +202,6 @@ function (VideoPlayer, VideoStorage) {
);
);
state
.
speeds
=
[
'0.75'
,
'1.0'
,
'1.25'
,
'1.50'
];
state
.
speeds
=
[
'0.75'
,
'1.0'
,
'1.25'
,
'1.50'
];
state
.
videos
=
{
'0.75'
:
state
.
config
.
sub
,
'1.0'
:
state
.
config
.
sub
,
'1.25'
:
state
.
config
.
sub
,
'1.50'
:
state
.
config
.
sub
};
// We must have at least one non-YouTube video source available.
// We must have at least one non-YouTube video source available.
// Otherwise, return a negative.
// Otherwise, return a negative.
...
...
common/lib/xmodule/xmodule/js/src/video/03_video_player.js
View file @
b292f6c2
...
@@ -461,7 +461,7 @@ function (HTML5Video, Resizer) {
...
@@ -461,7 +461,7 @@ function (HTML5Video, Resizer) {
this
.
videoPlayer
.
log
(
this
.
videoPlayer
.
log
(
'pause_video'
,
'pause_video'
,
{
{
'currentTime'
:
this
.
videoPlayer
.
currentTime
currentTime
:
this
.
videoPlayer
.
currentTime
}
}
);
);
...
@@ -482,7 +482,7 @@ function (HTML5Video, Resizer) {
...
@@ -482,7 +482,7 @@ function (HTML5Video, Resizer) {
this
.
videoPlayer
.
log
(
this
.
videoPlayer
.
log
(
'play_video'
,
'play_video'
,
{
{
'currentTime'
:
this
.
videoPlayer
.
currentTime
currentTime
:
this
.
videoPlayer
.
currentTime
}
}
);
);
...
@@ -863,8 +863,7 @@ function (HTML5Video, Resizer) {
...
@@ -863,8 +863,7 @@ function (HTML5Video, Resizer) {
// Default parameters that always get logged.
// Default parameters that always get logged.
logInfo
=
{
logInfo
=
{
'id'
:
this
.
id
,
id
:
this
.
id
'code'
:
this
.
youtubeId
()
};
};
// If extra parameters were passed to the log.
// If extra parameters were passed to the log.
...
...
common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
View file @
b292f6c2
...
@@ -226,14 +226,10 @@ function () {
...
@@ -226,14 +226,10 @@ function () {
*/
*/
function
fetchCaption
()
{
function
fetchCaption
()
{
var
self
=
this
,
var
self
=
this
,
Caption
=
self
.
videoCaption
;
Caption
=
self
.
videoCaption
,
// Check whether the captions file was specified. This is the point
data
=
{
// where we either stop with the caption panel (so that a white empty
language
:
this
.
getCurrentLanguage
()
// panel to the right of the video will not be shown), or carry on
};
// further.
if
(
!
this
.
youtubeId
(
'1.0'
))
{
return
false
;
}
if
(
Caption
.
loaded
)
{
if
(
Caption
.
loaded
)
{
Caption
.
hideCaptions
(
false
);
Caption
.
hideCaptions
(
false
);
...
@@ -245,15 +241,16 @@ function () {
...
@@ -245,15 +241,16 @@ function () {
Caption
.
fetchXHR
.
abort
();
Caption
.
fetchXHR
.
abort
();
}
}
if
(
this
.
videoType
===
'youtube'
)
{
data
.
videoId
=
this
.
youtubeId
();
}
// Fetch the captions file. If no file was specified, or if an error
// Fetch the captions file. If no file was specified, or if an error
// occurred, then we hide the captions panel, and the "CC" button
// occurred, then we hide the captions panel, and the "CC" button
Caption
.
fetchXHR
=
$
.
ajaxWithPrefix
({
Caption
.
fetchXHR
=
$
.
ajaxWithPrefix
({
url
:
self
.
config
.
transcriptTranslationUrl
,
url
:
self
.
config
.
transcriptTranslationUrl
,
notifyOnError
:
false
,
notifyOnError
:
false
,
data
:
{
data
:
data
,
videoId
:
this
.
youtubeId
(),
language
:
this
.
getCurrentLanguage
()
},
success
:
function
(
captions
)
{
success
:
function
(
captions
)
{
Caption
.
captions
=
captions
.
text
;
Caption
.
captions
=
captions
.
text
;
Caption
.
start
=
captions
.
start
;
Caption
.
start
=
captions
.
start
;
...
...
common/lib/xmodule/xmodule/video_module/video_module.py
View file @
b292f6c2
...
@@ -10,7 +10,6 @@ in-browser HTML5 video method (when in HTML5 mode).
...
@@ -10,7 +10,6 @@ in-browser HTML5 video method (when in HTML5 mode).
in XML.
in XML.
"""
"""
import
os
import
json
import
json
import
logging
import
logging
from
operator
import
itemgetter
from
operator
import
itemgetter
...
@@ -329,7 +328,7 @@ class VideoModule(VideoFields, XModule):
...
@@ -329,7 +328,7 @@ class VideoModule(VideoFields, XModule):
`available_translations`: returns list of languages, for which SRT files exist. For 'en' check if SJSON exists.
`available_translations`: returns list of languages, for which SRT files exist. For 'en' check if SJSON exists.
"""
"""
if
dispatch
==
'translation'
:
if
dispatch
==
'translation'
:
if
'language'
not
in
request
.
GET
or
'videoId'
not
in
request
.
GET
:
if
'language'
not
in
request
.
GET
:
log
.
info
(
"Invalid /transcript GET parameters."
)
log
.
info
(
"Invalid /transcript GET parameters."
)
return
Response
(
status
=
400
)
return
Response
(
status
=
400
)
...
@@ -341,7 +340,7 @@ class VideoModule(VideoFields, XModule):
...
@@ -341,7 +340,7 @@ class VideoModule(VideoFields, XModule):
self
.
transcript_language
=
lang
self
.
transcript_language
=
lang
try
:
try
:
transcript
=
self
.
translation
(
request
.
GET
.
get
(
'videoId'
))
transcript
=
self
.
translation
(
request
.
GET
.
get
(
'videoId'
,
None
))
except
(
TranscriptException
,
NotFoundError
)
as
ex
:
except
(
TranscriptException
,
NotFoundError
)
as
ex
:
log
.
info
(
ex
.
message
)
log
.
info
(
ex
.
message
)
response
=
Response
(
status
=
404
)
response
=
Response
(
status
=
404
)
...
@@ -390,26 +389,30 @@ class VideoModule(VideoFields, XModule):
...
@@ -390,26 +389,30 @@ class VideoModule(VideoFields, XModule):
return
response
return
response
def
translation
(
self
,
subs
_id
):
def
translation
(
self
,
youtube
_id
):
"""
"""
This is called to get transcript file for specific language.
This is called to get transcript file for specific language.
subs_id: str: must be on of: self.sub or one of youtube_ids.
youtube_id: str: must be one of youtube_ids or None if HTML video
Logic flow:
Logic flow:
If english -> give back `sub` subtitles:
If youtube_id doesn't exist, we have a video in HTML5 mode. Otherwise,
Return what we have in contentstore for given subs_id,
video video in Youtube or Flash modes.
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 youtube:
If english -> give back youtube_id subtitles:
Return what we have in contentstore for given youtube_id.
If non-english:
If non-english:
a) extract subs_id from srt file name
a) extract youtube_id from srt file name.
b) try to find sjson by youtube_id and return if successful.
c) generate sjson from srt for all youtube speeds.
if non-youtube:
if non-youtube:
b) try to find sjson by subs_id and return if sucessful
If english -> give back `sub` subtitles:
c) otherwise generate sjson from srt and return it
.
Return what we have in contentstore for given subs_if that is stored in self.sub
.
if youtube
:
If non-english
:
b) try to find sjson by subs_id and return if sucessful
a) try to find previously generated sjson.
c) generate sjson from srt for all youtube speeds
b) otherwise generate sjson from srt and return it.
Filenames naming:
Filenames naming:
en: subs_videoid.srt.sjson
en: subs_videoid.srt.sjson
...
@@ -418,28 +421,36 @@ class VideoModule(VideoFields, XModule):
...
@@ -418,28 +421,36 @@ class VideoModule(VideoFields, XModule):
Raises:
Raises:
NotFoundError if for 'en' subtitles no asset is uploaded.
NotFoundError if for 'en' subtitles no asset is uploaded.
"""
"""
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
)
if
youtube_id
:
# Youtube case:
# Youtube case:
if
self
.
transcript_language
==
'en'
:
return
asset
(
self
.
location
,
youtube_id
)
.
data
youtube_ids
=
youtube_speed_dict
(
self
)
youtube_ids
=
youtube_speed_dict
(
self
)
assert
subs
_id
in
youtube_ids
assert
youtube
_id
in
youtube_ids
try
:
try
:
sjson_transcript
=
asset
(
self
.
location
,
subs
_id
,
self
.
transcript_language
)
.
data
sjson_transcript
=
asset
(
self
.
location
,
youtube
_id
,
self
.
transcript_language
)
.
data
except
(
NotFoundError
):
except
(
NotFoundError
):
log
.
info
(
"Can't find content in storage for
%
s transcript: generating."
,
subs
_id
)
log
.
info
(
"Can't find content in storage for
%
s transcript: generating."
,
youtube
_id
)
generate_sjson_for_all_speeds
(
generate_sjson_for_all_speeds
(
self
,
self
,
self
.
transcripts
[
self
.
transcript_language
],
self
.
transcripts
[
self
.
transcript_language
],
{
speed
:
subs_id
for
subs
_id
,
speed
in
youtube_ids
.
iteritems
()},
{
speed
:
youtube_id
for
youtube
_id
,
speed
in
youtube_ids
.
iteritems
()},
self
.
transcript_language
self
.
transcript_language
)
)
sjson_transcript
=
asset
(
self
.
location
,
subs_id
,
self
.
transcript_language
)
.
data
sjson_transcript
=
asset
(
self
.
location
,
youtube_id
,
self
.
transcript_language
)
.
data
return
sjson_transcript
return
sjson_transcript
else
:
# HTML5 case
if
self
.
transcript_language
==
'en'
:
return
asset
(
self
.
location
,
self
.
sub
)
.
data
else
:
return
get_or_create_sjson
(
self
)
class
VideoDescriptor
(
VideoFields
,
TabsEditingDescriptor
,
EmptyDataRawDescriptor
):
class
VideoDescriptor
(
VideoFields
,
TabsEditingDescriptor
,
EmptyDataRawDescriptor
):
...
...
lms/djangoapps/courseware/features/video.feature
View file @
b292f6c2
...
@@ -2,7 +2,7 @@
...
@@ -2,7 +2,7 @@
Feature
:
LMS Video component
Feature
:
LMS Video component
As a student, I want to view course videos in LMS
As a student, I want to view course videos in LMS
#
0
#
1
Scenario
:
Video component stores position correctly when page is reloaded
Scenario
:
Video component stores position correctly when page is reloaded
Given
the course has a Video component in Youtube mode
Given
the course has a Video component in Youtube mode
Then
when I view the video it has rendered in Youtube mode
Then
when I view the video it has rendered in Youtube mode
...
@@ -13,51 +13,51 @@ Feature: LMS Video component
...
@@ -13,51 +13,51 @@ Feature: LMS Video component
And
I click video button
"play"
And
I click video button
"play"
Then I see video starts playing from "0
:
10"
position
Then I see video starts playing from "0
:
10"
position
#
1
#
2
Scenario
:
Video component is fully rendered in the LMS in HTML5 mode
Scenario
:
Video component is fully rendered in the LMS in HTML5 mode
Given
the course has a Video component in HTML5 mode
Given
the course has a Video component in HTML5 mode
Then
when I view the video it has rendered in HTML5 mode
Then
when I view the video it has rendered in HTML5 mode
And
all sources are correct
And
all sources are correct
#
2
#
3
# Firefox doesn't have HTML5 (only mp4 - fix here)
# Firefox doesn't have HTML5 (only mp4 - fix here)
@skip_firefox
@skip_firefox
Scenario
:
Autoplay is disabled in LMS for a Video component
Scenario
:
Autoplay is disabled in LMS for a Video component
Given
the course has a Video component in HTML5 mode
Given
the course has a Video component in HTML5 mode
Then
when I view the video it does not have autoplay enabled
Then
when I view the video it does not have autoplay enabled
#
3
#
4
# Youtube testing
# Youtube testing
Scenario
:
Video component is fully rendered in the LMS in Youtube mode with HTML5 sources
Scenario
:
Video component is fully rendered in the LMS in Youtube mode with HTML5 sources
Given
youtube server is up and response time is 0.4 seconds
Given
youtube server is up and response time is 0.4 seconds
And
the course has a Video component in Youtube_HTML5 mode
And
the course has a Video component in Youtube_HTML5 mode
Then
when I view the video it has rendered in Youtube mode
Then
when I view the video it has rendered in Youtube mode
#
4
#
5
Scenario
:
Video component is not rendered in the LMS in Youtube mode with HTML5 sources
Scenario
:
Video component is not rendered in the LMS in Youtube mode with HTML5 sources
Given
youtube server is up and response time is 2 seconds
Given
youtube server is up and response time is 2 seconds
And
the course has a Video component in Youtube_HTML5 mode
And
the course has a Video component in Youtube_HTML5 mode
Then
when I view the video it has rendered in HTML5 mode
Then
when I view the video it has rendered in HTML5 mode
#
5
#
6
Scenario
:
Video component is rendered in the LMS in Youtube mode without HTML5 sources
Scenario
:
Video component is rendered in the LMS in Youtube mode without HTML5 sources
Given
youtube server is up and response time is 2 seconds
Given
youtube server is up and response time is 2 seconds
And
the course has a Video component in Youtube mode
And
the course has a Video component in Youtube mode
Then
when I view the video it has rendered in Youtube mode
Then
when I view the video it has rendered in Youtube mode
#
6
#
7
Scenario
:
Video component is rendered in the LMS in Youtube mode with HTML5 sources that doesn't supported by browser
Scenario
:
Video component is rendered in the LMS in Youtube mode with HTML5 sources that doesn't supported by browser
Given
youtube server is up and response time is 2 seconds
Given
youtube server is up and response time is 2 seconds
And
the course has a Video component in Youtube_HTML5_Unsupported_Video mode
And
the course has a Video component in Youtube_HTML5_Unsupported_Video mode
Then
when I view the video it has rendered in Youtube mode
Then
when I view the video it has rendered in Youtube mode
#
7
#
8
Scenario
:
Video component is rendered in the LMS in HTML5 mode with HTML5 sources that doesn't supported by browser
Scenario
:
Video component is rendered in the LMS in HTML5 mode with HTML5 sources that doesn't supported by browser
Given
the course has a Video component in HTML5_Unsupported_Video mode
Given
the course has a Video component in HTML5_Unsupported_Video mode
Then
error message is shown
Then
error message is shown
And
error message has correct text
And
error message has correct text
#
8
#
9
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"
Given
I am registered for the course
"test_course"
And
it has a video
"A"
in
"Youtube"
mode in position
"1"
of sequential
And
it has a video
"A"
in
"Youtube"
mode in position
"1"
of sequential
...
@@ -79,8 +79,8 @@ Feature: LMS Video component
...
@@ -79,8 +79,8 @@ Feature: LMS Video component
When
I open video
"C"
When
I open video
"C"
Then
video
"C"
should start playing at speed
"1.0"
Then
video
"C"
should start playing at speed
"1.0"
#
9
#
10
Scenario
:
Language menu
in Video component works correctly
Scenario
:
Language menu
works correctly in Video component
Given the course has a Video component in Youtube mode
:
Given the course has a Video component in Youtube mode
:
|
transcripts
|
sub
|
|
transcripts
|
sub
|
|
{"zh":
"OEoXaMPEzfM"}
|
OEoXaMPEzfM
|
|
{"zh":
"OEoXaMPEzfM"}
|
OEoXaMPEzfM
|
...
@@ -90,3 +90,42 @@ Feature: LMS Video component
...
@@ -90,3 +90,42 @@ Feature: LMS Video component
Then
I see
"好 各位同学"
text in the captions
Then
I see
"好 各位同学"
text in the captions
And
I select language with code
"en"
And
I select language with code
"en"
And
I see
"Hi, welcome to Edx."
text in the captions
And
I see
"Hi, welcome to Edx."
text in the captions
# 11
Scenario
:
CC button works correctly w/o english transcript in HTML5 mode of Video component
Given the course has a Video component in HTML5 mode
:
|
transcripts
|
|
{"zh":
"OEoXaMPEzfM"}
|
And
I make sure captions are opened
Then
I see
"好 各位同学"
text in the captions
# 12
Scenario
:
CC button works correctly only w/ english transcript in HTML5 mode of Video component
Given
I am registered for the course
"test_course"
And
I have a
"subs_OEoXaMPEzfM.srt.sjson"
transcript file in assets
And it has a video in "HTML5" mode
:
|
sub
|
|
OEoXaMPEzfM
|
And
I make sure captions are opened
Then
I see
"Hi, welcome to Edx."
text in the captions
# 13
Scenario
:
CC button works correctly w/o english transcript in Youtube mode of Video component
Given the course has a Video component in Youtube mode
:
|
transcripts
|
|
{"zh":
"OEoXaMPEzfM"}
|
And
I make sure captions are opened
Then
I see
"好 各位同学"
text in the captions
# 14
Scenario
:
CC button works correctly if transcripts and sub fields are empty, but transcript file exists is assets (Youtube mode of Video component)
Given
I am registered for the course
"test_course"
And
I have a
"subs_OEoXaMPEzfM.srt.sjson"
transcript file in assets
And
it has a video in
"Youtube"
mode
And
I make sure captions are opened
Then
I see
"Hi, welcome to Edx."
text in the captions
# 15
Scenario
:
CC button is hidden if no translations
Given
the course has a Video component in Youtube mode
Then
button
"CC"
is hidden
lms/djangoapps/courseware/features/video.py
View file @
b292f6c2
...
@@ -9,7 +9,6 @@ from django.conf import settings
...
@@ -9,7 +9,6 @@ from django.conf import settings
from
cache_toolbox.core
import
del_cached_content
from
cache_toolbox.core
import
del_cached_content
from
xmodule.contentstore.content
import
StaticContent
from
xmodule.contentstore.content
import
StaticContent
import
os
import
os
from
functools
import
partial
from
xmodule.contentstore.django
import
contentstore
from
xmodule.contentstore.django
import
contentstore
TEST_ROOT
=
settings
.
COMMON_TEST_DATA_ROOT
TEST_ROOT
=
settings
.
COMMON_TEST_DATA_ROOT
LANGUAGES
=
settings
.
ALL_LANGUAGES
LANGUAGES
=
settings
.
ALL_LANGUAGES
...
@@ -46,48 +45,6 @@ VIDEO_BUTTONS = {
...
@@ -46,48 +45,6 @@ VIDEO_BUTTONS = {
coursenum
=
'test_course'
coursenum
=
'test_course'
sequence
=
{}
sequence
=
{}
@step
(
'when I view the (.*) it does not have autoplay enabled$'
)
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(?:
\
:)?$'
)
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
(),
_step
.
hashes
)
visit_scenario_item
(
'SECTION'
)
@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
(),
_step
.
hashes
,
display_name
=
player_id
)
@step
(
'I open the section with videos$'
)
def
visit_video_section
(
_step
):
visit_scenario_item
(
'SECTION'
)
@step
(
'I select the "([^"]*)" speed on video "([^"]*)"$'
)
def
change_video_speed
(
_step
,
speed
,
player_id
):
_navigate_to_an_item_in_a_sequence
(
sequence
[
player_id
])
_change_video_speed
(
speed
)
@step
(
'I open video "([^"]*)"$'
)
def
open_video
(
_step
,
player_id
):
_navigate_to_an_item_in_a_sequence
(
sequence
[
player_id
])
@step
(
'video "([^"]*)" should start playing at speed "([^"]*)"$'
)
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
,
hashes
,
display_name
=
'Video'
):
def
add_video_to_course
(
course
,
player_mode
,
hashes
,
display_name
=
'Video'
):
category
=
'video'
category
=
'video'
...
@@ -129,16 +86,105 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'):
...
@@ -129,16 +86,105 @@ def add_video_to_course(course, player_mode, hashes, display_name='Video'):
if
'transcripts'
in
kwargs
[
'metadata'
]:
if
'transcripts'
in
kwargs
[
'metadata'
]:
kwargs
[
'metadata'
][
'transcripts'
]
=
json
.
loads
(
kwargs
[
'metadata'
][
'transcripts'
])
kwargs
[
'metadata'
][
'transcripts'
]
=
json
.
loads
(
kwargs
[
'metadata'
][
'transcripts'
])
course_location
=
world
.
scenario_dict
[
'COURSE'
]
.
location
if
'sub'
in
kwargs
[
'metadata'
]:
if
'sub'
in
kwargs
[
'metadata'
]:
_upload_file
(
kwargs
[
'metadata'
][
'sub'
],
'en'
,
world
.
scenario_dict
[
'COURSE'
]
.
location
)
filename
=
_get_transcript_filename
(
kwargs
[
'metadata'
][
'sub'
],
'en'
)
_upload_file
(
filename
,
course_location
)
for
lang
,
videoId
in
kwargs
[
'metadata'
][
'transcripts'
]
.
items
():
for
lang
,
videoId
in
kwargs
[
'metadata'
][
'transcripts'
]
.
items
():
_upload_file
(
videoId
,
lang
,
world
.
scenario_dict
[
'COURSE'
]
.
location
)
filename
=
_get_transcript_filename
(
videoId
,
lang
)
_upload_file
(
filename
,
course_location
)
world
.
scenario_dict
[
'VIDEO'
]
=
world
.
ItemFactory
.
create
(
**
kwargs
)
world
.
scenario_dict
[
'VIDEO'
]
=
world
.
ItemFactory
.
create
(
**
kwargs
)
def
_get_transcript_filename
(
videoId
,
lang
):
if
lang
==
'en'
:
return
'subs_{0}.srt.sjson'
.
format
(
videoId
)
else
:
return
'{0}_subs_{1}.srt.sjson'
.
format
(
lang
,
videoId
)
def
_upload_file
(
filename
,
location
):
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
)
content
=
StaticContent
(
content_location
,
filename
,
mime_type
,
f
.
read
())
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
)
def
_change_video_speed
(
speed
):
world
.
browser
.
execute_script
(
"$('.speeds').addClass('open')"
)
speed_css
=
'li[data-speed="{0}"] a'
.
format
(
speed
)
world
.
css_click
(
speed_css
)
def
_open_menu
(
menu
):
world
.
browser
.
execute_script
(
"$('{selector}').parent().addClass('open')"
.
format
(
selector
=
VIDEO_MENUS
[
menu
]
))
@step
(
'when I view the (.*) it does not have autoplay enabled$'
)
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(?:
\
:)?$'
)
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
(),
_step
.
hashes
)
visit_scenario_item
(
'SECTION'
)
@step
(
'a video in "([^"]*)" mode(?:
\
:)?$'
)
def
add_video
(
_step
,
player_mode
):
add_video_to_course
(
coursenum
,
player_mode
.
lower
(),
_step
.
hashes
)
visit_scenario_item
(
'SECTION'
)
@step
(
'a video "([^"]*)" in "([^"]*)" mode in position "([^"]*)" of sequential(?:
\
:)?$'
)
def
add_video_in_position
(
_step
,
player_id
,
player_mode
,
position
):
sequence
[
player_id
]
=
position
add_video_to_course
(
coursenum
,
player_mode
.
lower
(),
_step
.
hashes
,
display_name
=
player_id
)
@step
(
'I open the section with videos$'
)
def
visit_video_section
(
_step
):
visit_scenario_item
(
'SECTION'
)
@step
(
'I select the "([^"]*)" speed on video "([^"]*)"$'
)
def
change_video_speed
(
_step
,
speed
,
player_id
):
_navigate_to_an_item_in_a_sequence
(
sequence
[
player_id
])
_change_video_speed
(
speed
)
@step
(
'I open video "([^"]*)"$'
)
def
open_video
(
_step
,
player_id
):
_navigate_to_an_item_in_a_sequence
(
sequence
[
player_id
])
@step
(
'video "([^"]*)" should start playing at speed "([^"]*)"$'
)
def
check_video_speed
(
_step
,
player_id
,
speed
):
speed_css
=
'.speeds p.active'
assert
world
.
css_has_text
(
speed_css
,
'{0}x'
.
format
(
speed
))
@step
(
'youtube server is up and response time is (.*) seconds$'
)
@step
(
'youtube server is up and response time is (.*) seconds$'
)
def
set_youtube_response_timeout
(
_step
,
time
):
def
set_youtube_response_timeout
(
_step
,
time
):
world
.
youtube
.
config
[
'time_to_response'
]
=
float
(
time
)
world
.
youtube
.
config
[
'time_to_response'
]
=
float
(
time
)
...
@@ -232,47 +278,6 @@ def click_button(_step, button):
...
@@ -232,47 +278,6 @@ def click_button(_step, button):
world
.
css_find
(
VIDEO_BUTTONS
[
button
])
.
click
()
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
)
def
_change_video_speed
(
speed
):
world
.
browser
.
execute_script
(
"$('.speeds').addClass('open')"
)
speed_css
=
'li[data-speed="{0}"] a'
.
format
(
speed
)
world
.
css_click
(
speed_css
)
@step
(
'I click video button "([^"]*)"$'
)
@step
(
'I click video button "([^"]*)"$'
)
def
click_button_video
(
_step
,
button_type
):
def
click_button_video
(
_step
,
button_type
):
world
.
wait_for_ajax_complete
()
world
.
wait_for_ajax_complete
()
...
@@ -295,7 +300,12 @@ def seek_video_to_n_seconds(_step, seconds):
...
@@ -295,7 +300,12 @@ def seek_video_to_n_seconds(_step, seconds):
world
.
browser
.
execute_script
(
jsCode
)
world
.
browser
.
execute_script
(
jsCode
)
def
_open_menu
(
menu
):
@step
(
'I have a "([^"]*)" transcript file in assets$'
)
world
.
browser
.
execute_script
(
"$('{selector}').parent().addClass('open')"
.
format
(
def
upload_to_assets
(
_step
,
filename
):
selector
=
VIDEO_MENUS
[
menu
]
_upload_file
(
filename
,
world
.
scenario_dict
[
'COURSE'
]
.
location
)
))
@step
(
'button "([^"]*)" is hidden$'
)
def
is_hidden_button
(
_step
,
button
):
assert
not
world
.
css_visible
(
VIDEO_BUTTONS
[
button
])
lms/djangoapps/courseware/tests/test_video_handlers.py
View file @
b292f6c2
...
@@ -189,17 +189,22 @@ class TestVideoTranscriptTranslation(TestVideo):
...
@@ -189,17 +189,22 @@ class TestVideoTranscriptTranslation(TestVideo):
# Tests for `translation` dispatch:
# Tests for `translation` dispatch:
def
test_translation_fails
(
self
):
def
test_translation_fails
(
self
):
# No
videoId
# No
language
request
=
Request
.
blank
(
'/translation
?language=ru
'
)
request
=
Request
.
blank
(
'/translation'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
self
.
assertEqual
(
response
.
status
,
'400 Bad Request'
)
self
.
assertEqual
(
response
.
status
,
'400 Bad Request'
)
# No videoId - HTML5 video with language that is not in available languages
request
=
Request
.
blank
(
'/translation?language=ru'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
self
.
assertEqual
(
response
.
status
,
'404 Not Found'
)
# Language is not in available languages
# Language is not in available languages
request
=
Request
.
blank
(
'/translation?language=ru&videoId=12345'
)
request
=
Request
.
blank
(
'/translation?language=ru&videoId=12345'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
self
.
assertEqual
(
response
.
status
,
'404 Not Found'
)
self
.
assertEqual
(
response
.
status
,
'404 Not Found'
)
def
test_translaton_en_success
(
self
):
def
test_translaton_en_
youtube_
success
(
self
):
subs
=
{
"start"
:
[
10
],
"end"
:
[
100
],
"text"
:
[
"Hi, welcome to Edx."
]}
subs
=
{
"start"
:
[
10
],
"end"
:
[
100
],
"text"
:
[
"Hi, welcome to Edx."
]}
good_sjson
=
_create_file
(
json
.
dumps
(
subs
))
good_sjson
=
_create_file
(
json
.
dumps
(
subs
))
_upload_sjson_file
(
good_sjson
,
self
.
item_descriptor
.
location
)
_upload_sjson_file
(
good_sjson
,
self
.
item_descriptor
.
location
)
...
@@ -210,25 +215,7 @@ class TestVideoTranscriptTranslation(TestVideo):
...
@@ -210,25 +215,7 @@ class TestVideoTranscriptTranslation(TestVideo):
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
self
.
assertDictEqual
(
json
.
loads
(
response
.
body
),
subs
)
self
.
assertDictEqual
(
json
.
loads
(
response
.
body
),
subs
)
def
test_translaton_non_en_non_youtube_success
(
self
):
def
test_translation_non_en_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
=
{
subs
=
{
u'end'
:
[
100
],
u'end'
:
[
100
],
u'start'
:
[
12
],
u'start'
:
[
12
],
...
@@ -270,6 +257,34 @@ class TestVideoTranscriptTranslation(TestVideo):
...
@@ -270,6 +257,34 @@ class TestVideoTranscriptTranslation(TestVideo):
}
}
self
.
assertDictEqual
(
json
.
loads
(
response
.
body
),
calculated_1_5
)
self
.
assertDictEqual
(
json
.
loads
(
response
.
body
),
calculated_1_5
)
def
test_translaton_en_html5_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'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
self
.
assertDictEqual
(
json
.
loads
(
response
.
body
),
subs
)
def
test_translaton_non_en_html5_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
])
# manually clean youtube_id_1_0, as it has default value
self
.
item
.
youtube_id_1_0
=
""
request
=
Request
.
blank
(
'/translation?language=uk'
)
response
=
self
.
item
.
transcript
(
request
=
request
,
dispatch
=
'translation'
)
self
.
assertDictEqual
(
json
.
loads
(
response
.
body
),
subs
)
class
TestVideoTranscriptsDownload
(
TestVideo
):
class
TestVideoTranscriptsDownload
(
TestVideo
):
"""
"""
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment