diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e52f1d4..4ba74f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [0.10.1] - 2018-02-13 + +## Added + +- Multiple transcripts downloading; +- Error handling during Brightcove video re-transcode job submitting; + +## Fixed + +- Safari `empty transcripts` issue; +- Studio editor improvements: + - transcripts accordion switch; + - re-transcode button styling; + ## [0.10.0] - 2018-01-24 ## Added @@ -282,4 +296,5 @@ and this project adheres to [Semantic Versioning](http://semver.org/). [0.9.3]: https://github.com/raccoongang/xblock-video/compare/v0.9.2...v0.9.3 [0.9.4]: https://github.com/raccoongang/xblock-video/compare/v0.9.3...v0.9.4 [0.10.0]: https://github.com/raccoongang/xblock-video/compare/v0.9.4...v0.10.0 -[Unreleased]: https://github.com/raccoongang/xblock-video/compare/v0.10.0...HEAD +[0.10.1]: https://github.com/raccoongang/xblock-video/compare/v0.10.0...v0.10.1 +[Unreleased]: https://github.com/raccoongang/xblock-video/compare/v0.10.1...HEAD diff --git a/Makefile b/Makefile index 7b6c1b52..f49f8bd1 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,7 @@ dev-install: pip install --process-dependency-links -e . deps-test: ## Install dependencies required to run tests + pip install -Ur requirements.txt pip install -Ur test_requirements.txt pip install -r $(VIRTUAL_ENV)/src/xblock-sdk/requirements/base.txt diff --git a/package-lock.json b/package-lock.json index f700645e..7c145966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2345,14 +2345,6 @@ } } }, - "string_decoder": { - "version": "1.0.1", - "bundled": true, - "dev": true, - "requires": { - "safe-buffer": "5.0.1" - } - }, "string-width": { "version": "1.0.2", "bundled": true, @@ -2363,6 +2355,14 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "5.0.1" + } + }, "stringstream": { "version": "0.0.5", "bundled": true, @@ -4559,15 +4559,6 @@ "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4=", "dev": true }, - "string_decoder": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", - "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", - "dev": true, - "requires": { - "safe-buffer": "5.1.1" - } - }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -4579,6 +4570,15 @@ "strip-ansi": "3.0.1" } }, + "string_decoder": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", + "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", + "dev": true, + "requires": { + "safe-buffer": "5.1.1" + } + }, "stringstream": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..8a026b5c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pycaption>=1.0.6,<2 +requests>=2.9.1,<3.0.0 +babelfish>=0.5.5,<0.6.0 +XBlock>=0.4.10,<2.0.0 +xblock-utils>2,<=2.1.1 + diff --git a/setup.py b/setup.py index 7d44b1c8..8d4f620a 100644 --- a/setup.py +++ b/setup.py @@ -48,16 +48,13 @@ def package_data(pkg, roots): 'video_xblock', ], dependency_links=[ - # At the moment of writing PyPI hosts outdated version of xblock-utils, hence git - # Replace dependency links with numbered versions when it's released on PyPI - 'git+https://github.com/edx/xblock-utils.git@v1.0.2#egg=xblock-utils-1.0.5', ], install_requires=[ - 'XBlock>=0.4.10,<2.0.0', - 'xblock-utils>=1.0.2,<=1.0.5', - 'pycaption>=0.7.1,<1.0', # The latest Python 2.7 compatible version + 'pycaption>=1.0.6,<2', # Py3, Juniper compat BeautifulSoup4>=4.9.1 'requests>=2.9.1,<3.0.0', 'babelfish>=0.5.5,<0.6.0', + 'XBlock>=0.4.10,<2.0.0', + 'xblock-utils>2,<=2.1.1' ], entry_points={ 'xblock.v1': [ diff --git a/video_xblock/__init__.py b/video_xblock/__init__.py index 962e9a2d..b02285f8 100644 --- a/video_xblock/__init__.py +++ b/video_xblock/__init__.py @@ -2,7 +2,7 @@ Video xblock module. """ -__version__ = '0.10.0' +__version__ = '0.10.1' # pylint: disable=wildcard-import from .video_xblock import * # nopep8 diff --git a/video_xblock/backends/base.py b/video_xblock/backends/base.py index 7a4ce231..5cc08909 100644 --- a/video_xblock/backends/base.py +++ b/video_xblock/backends/base.py @@ -413,7 +413,7 @@ def clean_default_transcripts(default_transcripts): default_transcripts.sort(key=get_values) distinct_transcripts = [] for _key, group in itertools.groupby(default_transcripts, get_values): - distinct_transcripts.append(group.next()) + distinct_transcripts.append(next(group)) return distinct_transcripts def filter_default_transcripts(self, default_transcripts, transcripts): @@ -423,6 +423,6 @@ def filter_default_transcripts(self, default_transcripts, transcripts): enabled_languages_codes = [t[u'lang'] for t in transcripts] default_transcripts = [ dt for dt in default_transcripts - if (unicode(dt.get('lang')) not in enabled_languages_codes) and default_transcripts + if (dt.get('lang') not in enabled_languages_codes) and default_transcripts ] return default_transcripts diff --git a/video_xblock/backends/brightcove.py b/video_xblock/backends/brightcove.py index c869d358..674b5645 100644 --- a/video_xblock/backends/brightcove.py +++ b/video_xblock/backends/brightcove.py @@ -6,7 +6,7 @@ import base64 from datetime import datetime import json -import httplib +import http.client as httplib import logging import re @@ -105,10 +105,14 @@ def _refresh_access_token(self): "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Basic " + auth_string } - resp = requests.post(url, headers=headers, data=params) - if resp.status_code == httplib.OK: - result = resp.json() - return result['access_token'] + try: + resp = requests.post(url, headers=headers, data=params) + if resp.status_code == httplib.OK: + result = resp.json() + return result['access_token'] + except IOError: + log.exception(_("Connection issue. Couldn't refresh API access token.")) + return None def get(self, url, headers=None, can_retry=True): """ @@ -153,13 +157,21 @@ def post(self, url, payload, headers=None, can_retry=True): headers_.update(headers) resp = requests.post(url, data=payload, headers=headers_) + log.debug("BC response status: {}".format(resp.status_code)) if resp.status_code in (httplib.OK, httplib.CREATED): return resp.json() elif resp.status_code == httplib.UNAUTHORIZED and can_retry: self.access_token = self._refresh_access_token() return self.post(url, payload, headers, can_retry=False) - else: - raise BrightcoveApiClientError + + try: + resp_dict = resp.json()[0] + log.warn("API error code: %s - %s", resp_dict.get(u'error_code'), resp_dict.get(u'message')) + except (ValueError, IndexError): + message = _("Can't parse unexpected response during POST request to Brightcove API!") + log.exception(message) + resp_dict = {"message": message} + return resp_dict class BrightcoveHlsMixin(object): @@ -169,6 +181,8 @@ class BrightcoveHlsMixin(object): These features are: 1. Video playback autoquality. i.e. adjusting video bitrate depending on client's bandwidth. 2. Video content encryption using short-living keys. + + NOTE(wowkalucky): Dynamic Ingest is the legacy ingest system. New Video Cloud accounts use Dynamic Delivery. """ DI_PROFILES = { @@ -240,6 +254,7 @@ def submit_retranscode_job(self, account_id, video_id, profile_type): - default - re-transcode using default DI profile; - autoquality - re-transcode using HLS only profile; - encryption - re-transcode using HLS with encryption profile; + ref: https://support.brightcove.com/dynamic-ingest-api """ url = 'https://ingest.api.brightcove.com/v1/accounts/{account_id}/videos/{video_id}/ingest-requests'.format( account_id=account_id, video_id=video_id @@ -255,9 +270,18 @@ def submit_retranscode_job(self, account_id, video_id, profile_type): if profile_type != 'default': retranscode_params['profile'] = self.DI_PROFILES[profile_type]['name'] res = self.api_client.post(url, json.dumps(retranscode_params)) - self.xblock.metadata['retranscode-status'] = ( - 'ReTranscode request submitted {:%Y-%m-%d %H:%M} UTC using profile "{}". Job id: {}'.format( - datetime.utcnow(), retranscode_params.get('profile', 'default'), res['id'])) + if u'error_code' in res: + self.xblock.metadata['retranscode-status'] = ( + 'ReTranscode request encountered error {:%Y-%m-%d %H:%M} UTC using profile "{}".\nMessage: {}'.format( + datetime.utcnow(), retranscode_params.get('profile', 'default'), res['message'] + ) + ) + else: + self.xblock.metadata['retranscode-status'] = ( + 'ReTranscode request submitted {:%Y-%m-%d %H:%M} UTC using profile "{}". Job id: {}'.format( + datetime.utcnow(), retranscode_params.get('profile', 'default'), res['id'] + ) + ) return res def get_video_renditions(self, account_id, video_id): @@ -388,6 +412,7 @@ def get_frag(self, **context): Because of this it doesn't use `super.get_frag()`. """ context['player_state'] = json.dumps(context['player_state']) + log.debug('CONTEXT: player_state: %s', context.get('player_state')) frag = Fragment( self.render_template('brightcove.html', **context) @@ -433,7 +458,7 @@ def get_player_html(self, **context): 'static/js/videojs/videojs-transcript.js' ] context['vjs_plugins'] = map(self.resource_string, vjs_plugins) - log.debug("[get_player_html] initialized scripts: %s", vjs_plugins) + log.debug("Initialized scripts: %s", vjs_plugins) return super(BrightcovePlayer, self).get_player_html(**context) def dispatch(self, _request, suffix): diff --git a/video_xblock/backends/vimeo.py b/video_xblock/backends/vimeo.py index 42e3b2f2..9fd2bb6b 100644 --- a/video_xblock/backends/vimeo.py +++ b/video_xblock/backends/vimeo.py @@ -3,7 +3,7 @@ Vimeo Video player plugin. """ -import httplib +import http.client as httplib import json import logging import re @@ -51,7 +51,7 @@ def get(self, url, headers=None, can_retry=False): Response in python native data format. """ headers_ = { - 'Authorization': 'Bearer {}'.format(self.access_token.encode(encoding='utf-8')), + 'Authorization': 'Bearer {}'.format(self.access_token), 'Accept': 'application/json' } if headers is not None: @@ -277,4 +277,4 @@ def download_default_transcript(self, url, language_code=None): # pylint: disab data = requests.get(url) text = data.content.decode('utf8') cleaned_captions_text = remove_escaping(text) - return unicode(cleaned_captions_text) + return cleaned_captions_text diff --git a/video_xblock/backends/wistia.py b/video_xblock/backends/wistia.py index 9035d99e..97a5b055 100644 --- a/video_xblock/backends/wistia.py +++ b/video_xblock/backends/wistia.py @@ -3,9 +3,9 @@ Wistia Video player plugin. """ -import HTMLParser +from html.parser import HTMLParser import json -import httplib +import http.client as httplib import logging import re diff --git a/video_xblock/backends/youtube.py b/video_xblock/backends/youtube.py index e620fa51..66012d27 100644 --- a/video_xblock/backends/youtube.py +++ b/video_xblock/backends/youtube.py @@ -3,9 +3,9 @@ YouTube Video player plugin. """ -import HTMLParser +from html.parser import HTMLParser import json -import httplib +import http.client as httplib import re import textwrap import urllib diff --git a/video_xblock/fields.py b/video_xblock/fields.py index ec97a04b..d04c9b42 100644 --- a/video_xblock/fields.py +++ b/video_xblock/fields.py @@ -7,6 +7,7 @@ """ import datetime +from six import string_types import time from xblock.fields import JSONField @@ -73,7 +74,7 @@ def from_json(self, value): if isinstance(value, float): return datetime.timedelta(seconds=value) - if isinstance(value, basestring): + if isinstance(value, string_types): return self.isotime_to_timedelta(value) msg = "RelativeTime Field {0} has bad value '{1!r}'".format(self.name, value) diff --git a/video_xblock/mixins.py b/video_xblock/mixins.py index 97e71812..a66e8978 100644 --- a/video_xblock/mixins.py +++ b/video_xblock/mixins.py @@ -11,7 +11,7 @@ from xblock.exceptions import NoSuchServiceError from xblock.fields import Scope, Boolean, Float, String -from .constants import DEFAULT_LANG, TPMApiTranscriptFormatID, TPMApiLanguage, TranscriptSource, Status +from .constants import DEFAULT_LANG, TPMApiTranscriptFormatID, TPMApiLanguage, TranscriptSource, Status, PlayerName from .utils import import_from, ugettext as _, underscore_to_mixedcase, Transcript log = logging.getLogger(__name__) @@ -106,10 +106,10 @@ def vtt_to_text(vtt_content): """ text_lines = [] for line in vtt_content.splitlines(): - if '-->' in line or line == '': + if b'-->' in line or line == b'': continue text_lines.append(line) - return ' '.join(text_lines) + return b' '.join(text_lines) def route_transcripts(self): """ @@ -125,9 +125,17 @@ def route_transcripts(self): transcripts = self.get_enabled_transcripts() for tran in transcripts: if self.threeplaymedia_streaming: - tran['url'] = self.runtime.handler_url( + # download URL remains hidden behind the handler: + tran['download_url'] = self.runtime.handler_url( self, 'fetch_from_three_play_media', query="{}={}".format(tran['lang_id'], tran['id']) ) + # NOTE(wowkalucky): for some reason handler's URL doesn't work in combination + # Brightcove player/Safari browser. Safari just doesn't populate text tracks with cues! + # So, we have to expose raw 3PM URL for Brightcove users, for now... + if str(self.player_name) != PlayerName.BRIGHTCOVE: + tran['url'] = self.runtime.handler_url( + self, 'fetch_from_three_play_media', query="{}={}".format(tran['lang_id'], tran['id']) + ) elif not tran['url'].endswith('.vtt'): tran['url'] = self.runtime.handler_url( self, 'srt_to_vtt', query=tran['url'] @@ -201,7 +209,7 @@ def convert_3playmedia_caps_to_vtt(self, caps, video_id, lang="en", lang_label=" sub = self.convert_caps_to_vtt(caps=caps) reference_name = "{lang_label}_captions_video_{video_id}".format( lang_label=lang_label, video_id=video_id - ).encode('utf8') + ) file_name, external_url = self.create_transcript_file( trans_str=sub, reference_name=reference_name ) @@ -251,6 +259,7 @@ def get_3pm_transcripts_list(self, file_id, apikey): domain=domain, file_id=file_id, api_key=apikey ) ) + log.debug(response._content) # pylint: disable=protected-access except IOError: log.exception(failure_message) return feedback, transcripts_list @@ -357,7 +366,7 @@ def fetch_from_three_play_media(self, request, _suffix=''): transcript = self.fetch_single_3pm_translation(transcript_data={'id': transcript_id, 'language_id': lang_id}) if transcript is None: return Response() - return Response(transcript.content) + return Response(transcript.content, content_type='text/vtt') @XBlock.handler def validate_three_play_media_config(self, request, _suffix=''): @@ -531,6 +540,10 @@ def save_player_state(self, request, _suffix=''): if field_name not in player_state: player_state[field_name] = request[underscore_to_mixedcase(field_name)] + # make sure player's volume is down when muted: + if player_state['muted']: + player_state['volume'] = 0.000 + self.player_state = player_state return {'success': True} @@ -611,5 +624,5 @@ def usage_id(self): Returns stub value if `location` property is unavailabe. E.g. in workbench runtime. """ if hasattr(self, 'location'): - return self.location.to_deprecated_string() + return self.location._to_deprecated_string() return 'usage_id' diff --git a/video_xblock/static/css/student-view.css b/video_xblock/static/css/student-view.css index 6c70340b..c330b8b9 100644 --- a/video_xblock/static/css/student-view.css +++ b/video_xblock/static/css/student-view.css @@ -35,3 +35,41 @@ background-color: #1aa1de; color: #fff; } + +/* Transcripts download dropdown buttons */ +.dropdown { + display: inline-block; + position: relative; + bottom: 0px; +} + +.dropdown-content { + display: none; + position: absolute; + bottom: 0px; + background-color: #f9f9f9; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1; + width: 180px; + min-width: 180px; + overflow: visible; + background: transparent; +} + +.dropdown-content a { + color: black; + text-decoration: none; + display: block; + min-width: 160px; +} + +.dropdown-content a:hover {background-color: #1aa1de} + +.dropdown:hover .dropdown-content { + display: block; +} + +.dropdown:hover { + background-color: #0075b4 !important; + color: #fff !important; +} diff --git a/video_xblock/static/css/studio-edit-accordion.css b/video_xblock/static/css/studio-edit-accordion.css index df021eca..541c55e4 100644 --- a/video_xblock/static/css/studio-edit-accordion.css +++ b/video_xblock/static/css/studio-edit-accordion.css @@ -1,17 +1,17 @@ /* Style the buttons that are used to open and close the accordion panel */ .accordion-btn { - background-color: #eee; - border: none; + /*background-color: #eee;*/ + border: 1px solid #009fe6; color: #444; cursor: pointer; padding: 18px; text-align: left; - transition: 0.4s; + transition: 1s; width: 100%; } .accordion-btn::after { - content: '\002B'; + content: 'OFF'; color: #777; font-weight: bold; float: right; @@ -19,25 +19,36 @@ } .accordion-btn.active::after { - content: "\2212"; + content: "ON"; + color: #0D6; } /* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */ -.accordion-btn.active, .accordion-btn:hover { +.accordion-btn.active { background-color: #009fe6; color: #fff; } +.accordion-btn.active:hover { + background-color: #009fe6; +} + +.accordion-btn:hover { + background-color: #0D6; +} + /* Style the accordion panel. Note: hidden by default */ .accordion-panel { + max-height: 0; background-color: white; - border: 1px solid #009fe6; - display: none; + border: none; + display: block; overflow: hidden; padding: 0 18px; - transition: max-height 0.2s ease-out; + transition: max-height 1.5s; } .accordion-panel.active { - display: block; + border: 5px solid #009fe6; + max-height: 1080px; } diff --git a/video_xblock/static/css/studio-edit.css b/video_xblock/static/css/studio-edit.css index efde1183..f50d6853 100644 --- a/video_xblock/static/css/studio-edit.css +++ b/video_xblock/static/css/studio-edit.css @@ -251,3 +251,22 @@ color: #fff; } +.retranscode-button { + box-sizing: border-box; + display: inline-block; + font-size: 1.2rem; + font-weight: 600; + transition: all 0.15s; + text-align: center; + text-decoration: none; + border-radius: 5px; + border: 1px solid #0075b4; + background-color: #fff; + color: #0075b4; + padding: 10px; +} + +.retranscode-button:hover { + background-color: #065683; + color: #fff; +} diff --git a/video_xblock/static/html/brightcove.html b/video_xblock/static/html/brightcove.html index acc0ba92..94696807 100644 --- a/video_xblock/static/html/brightcove.html +++ b/video_xblock/static/html/brightcove.html @@ -54,12 +54,13 @@ data-player="{{player_id}}" data-embed="default" data-application-id - data-setup='{"playbackRates": [0.5, 1.0, 1.5, 2.0] }' + data-setup='{"playbackRates": [0.5, 1.0, 1.5, 2.0], "html5": {"nativeTextTracks": false}}' width="100%" height="560" class="video-js" brightcove controls + crossorigin="anonymous" > {{ transcripts }} diff --git a/video_xblock/static/html/student_view.html b/video_xblock/static/html/student_view.html index aa01a918..4050ed9c 100644 --- a/video_xblock/static/html/student_view.html +++ b/video_xblock/static/html/student_view.html @@ -20,13 +20,21 @@