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.
Change video privacy settings

+
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 @@ +