From 0ece0b326335ed01a61275c20c146a5cc959bf29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Thu, 6 May 2021 10:02:43 +0000 Subject: [PATCH] [IMP] website_slides: add Vimeo integration This commit adds support for the Vimeo provider in the e-learning platform. This includes managing Vimeo video links and showing the Vimeo embedded player in the fullscreen and non-fullscreen viewers. But also managing video events to: - automatically set the slide as completed when the video reaches the last 30 seconds - go to the next slide when the video ends (if there is one) Task-2510174 Part-of: odoo/odoo#71477 --- addons/website_slides/controllers/main.py | 5 +- addons/website_slides/models/slide_slide.py | 114 +++++++++++++++++- .../src/js/slides_course_fullscreen_player.js | 112 ++++++++++++++++- .../src/xml/website_slides_fullscreen.xml | 14 ++- .../static/src/xml/website_slides_upload.xml | 16 ++- .../website_slides/tests/test_slide_utils.py | 68 +++++++++-- .../views/website_slides_templates_course.xml | 1 + 7 files changed, 308 insertions(+), 22 deletions(-) diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index fe4e0c30142..86a2cb0936c 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -1083,7 +1083,7 @@ class WebsiteSlides(WebsiteProfile): if not slide.video_source_type: slide.unlink() - return {'error': _('Please enter valid YouTube or Google Drive Link')} + return {'error': _('Please enter valid YouTube, Vimeo or Google Drive Link')} if slide.video_source_type == 'youtube': identical_video = existing_videos.filtered( @@ -1091,6 +1091,9 @@ class WebsiteSlides(WebsiteProfile): elif slide.video_source_type == 'google_drive': identical_video = existing_videos.filtered( lambda existing_video: slide.google_drive_id == existing_video.google_drive_id) + elif slide.video_source_type == 'vimeo': + identical_video = existing_videos.filtered( + lambda existing_video: slide.vimeo_id == existing_video.vimeo_id) if identical_video: identical_video_name = identical_video[0].name return {'error': _('This video already exists in this channel on the following content: %s', identical_video_name)} diff --git a/addons/website_slides/models/slide_slide.py b/addons/website_slides/models/slide_slide.py index c0f34daf1fd..0b2eed48332 100644 --- a/addons/website_slides/models/slide_slide.py +++ b/addons/website_slides/models/slide_slide.py @@ -90,6 +90,7 @@ class Slide(models.Model): YOUTUBE_VIDEO_ID_REGEX = r'^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*' GOOGLE_DRIVE_DOCUMENT_ID_REGEX = r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)' + VIMEO_VIDEO_ID_REGEX = r'\/\/(player.)?vimeo.com\/(?:[a-z]*\/)*([0-9]{6,11})\/?([0-9a-z]{6,11})?[?]?.*' # description name = fields.Char('Title', required=True, translate=True) @@ -158,7 +159,8 @@ class Slide(models.Model): ('doc', 'Document (Word, Google Doc, ...)'), ('slides', 'Slides (PowerPoint, Google Slides, ...)'), ('youtube_video', 'YouTube Video'), - ('google_drive_video', 'Google Drive Video')], + ('google_drive_video', 'Google Drive Video'), + ('vimeo_video', 'Vimeo Video')], string="Slide Type", compute='_compute_slide_type', store=True, readonly=False, help="Subtype of the slide category, allows more precision on the actual file type / source type.") document_google_url = fields.Char('Document Link', related='url', readonly=False, @@ -167,12 +169,14 @@ class Slide(models.Model): help="Used to filter file input to PDF only") # content - videos video_url = fields.Char('Video URL', related='url', readonly=False, - help="Link of the video (we support YouTube and Google Drive as sources)") + help="Link of the video (we support YouTube, Google Drive and Vimeo as sources)") video_source_type = fields.Selection([ ('youtube', 'YouTube'), - ('google_drive', 'Google Drive')], + ('google_drive', 'Google Drive'), + ('vimeo', 'Vimeo')], string='Video Source', compute="_compute_video_source_type") youtube_id = fields.Char('Video YouTube ID', compute='_compute_youtube_id') + vimeo_id = fields.Char('Video Vimeo ID', compute='_compute_vimeo_id') # website website_id = fields.Many2one(related='channel_id.website_id', readonly=True) date_published = fields.Datetime('Publish Date', readonly=True, tracking=False) @@ -370,6 +374,8 @@ class Slide(models.Model): slide.slide_type = 'youtube_video' elif slide.slide_category == 'video' and slide.video_source_type == 'google_drive': slide.slide_type = 'google_drive_video' + elif slide.slide_category == 'video' and slide.video_source_type == 'vimeo': + slide.slide_type = 'vimeo_video' else: slide.slide_type = False @@ -404,6 +410,19 @@ class Slide(models.Model): embed_code = Markup('') % (slide.youtube_id, query_params) elif slide.video_source_type == 'google_drive': embed_code = Markup('') % (slide.google_drive_id) + elif slide.video_source_type == 'vimeo': + if '/' in slide.vimeo_id: + # in case of privacy 'with URL only', vimeo adds a token after the video ID + # the embed url needs to receive that token as a "h" parameter + [vimeo_id, vimeo_token] = slide.vimeo_id.split('/') + embed_code = Markup(""" + """) % ( + vimeo_id, vimeo_token) + else: + embed_code = Markup(""" + """) % (slide.vimeo_id) elif slide.slide_category in ['infographic', 'document'] and slide.source_type == 'external' and slide.google_drive_id: embed_code = Markup('') % (slide.google_drive_id) elif slide.slide_category == 'document' and slide.source_type == 'local_file': @@ -425,6 +444,9 @@ class Slide(models.Model): video_source_type = 'youtube' if slide.video_url and not video_source_type and re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, slide.video_url): video_source_type = 'google_drive' + vimeo_match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False + if not video_source_type and vimeo_match and len(vimeo_match.groups()) == 3: + video_source_type = 'vimeo' slide.video_source_type = video_source_type @@ -440,6 +462,23 @@ class Slide(models.Model): else: slide.youtube_id = False + @api.depends('video_url', 'video_source_type') + def _compute_vimeo_id(self): + for slide in self: + if slide.video_url and slide.video_source_type == 'vimeo': + match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url) + if match and len(match.groups()) == 3: + if match.group(3): + # in case of privacy 'with URL only', vimeo adds a token after the video ID + # the share url is then 'vimeo_id/token' + # the token will be captured in the third group of the regex (if any) + slide.vimeo_id = '%s/%s' % (match.group(2), match.group(3)) + else: + # regular video, we just capture the vimeo_id + slide.vimeo_id = match.group(2) + else: + slide.vimeo_id = False + @api.depends('url', 'document_google_url', 'image_google_url', 'video_url') def _compute_google_drive_id(self): """ Extracts the Google Drive ID from the url based on the slide category. """ @@ -868,6 +907,8 @@ class Slide(models.Model): slide_metadata, error = self._fetch_youtube_metadata(image_url_only) elif self.slide_category == 'video' and self.video_source_type == 'google_drive': slide_metadata, error = self._fetch_google_drive_metadata(image_url_only) + elif self.slide_category == 'video' and self.video_source_type == 'vimeo': + slide_metadata, error = self._fetch_vimeo_metadata(image_url_only) elif self.slide_category in ['document', 'infographic'] and self.source_type == 'external': # external documents & google drive videos share the same method currently slide_metadata, error = self._fetch_google_drive_metadata(image_url_only) @@ -1082,6 +1123,73 @@ class Slide(models.Model): return slide_metadata, None + def _fetch_vimeo_metadata(self, image_url_only=False): + """ Fetches video metadata from the Vimeo API. + See https://developer.vimeo.com/api/oembed/showcases for more information. + + Returns a dict containing video metadata with the following keys (matching slide.slide fields): + - 'name' matching the video title + - 'description' matching the video description + - 'image_1920' binary data of the video thumbnail + OR 'image_url' containing an external link to the thumbnail when 'fetch_image' param is False + - 'completion_time' matching the video duration + + :param image_url_only: if False, will return 'image_url' instead of binary data + Typically used when displaying a slide preview to the end user. + :return a tuple (values, error) containing the values of the slide and a potential error + (e.g: 'Video could not be found') """ + + self.ensure_one() + error_message = False + try: + response = requests.get( + 'https://vimeo.com/api/oembed.json?%s' % urls.url_encode({'url': self.video_url}), + timeout=3 + ) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + error_message = e.response.content + if e.response.status_code == 404: + return {}, _('Your video could not be found on Vimeo, please check the link and/or privacy settings') + except requests.exceptions.ConnectionError as e: + error_message = str(e) + + if not error_message and 'application/json' in response.headers.get('content-type'): + response = response.json() + if response.get('error'): + error_message = response.get('error', {}).get('errors', [{}])[0].get('reason') + + if not response: + error_message = _('Please enter a valid Vimeo video link') + + if error_message: + _logger.warning('Could not fetch Vimeo metadata: %s', error_message) + return {}, error_message + + vimeo_values = response + slide_metadata = {'slide_type': 'vimeo_video'} + + if vimeo_values.get('title'): + slide_metadata['name'] = vimeo_values.get('title') + + if vimeo_values.get('description'): + slide_metadata['description'] = vimeo_values.get('description') + + if vimeo_values.get('duration'): + # seconds to hours conversion + slide_metadata['completion_time'] = vimeo_values.get('duration') / (60 * 60) + + thumbnail_url = vimeo_values.get('thumbnail_url') + if thumbnail_url: + if image_url_only: + slide_metadata['image_url'] = thumbnail_url + else: + slide_metadata['image_1920'] = base64.b64encode( + requests.get(thumbnail_url, timeout=3).content + ) + + return slide_metadata, None + def _default_website_meta(self): res = super(Slide, self)._default_website_meta() res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index 9fd92d1e587..0882af227b6 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -1,9 +1,10 @@ /** @odoo-module **/ -/* global YT */ +/* global YT, Vimeo */ import publicWidget from 'web.public.widget'; import { qweb as QWeb, _t } from 'web.core'; + import { Markup } from 'web.utils'; import config from 'web.config'; import session from 'web.session'; @@ -30,8 +31,8 @@ * its end, and `slide_completed` when the player is at 30 sec before the * end of the video (30 sec before is considered as completed). */ - var VideoPlayer = publicWidget.Widget.extend({ - template: 'website.slides.fullscreen.video', + var VideoPlayerYouTube = publicWidget.Widget.extend({ + template: 'website.slides.fullscreen.video.youtube', youtubeUrl: 'https://www.youtube.com/iframe_api', init: function (parent, slide) { @@ -129,6 +130,98 @@ }, }); + /** + * This widget is responsible of loading the Vimeo video. + * + * Similarly to the YouTube implementation, the widget will trigger an event `change_slide` when + * the video is at its end, and `slide_completed` when the player is at 30 sec before the end of + * the video (30 sec before is considered as completed). + * + * See https://developer.vimeo.com/player/sdk/reference for all the API documentation. + */ + var VideoPlayerVimeo = publicWidget.Widget.extend({ + template: 'website.slides.fullscreen.video.vimeo', + vimeoScriptUrl: 'https://player.vimeo.com/api/player.js', + + init: function (parent, slide) { + this.slide = slide; + return this._super.apply(this, arguments); + }, + + /** + * Loads the Vimeo JS API that allows interfacing with the iframe viewer. + * (We only load the API if not already loaded). + * + * @returns {Promise} + */ + willStart: function () { + var self = this; + var vimeoAPIPromise = new Promise(function (resolve, reject) { + if ($(document).find('script[src="' + self.vimeoScriptUrl + '"]').length === 0) { + $.ajax({ + url: self.vimeoScriptUrl, + dataType: 'script', + success: function () {resolve();} + }); + } else { + resolve(); + } + }); + + return Promise.all([this._super.apply(this, arguments), vimeoAPIPromise]); + }, + + start: function () { + return this._super.apply(arguments).then(this._setupVideoPlayer.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Instantiate the Vimeo player and register the various events. + */ + _setupVideoPlayer: async function () { + this.player = new Vimeo.Player(this.$('iframe')[0]); + this.videoDuration = await this.player.getDuration(); + this.player.on('timeupdate', this._onVideoTimeUpdate.bind(this)); + this.player.on('ended', this._onVideoEnded.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * When the player triggers the 'ended' event, we go to the next slide if there is one. + * + * See https://developer.vimeo.com/player/sdk/reference#ended for more information + */ + _onVideoEnded: function () { + if (this.slide.hasNext) { + this.trigger_up('slide_go_next', this.slide); + } + }, + + /** + * Every time the video changes position, both while viewing and also when seeking manually, + * Vimeo triggers this handy 'timeupdate' event. + * We use it to set the slide as completed as soon as we reach the end (30 last seconds). + * + * See https://developer.vimeo.com/player/sdk/reference#timeupdate for more information + * + * @param {Object} eventData the 'timeupdate' event data + */ + _onVideoTimeUpdate: async function (eventData) { + if (eventData.seconds > (this.videoDuration - 30)) { + if (!this.slide.hasQuestion && !this.slide.completed){ + this.trigger_up('slide_to_complete', this.slide); + } + } + } + }); + /** * This widget is responsible of navigation for one slide to another: @@ -501,7 +594,7 @@ // compute hasNext slide slideData.hasNext = index < slidesDataList.length-1; // compute embed url - if (slideData.category === 'video') { + if (slideData.category === 'video' && slideData.videoSourceType !== 'vimeo') { slideData.embedCode = $(slideData.embedCode).attr('src') || ""; // embedCode contains an iframe tag, where src attribute is the url (youtube or embed document from odoo) var separator = slideData.embedCode.indexOf("?") !== -1 ? "&" : "?"; var scheme = slideData.embedCode.indexOf('//') === 0 ? 'https:' : ''; @@ -510,6 +603,8 @@ params.autoplay = 1; } slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : ""; + } else if (slideData.category === 'video' && slideData.videoSourceType === 'vimeo') { + slideData.embedCode = Markup(slideData.embedCode); } else if (slideData.category === 'infographic') { slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id); } else if (slideData.category === 'document') { @@ -573,9 +668,14 @@ // render slide content if (_.contains(['document', 'infographic'], slide.category)) { $content.html(QWeb.render('website.slides.fullscreen.content', {widget: this})); - } else if (slide.category === 'video') { - this.videoPlayer = new VideoPlayer(this, slide); + } else if (slide.category === 'video' && slide.videoSourceType === 'youtube') { + this.videoPlayer = new VideoPlayerYouTube(this, slide); return this.videoPlayer.appendTo($content); + } else if (slide.category === 'video' && slide.videoSourceType === 'vimeo') { + this.videoPlayer = new VideoPlayerVimeo(this, slide); + return this.videoPlayer.appendTo($content); + } else if (slide.category === 'video' && slide.videoSourceType === 'google_drive') { + $content.html(QWeb.render('website.slides.fullscreen.video.google_drive', {widget: this})); } else if (slide.category === 'webpage'){ var $wpContainer = $('
').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto'); $(slide.htmlContent).appendTo($wpContainer); diff --git a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml index d34749cb85f..a4415789b97 100644 --- a/addons/website_slides/static/src/xml/website_slides_fullscreen.xml +++ b/addons/website_slides/static/src/xml/website_slides_fullscreen.xml @@ -13,10 +13,22 @@ - + +
+ +
+
+ +
+ +
+ +
+
+ diff --git a/addons/website_slides/static/src/xml/website_slides_upload.xml b/addons/website_slides/static/src/xml/website_slides_upload.xml index 7a37497b05a..775267e464d 100644 --- a/addons/website_slides/static/src/xml/website_slides_upload.xml +++ b/addons/website_slides/static/src/xml/website_slides_upload.xml @@ -239,7 +239,7 @@
- +
@@ -253,6 +253,20 @@
What does unlisted means? The YouTube "unlisted" means it is a video which can be viewed only by the users with the link to it. Your video will never come up in the search results nor on your channel.

+
On Vimeo
+
+ First, upload your videos on Vimeo and mark them as Private. This way, they will be secured.
+ What does Private mean? The Vimeo "Private" privacy setting means it is a video which can be viewed only by the users with the link to it. + Your video will never come up in the search results nor on your channel.
+ Change video privacy settings

+ The video link to input here can be obtained by using the 'share' button in the Vimeo interface.
+ It should look similar to + https://vimeo.com/558907333/30da9ff3d8 + for 'Private' videos and similar to + https://vimeo.com/558907555 + for public ones. +
+
On Google Drive
The Google Drive link can be obtained by using the 'share' button in the Google interface.
diff --git a/addons/website_slides/tests/test_slide_utils.py b/addons/website_slides/tests/test_slide_utils.py index df47492403a..e46130a61e1 100644 --- a/addons/website_slides/tests/test_slide_utils.py +++ b/addons/website_slides/tests/test_slide_utils.py @@ -168,7 +168,7 @@ class TestSequencing(slides_common.SlidesCase): class TestFromURL(slides_common.SlidesCase): - def test_video_urls(self): + def test_video_youtube(self): youtube_urls = { 'W0JQcpGLSFw': [ 'https://youtu.be/W0JQcpGLSFw', @@ -203,12 +203,60 @@ class TestFromURL(slides_common.SlidesCase): self.assertEqual('youtube', slide.video_source_type) self.assertEqual(youtube_id, slide.youtube_id) - # test URL from Google Drive when user hits the "share" button (main use case) - slide = Slide.create({ - 'name': 'dummy', - 'channel_id': self.channel.id, - 'url': 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing', - 'slide_category': 'video' - }) - self.assertEqual('google_drive', slide.video_source_type) - self.assertEqual('1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR', slide.google_drive_id) + def test_video_google_drive(self): + google_drive_urls = { + '1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR': [ + 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing', + 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR', + ], + } + + Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True) + + # test various Google Drive URL formats + for google_drive_id, urls in google_drive_urls.items(): + for url in urls: + with self.subTest(url=url, id=google_drive_id): + slide = Slide.create({ + 'name': 'dummy', + 'channel_id': self.channel.id, + 'url': url, + 'slide_category': 'video' + }) + self.assertEqual('google_drive', slide.video_source_type) + self.assertEqual(google_drive_id, slide.google_drive_id) + + def test_video_vimeo(self): + vimeo_urls = { + # regular URL from Vimeo + '545859999': [ + 'https://vimeo.com/545859999', + 'https://vimeo.com/545859999?autoplay=1', + ], + # test channel URL from Vimeo + '551979139': [ + 'https://vimeo.com/channels/staffpicks/551979139', + 'https://vimeo.com/channels/staffpicks/551979139?autoplay=1', + ], + # test URL from Vimeo with setting 'with URL only' + # we need to store both the ID and the token, see '_compute_embed_code' method for details + '545859999/94dd03ddb0': [ + 'https://vimeo.com/545859999/94dd03ddb0', + 'https://vimeo.com/545859999/94dd03ddb0?autoplay=1', + ], + } + + Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True) + + # test various Vimeo URL formats + for vimeo_id, urls in vimeo_urls.items(): + for url in urls: + with self.subTest(url=url, id=vimeo_id): + slide = Slide.create({ + 'name': 'dummy', + 'channel_id': self.channel.id, + 'url': url, + 'slide_category': 'video' + }) + self.assertEqual('vimeo', slide.video_source_type) + self.assertEqual(vimeo_id, slide.vimeo_id) diff --git a/addons/website_slides/views/website_slides_templates_course.xml b/addons/website_slides/views/website_slides_templates_course.xml index 330cf695060..ca952f6391b 100644 --- a/addons/website_slides/views/website_slides_templates_course.xml +++ b/addons/website_slides/views/website_slides_templates_course.xml @@ -812,6 +812,7 @@ +