[IMP] website, *: improve video providers handling

*: web_editor, website_sale

This commit adds the abibility to get an image thumbnail based on the
video provider URL and to avoid duplicating the regexes. It also
improves those URL regexes.

All the regexes are kept in a python tools file and are called in JS
using an RPC.

task-2154812

closes odoo/odoo#44537

Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
Co-authored-by: ras-odoo <ras@odoo.com>
Co-authored-by: bbh-odoo <bbh@odoo.com>
This commit is contained in:
jpr-odoo
2021-11-19 22:32:39 +00:00
committed by qsm-odoo
co-authored by ras-odoo bbh-odoo
parent 1de5dc4b96
commit 2dd772dab8
7 changed files with 269 additions and 116 deletions
+14
View File
@@ -15,6 +15,7 @@ from base64 import b64decode, b64encode
from odoo.http import request
from odoo import http, tools, _, SUPERUSER_ID
from odoo.addons.http_routing.models.ir_http import slug, unslug
from odoo.addons.web_editor.tools import get_video_url_data
from odoo.exceptions import UserError
from odoo.modules.module import get_resource_path
from odoo.tools.mimetypes import guess_mimetype
@@ -174,6 +175,19 @@ class Web_Editor(http.Controller):
return True
@http.route('/web_editor/video_url/data', type='json', auth='user', website=True)
def video_url_data(self, video_url, autoplay=False, loop=False,
hide_controls=False, hide_fullscreen=False, hide_yt_logo=False,
hide_dm_logo=False, hide_dm_share=False):
if not request.env.user.has_group('base.group_user'):
raise werkzeug.exceptions.Forbidden()
return get_video_url_data(
video_url, autoplay=autoplay, loop=loop,
hide_controls=hide_controls, hide_fullscreen=hide_fullscreen,
hide_yt_logo=hide_yt_logo, hide_dm_logo=hide_dm_logo,
hide_dm_share=hide_dm_share
)
@http.route('/web_editor/attachment/add_data', type='json', auth='user', methods=['POST'], website=True)
def add_data(self, name, data, is_image, quality=0, width=0, height=0, res_id=False, res_model='ir.ui.view', **kwargs):
if is_image:
@@ -1228,7 +1228,8 @@ var VideoWidget = MediaWidget.extend({
/**
* @override
*/
start: function () {
start: async function () {
const _super = this._super.bind(this);
this.$content = this.$('.o_video_dialog_iframe');
if (this.media) {
@@ -1244,7 +1245,7 @@ var VideoWidget = MediaWidget.extend({
this.$('input#o_video_hide_dm_logo').prop('checked', src.indexOf('ui-logo=0') >= 0);
this.$('input#o_video_hide_dm_share').prop('checked', src.indexOf('sharing-enable=0') >= 0);
this._updateVideo();
await this._updateVideo();
}
// loads the thumbnail of vimeo video previews.
@@ -1264,7 +1265,7 @@ var VideoWidget = MediaWidget.extend({
});
});
return this._super.apply(this, arguments);
return _super(...arguments);
},
//--------------------------------------------------------------------------
@@ -1274,8 +1275,8 @@ var VideoWidget = MediaWidget.extend({
/**
* @override
*/
save: function () {
this._updateVideo();
save: async function () {
await this._updateVideo();
const videoSrc = this.$content.attr('src');
if (this.isForBgVideo) {
return Promise.resolve({bgVideoSrc: videoSrc});
@@ -1337,28 +1338,28 @@ var VideoWidget = MediaWidget.extend({
* errorCode -> if defined, either '0' for invalid URL or '1' for
* unsupported video provider
*/
_createVideoNode: function (url, options) {
_createVideoNode: async function (url, options) {
options = options || {};
const videoData = this._getVideoURLData(url, options);
const videoData = await this._getVideoURLData(url, options);
if (videoData.error) {
return {errorCode: 0};
}
if (!videoData.type) {
if (!videoData.platform) {
return {errorCode: 1};
}
const $video = $('<iframe>').width(1280).height(720)
.attr('frameborder', 0)
.attr('src', videoData.embedURL)
.attr('src', videoData.embed_url)
.addClass('o_video_dialog_iframe');
return {$video: $video, type: videoData.type};
return {$video: $video, platform: videoData.platform};
},
/**
* Updates the video preview according to video code and enabled options.
*
* @private
*/
_updateVideo: function () {
_updateVideo: async function () {
// Reset the feedback
this.$content.empty();
this.$('#o_video_form_group').removeClass('o_has_error o_has_success').find('.form-control, .custom-select').removeClass('is-invalid is-valid');
@@ -1378,7 +1379,7 @@ var VideoWidget = MediaWidget.extend({
}
var url = embedMatch ? embedMatch[1] : code;
var query = this._createVideoNode(url, {
const query = await this._createVideoNode(url, {
'autoplay': this.isForBgVideo || this.$('input#o_video_autoplay').is(':checked'),
'hide_controls': this.isForBgVideo || this.$('input#o_video_hide_controls').is(':checked'),
'loop': this.isForBgVideo || this.$('input#o_video_loop').is(':checked'),
@@ -1399,13 +1400,13 @@ var VideoWidget = MediaWidget.extend({
.toggleClass('o_has_success', !!query.$video).find('.form-control, .custom-select').toggleClass('is-valid', !!query.$video);
// Individually show / hide options base on the video provider
$optBox.find('div.o_' + query.type + '_option').removeClass('d-none');
$optBox.find('div.o_' + query.platform + '_option').removeClass('d-none');
// Hide the entire options box if no options are available or if the
// dialog is opened for a background-video
$optBox.toggleClass('d-none', this.isForBgVideo || $optBox.find('div:not(.d-none)').length === 0);
if (query.type === 'youtube') {
if (query.platform === 'youtube') {
// Youtube only: If 'hide controls' is checked, hide 'fullscreen'
// and 'youtube logo' options too
this.$('input#o_video_hide_fullscreen, input#o_video_hide_yt_logo').closest('div').toggleClass('d-none', this.$('input#o_video_hide_controls').is(':checked'));
@@ -1481,56 +1482,10 @@ var VideoWidget = MediaWidget.extend({
* @private
*/
_getVideoURLData: function (url, options) {
if (!url.match(/^(http:\/\/|https:\/\/|\/\/)[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i)) {
return {
error: true,
message: 'The provided url is invalid',
};
}
const regexes = {
youtube: /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/,
instagram: /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/,
vine: /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/,
vimeo: /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/,
dailymotion: /.+dailymotion.com\/(video|hub|embed)\/([^_?]+)[^#]*(#video=([^_&]+))?/,
youku: /(.*).youku\.com\/(v_show\/id_|embed\/)(.+)/,
};
const matches = _.mapObject(regexes, regex => url.match(regex));
const autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0';
const controls = options.hide_controls ? '&controls=0' : '';
const loop = options.loop ? '&loop=1' : '';
let embedURL;
let type;
if (matches.youtube && matches.youtube[2].length === 11) {
const fullscreen = options.hide_fullscreen ? '&fs=0' : '';
const ytLoop = loop ? loop + `&playlist=${matches.youtube[2]}` : '';
const logo = options.hide_yt_logo ? '&modestbranding=1' : '';
embedURL = `//www.youtube${matches.youtube[1] || ''}.com/embed/${matches.youtube[2]}${autoplay}&rel=0${ytLoop}${controls}${fullscreen}${logo}`;
type = 'youtube';
} else if (matches.instagram && matches.instagram[2].length) {
embedURL = `//www.instagram.com/p/${matches.instagram[2]}/embed/`;
type = 'instagram';
} else if (matches.vine && matches.vine[0].length) {
embedURL = `${matches.vine[0]}/embed/simple`;
type = 'vine';
} else if (matches.vimeo && matches.vimeo[3].length) {
const vimeoAutoplay = autoplay.replace('mute', 'muted');
embedURL = `//player.vimeo.com/video/${matches.vimeo[3]}${vimeoAutoplay}${loop}`;
type = 'vimeo';
} else if (matches.dailymotion && matches.dailymotion[2].length) {
const videoId = matches.dailymotion[2].replace('video/', '');
const logo = options.hide_dm_logo ? '&ui-logo=0' : '';
const share = options.hide_dm_share ? '&sharing-enable=0' : '';
embedURL = `//www.dailymotion.com/embed/video/${videoId}${autoplay}${controls}${logo}${share}`;
type = 'dailymotion';
} else if (matches.youku && matches.youku[3].length) {
const videoId = matches.youku[3].indexOf('.html?') >= 0 ? matches.youku[3].substring(0, matches.youku[3].indexOf('.html?')) : matches.youku[3];
embedURL = `//player.youku.com/embed/${videoId}`;
type = 'youku';
}
return {type: type, embedURL: embedURL};
return this._rpc({
route: '/web_editor/video_url/data',
params: Object.assign({video_url: url}, options),
});
},
});
+1
View File
@@ -4,3 +4,4 @@
from . import test_converter
from . import test_odoo_editor
from . import test_views
from . import test_tools
+79
View File
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo.tests import common, tagged
from odoo.addons.web_editor import tools
@tagged('post_install', '-at_install')
class TestVideoUtils(common.BaseCase):
urls = {
'youtube': 'https://www.youtube.com/watch?v=xCvFZrrQq7k',
'vimeo': 'https://vimeo.com/395399735',
'dailymotion': 'https://www.dailymotion.com/video/x7svr6t',
'youku': 'https://v.youku.com/v_show/id_XMzY1MjY4.html?spm=a2hzp.8244740.0.0',
'instagram': 'https://www.instagram.com/p/B6dXGTxggTG/'
}
def test_player_regexes(self):
#youtube
self.assertIsNotNone(re.search(tools.player_regexes['youtube'], TestVideoUtils.urls['youtube']))
#vimeo
self.assertIsNotNone(re.search(tools.player_regexes['vimeo'], TestVideoUtils.urls['vimeo']))
#dailymotion
self.assertIsNotNone(re.search(tools.player_regexes['dailymotion'], TestVideoUtils.urls['dailymotion']))
#youku
self.assertIsNotNone(re.search(tools.player_regexes['youku'], TestVideoUtils.urls['youku']))
#instagram
self.assertIsNotNone(re.search(tools.player_regexes['instagram'], TestVideoUtils.urls['instagram']))
def test_get_video_source_data(self):
self.assertEqual(3, len(tools.get_video_source_data(TestVideoUtils.urls['youtube'])))
#youtube
self.assertEqual('youtube', tools.get_video_source_data(TestVideoUtils.urls['youtube'])[0])
self.assertEqual('xCvFZrrQq7k', tools.get_video_source_data(TestVideoUtils.urls['youtube'])[1])
#vimeo
self.assertEqual('vimeo', tools.get_video_source_data(TestVideoUtils.urls['vimeo'])[0])
self.assertEqual('395399735', tools.get_video_source_data(TestVideoUtils.urls['vimeo'])[1])
#dailymotion
self.assertEqual('dailymotion', tools.get_video_source_data(TestVideoUtils.urls['dailymotion'])[0])
self.assertEqual('x7svr6t', tools.get_video_source_data(TestVideoUtils.urls['dailymotion'])[1])
#youku
self.assertEqual('youku', tools.get_video_source_data(TestVideoUtils.urls['youku'])[0])
self.assertEqual('XMzY1MjY4', tools.get_video_source_data(TestVideoUtils.urls['youku'])[1])
#instagram
self.assertEqual('instagram', tools.get_video_source_data(TestVideoUtils.urls['instagram'])[0])
self.assertEqual('B6dXGTxggTG', tools.get_video_source_data(TestVideoUtils.urls['instagram'])[1])
def test_get_video_url_data(self):
self.assertEqual(2, len(tools.get_video_url_data(TestVideoUtils.urls['youtube'])))
#youtube
self.assertEqual('youtube', tools.get_video_url_data(TestVideoUtils.urls['youtube'])['platform'])
#vimeo
self.assertEqual('vimeo', tools.get_video_url_data(TestVideoUtils.urls['vimeo'])['platform'])
#dailymotion
self.assertEqual('dailymotion', tools.get_video_url_data(TestVideoUtils.urls['dailymotion'])['platform'])
#youku
self.assertEqual('youku', tools.get_video_url_data(TestVideoUtils.urls['youku'])['platform'])
#instagram
self.assertEqual('instagram', tools.get_video_url_data(TestVideoUtils.urls['instagram'])['platform'])
def test_valid_video_url(self):
self.assertIsNotNone(re.search(tools.valid_url_regex, TestVideoUtils.urls['youtube']))
@tagged('-standard', 'external')
class TestVideoUtilsExternal(common.BaseCase):
def test_get_video_thumbnail(self):
#youtube
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['youtube']), bytes)
#vimeo
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['vimeo']), bytes)
#dailymotion
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['dailymotion']), bytes)
#instagram
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['instagram']), bytes)
#default
self.assertIsInstance(tools.get_video_thumbnail(TestVideoUtils.urls['youku']), bytes)
+148
View File
@@ -0,0 +1,148 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import re
import requests
from markupsafe import Markup
from werkzeug.urls import url_encode
from odoo import _
from odoo.tools import image_process
# To detect if we have a valid URL or not
valid_url_regex = r'^(http://|https://|//)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$'
# Regex for few of the widely used video hosting services
player_regexes = {
'youtube': r'^(?:(?:https?:)?//)?(?:www\.)?(?:youtu\.be/|youtube(-nocookie)?\.com/(?:embed/|v/|watch\?v=|watch\?.+&v=))((?:\w|-){11})\S*$',
'vimeo': r'//(player.)?vimeo.com/([a-z]*/)*([0-9]{6,11})[?]?.*',
'dailymotion': r'(?:.+dailymotion.com/(video|hub|embed/video|embed)|dai\.ly)/([^_?]+)[^#]*(#video=([^_&]+))?',
'instagram': r'(?:(.*)instagram.com|instagr\.am)/p/(.[a-zA-Z0-9-_\.]*)',
'youku': r'(.*).youku\.com/(v_show/id_|embed/)(.+)',
}
def get_video_source_data(video_url):
""" Computes the valid source, document ID and regex match from given URL
(or None in case of invalid URL).
"""
if not video_url:
return None
if re.search(valid_url_regex, video_url):
youtube_match = re.search(player_regexes['youtube'], video_url)
if youtube_match:
return ('youtube', youtube_match[2], youtube_match)
vimeo_match = re.search(player_regexes['vimeo'], video_url)
if vimeo_match:
return ('vimeo', vimeo_match[3], vimeo_match)
dailymotion_match = re.search(player_regexes['dailymotion'], video_url)
if dailymotion_match:
return ('dailymotion', dailymotion_match[2], dailymotion_match)
instagram_match = re.search(player_regexes['instagram'], video_url)
if instagram_match:
return ('instagram', instagram_match[2], instagram_match)
youku_match = re.search(player_regexes['youku'], video_url)
if youku_match:
youku_link = youku_match[3]
if '.html?' in youku_link:
youku_link = youku_link.split('.html?')[0]
return ('youku', youku_link, youku_match)
return None
def get_video_url_data(video_url, autoplay=False, loop=False, hide_controls=False, hide_fullscreen=False, hide_yt_logo=False, hide_dm_logo=False, hide_dm_share=False):
""" Computes the platform name and embed_url from given URL
(or error message in case of invalid URL).
"""
source = get_video_source_data(video_url)
if source is None:
return {'error': True, 'message': _('The provided url is invalid')}
embed_url = video_url
platform, video_id, platform_match = source
params = {}
if platform == 'youtube':
params['rel'] = 0
params['autoplay'] = autoplay and 1 or 0
if autoplay:
params['mute'] = 1
if hide_controls:
params['controls'] = 0
if loop:
params['loop'] = 1
params['playlist'] = video_id
if hide_fullscreen:
params['fs'] = 0
if hide_yt_logo:
params['modestbranding'] = 1
yt_extra = platform_match[1] or ''
embed_url = f'//www.youtube{yt_extra}.com/embed/{video_id}'
elif platform == 'vimeo':
params['autoplay'] = autoplay and 1 or 0
if autoplay:
params['muted'] = 1
if loop:
params['loop'] = 1
embed_url = f'//player.vimeo.com/video/{video_id}'
elif platform == 'dailymotion':
params['autoplay'] = autoplay and 1 or 0
if autoplay:
params['mute'] = 1
if hide_controls:
params['controls'] = 0
if hide_dm_logo:
params['ui-logo'] = 0
if hide_dm_share:
params['sharing-enable'] = 0
embed_url = f'//www.dailymotion.com/embed/video/{video_id}'
elif platform == 'instagram':
embed_url = f'//www.instagram.com/p/{video_id}/embed/'
elif platform == 'youku':
embed_url = f'//player.youku.com/embed/{video_id}'
if params:
embed_url = f'{embed_url}?{url_encode(params)}'
return {'platform': platform, 'embed_url': embed_url}
def get_video_embed_code(video_url):
""" Computes the valid iframe from given URL that can be embedded
(or None in case of invalid URL).
"""
data = get_video_url_data(video_url)
if 'error' in data:
return None
return Markup('<iframe class="embed-responsive-item" src="%s" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowFullScreen="true" frameborder="0"></iframe>') % data['embed_url']
def get_video_thumbnail(video_url):
""" Computes the valid thumbnail image from given URL
(or None in case of invalid URL).
"""
source = get_video_source_data(video_url)
if source is None:
return None
response = None
platform, video_id = source[:2]
if platform == 'youtube':
response = requests.get(f'https://img.youtube.com/vi/{video_id}/0.jpg', timeout=10)
elif platform == 'vimeo':
res = requests.get(f'http://vimeo.com/api/oembed.json?url={video_url}', timeout=10)
if res.ok:
data = res.json()
response = requests.get(data['thumbnail_url'], timeout=10)
elif platform == 'dailymotion':
response = requests.get(f'https://www.dailymotion.com/thumbnail/video/{video_id}', timeout=10)
elif platform == 'instagram':
response = requests.get(f'https://www.instagram.com/p/{video_id}/media/?size=t', timeout=10)
if response and response.ok:
return image_process(base64.b64encode(response.content))
return None
-49
View File
@@ -1,9 +1,7 @@
# -*- encoding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import contextlib
import re
from lxml import etree
from psycopg2 import sql
from unittest.mock import Mock, MagicMock, patch
import werkzeug
@@ -12,53 +10,6 @@ import odoo
from odoo.tools.misc import DotDict
def get_video_embed_code(video_url):
''' Computes the valid iframe from given URL that can be embedded
(or False in case of invalid URL).
'''
if not video_url:
return False
# To detect if we have a valid URL or not
validURLRegex = r'^(http:\/\/|https:\/\/|\/\/)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$'
# Regex for few of the widely used video hosting services
ytRegex = r'^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$'
vimeoRegex = r'\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*'
dmRegex = r'.+dailymotion.com\/(video|hub|embed)\/([^_?]+)[^#]*(#video=([^_&]+))?'
igRegex = r'(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)'
ykuRegex = r'(.*).youku\.com\/(v_show\/id_|embed\/)(.+)'
if not re.search(validURLRegex, video_url):
return False
else:
embedUrl = False
ytMatch = re.search(ytRegex, video_url)
vimeoMatch = re.search(vimeoRegex, video_url)
dmMatch = re.search(dmRegex, video_url)
igMatch = re.search(igRegex, video_url)
ykuMatch = re.search(ykuRegex, video_url)
if ytMatch and len(ytMatch.groups()[1]) == 11:
embedUrl = '//www.youtube%s.com/embed/%s?rel=0' % (ytMatch.groups()[0] or '', ytMatch.groups()[1])
elif vimeoMatch:
embedUrl = '//player.vimeo.com/video/%s' % (vimeoMatch.groups()[2])
elif dmMatch:
embedUrl = '//www.dailymotion.com/embed/video/%s' % (dmMatch.groups()[1])
elif igMatch:
embedUrl = '//www.instagram.com/p/%s/embed/' % (igMatch.groups()[1])
elif ykuMatch:
ykuLink = ykuMatch.groups()[2]
if '.html?' in ykuLink:
ykuLink = ykuLink.split('.html?')[0]
embedUrl = '//player.youku.com/embed/%s' % (ykuLink)
else:
# We directly use the provided URL as it is
embedUrl = video_url
return '<iframe class="embed-responsive-item" src="%s" allowFullScreen="true" frameborder="0"></iframe>' % embedUrl
def werkzeugRaiseNotFound(*args, **kwargs):
raise werkzeug.exceptions.NotFound()
+8 -3
View File
@@ -4,7 +4,7 @@
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
from odoo.addons.website.tools import get_video_embed_code
from odoo.addons.web_editor.tools import get_video_embed_code, get_video_thumbnail
class ProductImage(models.Model):
@@ -16,7 +16,7 @@ class ProductImage(models.Model):
name = fields.Char("Name", required=True)
sequence = fields.Integer(default=10, index=True)
image_1920 = fields.Image(required=True)
image_1920 = fields.Image()
product_tmpl_id = fields.Many2one('product.template', "Product Template", index=True, ondelete='cascade')
product_variant_id = fields.Many2one('product.product', "Product Variant", index=True, ondelete='cascade')
@@ -31,10 +31,15 @@ class ProductImage(models.Model):
for image in self:
image.can_image_1024_be_zoomed = image.image_1920 and tools.is_image_size_above(image.image_1920, image.image_1024)
@api.onchange('video_url')
def _onchange_video_url(self):
if not self.image_1920:
self.image_1920 = get_video_thumbnail(self.video_url) or False
@api.depends('video_url')
def _compute_embed_code(self):
for image in self:
image.embed_code = get_video_embed_code(image.video_url)
image.embed_code = get_video_embed_code(image.video_url) or False
@api.constrains('video_url')
def _check_valid_video_url(self):