Hello community, here is the log from the commit of package youtube-dl for openSUSE:Factory checked in at 2020-11-29 12:33:18 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/youtube-dl (Old) and /work/SRC/openSUSE:Factory/.youtube-dl.new.5913 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Package is "youtube-dl" Sun Nov 29 12:33:18 2020 rev:146 rq:851648 version:2020.11.29 Changes: -------- --- /work/SRC/openSUSE:Factory/youtube-dl/python-youtube-dl.changes 2020-11-26 23:15:26.141039516 +0100 +++ /work/SRC/openSUSE:Factory/.youtube-dl.new.5913/python-youtube-dl.changes 2020-11-29 12:34:06.290346664 +0100 @@ -1,0 +2,6 @@ +Sun Nov 29 10:12:40 UTC 2020 - Jan Engelhardt <jengelh@inai.de> + +- Update to release 2020.11.29 + * youtube: Improve yt initial player response extraction + +------------------------------------------------------------------- youtube-dl.changes: same change Old: ---- youtube-dl-2020.11.26.tar.gz youtube-dl-2020.11.26.tar.gz.sig New: ---- youtube-dl-2020.11.29.tar.gz youtube-dl-2020.11.29.tar.gz.sig ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-youtube-dl.spec ++++++ --- /var/tmp/diff_new_pack.xxwdWt/_old 2020-11-29 12:34:06.922347303 +0100 +++ /var/tmp/diff_new_pack.xxwdWt/_new 2020-11-29 12:34:06.922347303 +0100 @@ -19,7 +19,7 @@ %define modname youtube-dl %{?!python_module:%define python_module() python-%{**} python3-%{**}} Name: python-youtube-dl -Version: 2020.11.26 +Version: 2020.11.29 Release: 0 Summary: A Python module for downloading from video sites for offline watching License: SUSE-Public-Domain AND CC-BY-SA-3.0 ++++++ youtube-dl.spec ++++++ --- /var/tmp/diff_new_pack.xxwdWt/_old 2020-11-29 12:34:06.942347323 +0100 +++ /var/tmp/diff_new_pack.xxwdWt/_new 2020-11-29 12:34:06.946347328 +0100 @@ -17,7 +17,7 @@ Name: youtube-dl -Version: 2020.11.26 +Version: 2020.11.29 Release: 0 Summary: A tool for downloading from video sites for offline watching License: SUSE-Public-Domain AND CC-BY-SA-3.0 ++++++ youtube-dl-2020.11.26.tar.gz -> youtube-dl-2020.11.29.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/ChangeLog new/youtube-dl/ChangeLog --- old/youtube-dl/ChangeLog 2020-11-25 21:05:47.000000000 +0100 +++ new/youtube-dl/ChangeLog 2020-11-29 07:52:58.000000000 +0100 @@ -1,3 +1,19 @@ +version 2020.11.29 + +Core +* [YoutubeDL] Write static debug to stderr and respect quiet for dynamic debug + (#14579, #22593) + +Extractors +* [drtv] Extend URL regular expression (#27243) +* [tiktok] Fix extraction (#20809, #22838, #22850, #25987, #26281, #26411, + #26639, #26776, #27237) ++ [ina] Add support for mobile URLs (#27229) +* [pornhub] Fix like and dislike count extraction (#27227, #27234) +* [youtube] Improve yt initial player response extraction (#27216) +* [videa] Fix extraction (#25650, #25973, #26301) + + version 2020.11.26 Core @@ -12,7 +28,7 @@ * [bbc] Fix BBC Three clip extraction * [bbc] Fix BBC News videos extraction + [medaltv] Add support for medal.tv (#27149) -* [youtube] Imporve music metadata and license extraction (#26013) +* [youtube] Improve music metadata and license extraction (#26013) * [nrk] Fix extraction * [cda] Fix extraction (#17803, #24458, #24518, #26381) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/docs/supportedsites.md new/youtube-dl/docs/supportedsites.md --- old/youtube-dl/docs/supportedsites.md 2020-11-25 21:05:51.000000000 +0100 +++ new/youtube-dl/docs/supportedsites.md 2020-11-29 07:53:01.000000000 +0100 @@ -912,7 +912,7 @@ - **ThisAV** - **ThisOldHouse** - **TikTok** - - **TikTokUser** + - **TikTokUser** (Currently broken) - **tinypic**: tinypic.com videos - **TMZ** - **TMZArticle** Binary files old/youtube-dl/youtube-dl and new/youtube-dl/youtube-dl differ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/YoutubeDL.py new/youtube-dl/youtube_dl/YoutubeDL.py --- old/youtube-dl/youtube_dl/YoutubeDL.py 2020-11-25 21:05:29.000000000 +0100 +++ new/youtube-dl/youtube_dl/YoutubeDL.py 2020-11-29 07:52:00.000000000 +0100 @@ -1610,7 +1610,7 @@ if req_format is None: req_format = self._default_format_spec(info_dict, download=download) if self.params.get('verbose'): - self.to_stdout('[debug] Default format spec: %s' % req_format) + self._write_string('[debug] Default format spec: %s\n' % req_format) format_selector = self.build_format_selector(req_format) @@ -1871,7 +1871,7 @@ for ph in self._progress_hooks: fd.add_progress_hook(ph) if self.params.get('verbose'): - self.to_stdout('[debug] Invoking downloader on %r' % info.get('url')) + self.to_screen('[debug] Invoking downloader on %r' % info.get('url')) return fd.download(name, info) if info_dict.get('requested_formats') is not None: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/drtv.py new/youtube-dl/youtube_dl/extractor/drtv.py --- old/youtube-dl/youtube_dl/extractor/drtv.py 2020-11-25 21:05:29.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/drtv.py 2020-11-29 07:52:00.000000000 +0100 @@ -29,7 +29,7 @@ https?:// (?: (?:www\.)?dr\.dk/(?:tv/se|nyheder|radio(?:/ondemand)?)/(?:[^/]+/)*| - (?:www\.)?(?:dr\.dk|dr-massive\.com)/drtv/(?:se|episode)/ + (?:www\.)?(?:dr\.dk|dr-massive\.com)/drtv/(?:se|episode|program)/ ) (?P<id>[\da-z_-]+) ''' @@ -111,6 +111,9 @@ }, { 'url': 'https://dr-massive.com/drtv/se/bonderoeven_71769', 'only_matching': True, + }, { + 'url': 'https://www.dr.dk/drtv/program/jagten_220924', + 'only_matching': True, }] def _real_extract(self, url): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/ina.py new/youtube-dl/youtube_dl/extractor/ina.py --- old/youtube-dl/youtube_dl/extractor/ina.py 2020-11-25 21:05:29.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/ina.py 2020-11-29 07:52:00.000000000 +0100 @@ -12,7 +12,7 @@ class InaIE(InfoExtractor): - _VALID_URL = r'https?://(?:www\.)?ina\.fr/(?:video|audio)/(?P<id>[A-Z0-9_]+)' + _VALID_URL = r'https?://(?:(?:www|m)\.)?ina\.fr/(?:video|audio)/(?P<id>[A-Z0-9_]+)' _TESTS = [{ 'url': 'http://www.ina.fr/video/I12055569/francois-hollande-je-crois-que-c-est-clair...', 'md5': 'a667021bf2b41f8dc6049479d9bb38a3', @@ -31,6 +31,9 @@ }, { 'url': 'https://www.ina.fr/video/P16173408-video.html', 'only_matching': True, + }, { + 'url': 'http://m.ina.fr/video/I12055569', + 'only_matching': True, }] def _real_extract(self, url): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/pornhub.py new/youtube-dl/youtube_dl/extractor/pornhub.py --- old/youtube-dl/youtube_dl/extractor/pornhub.py 2020-11-25 21:05:29.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/pornhub.py 2020-11-29 07:52:00.000000000 +0100 @@ -346,9 +346,9 @@ view_count = self._extract_count( r'<span class="count">([\d,\.]+)</span> [Vv]iews', webpage, 'view') like_count = self._extract_count( - r'<span class="votesUp">([\d,\.]+)</span>', webpage, 'like') + r'<span[^>]+class="votesUp"[^>]*>([\d,\.]+)</span>', webpage, 'like') dislike_count = self._extract_count( - r'<span class="votesDown">([\d,\.]+)</span>', webpage, 'dislike') + r'<span[^>]+class="votesDown"[^>]*>([\d,\.]+)</span>', webpage, 'dislike') comment_count = self._extract_count( r'All Comments\s*<span>\(([\d,.]+)\)', webpage, 'comment') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/spreaker.py new/youtube-dl/youtube_dl/extractor/spreaker.py --- old/youtube-dl/youtube_dl/extractor/spreaker.py 2020-11-25 21:05:35.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/spreaker.py 2020-11-29 07:52:00.000000000 +0100 @@ -126,7 +126,7 @@ class SpreakerShowIE(InfoExtractor): _VALID_URL = r'https?://api\.spreaker\.com/show/(?P<id>\d+)' _TESTS = [{ - 'url': 'https://www.spreaker.com/show/3-ninjas-podcast', + 'url': 'https://api.spreaker.com/show/4652058', 'info_dict': { 'id': '4652058', }, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/tiktok.py new/youtube-dl/youtube_dl/extractor/tiktok.py --- old/youtube-dl/youtube_dl/extractor/tiktok.py 2020-11-25 21:05:29.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/tiktok.py 2020-11-29 07:52:00.000000000 +0100 @@ -5,6 +5,7 @@ from ..utils import ( compat_str, ExtractorError, + float_or_none, int_or_none, str_or_none, try_get, @@ -13,7 +14,7 @@ class TikTokBaseIE(InfoExtractor): - def _extract_aweme(self, data): + def _extract_video(self, data, video_id=None): video = data['video'] description = str_or_none(try_get(data, lambda x: x['desc'])) width = int_or_none(try_get(data, lambda x: video['width'])) @@ -21,43 +22,54 @@ format_urls = set() formats = [] - for format_id in ( - 'play_addr_lowbr', 'play_addr', 'play_addr_h264', - 'download_addr'): - for format in try_get( - video, lambda x: x[format_id]['url_list'], list) or []: - format_url = url_or_none(format) - if not format_url: - continue - if format_url in format_urls: - continue - format_urls.add(format_url) - formats.append({ - 'url': format_url, - 'ext': 'mp4', - 'height': height, - 'width': width, - }) + for format_id in ('download', 'play'): + format_url = url_or_none(video.get('%sAddr' % format_id)) + if not format_url: + continue + if format_url in format_urls: + continue + format_urls.add(format_url) + formats.append({ + 'url': format_url, + 'ext': 'mp4', + 'height': height, + 'width': width, + 'http_headers': { + 'Referer': 'https://www.tiktok.com/', + } + }) self._sort_formats(formats) - thumbnail = url_or_none(try_get( - video, lambda x: x['cover']['url_list'][0], compat_str)) + thumbnail = url_or_none(video.get('cover')) + duration = float_or_none(video.get('duration')) + uploader = try_get(data, lambda x: x['author']['nickname'], compat_str) - timestamp = int_or_none(data.get('create_time')) - comment_count = int_or_none(data.get('comment_count')) or int_or_none( - try_get(data, lambda x: x['statistics']['comment_count'])) - repost_count = int_or_none(try_get( - data, lambda x: x['statistics']['share_count'])) + uploader_id = try_get(data, lambda x: x['author']['id'], compat_str) + + timestamp = int_or_none(data.get('createTime')) - aweme_id = data['aweme_id'] + def stats(key): + return int_or_none(try_get( + data, lambda x: x['stats']['%sCount' % key])) + + view_count = stats('play') + like_count = stats('digg') + comment_count = stats('comment') + repost_count = stats('share') + + aweme_id = data.get('id') or video_id return { 'id': aweme_id, 'title': uploader or aweme_id, 'description': description, 'thumbnail': thumbnail, + 'duration': duration, 'uploader': uploader, + 'uploader_id': uploader_id, 'timestamp': timestamp, + 'view_count': view_count, + 'like_count': like_count, 'comment_count': comment_count, 'repost_count': repost_count, 'formats': formats, @@ -65,62 +77,56 @@ class TikTokIE(TikTokBaseIE): - _VALID_URL = r'''(?x) - https?:// - (?: - (?:m\.)?tiktok\.com/v| - (?:www\.)?tiktok\.com/share/video - ) - /(?P<id>\d+) - ''' + _VALID_URL = r'https?://(?:www\.)?tiktok\.com/@[^/]+/video/(?P<id>\d+)' _TESTS = [{ - 'url': 'https://m.tiktok.com/v/6606727368545406213.html', - 'md5': 'd584b572e92fcd48888051f238022420', + 'url': 'https://www.tiktok.com/@zureeal/video/6606727368545406213', + 'md5': '163ceff303bb52de60e6887fe399e6cd', 'info_dict': { 'id': '6606727368545406213', 'ext': 'mp4', 'title': 'Zureeal', 'description': '#bowsette#mario#cosplay#uk#lgbt#gaming#asian#bowsettecosplay', - 'thumbnail': r're:^https?://.*~noop.image', + 'thumbnail': r're:^https?://.*', + 'duration': 15, 'uploader': 'Zureeal', + 'uploader_id': '188294915489964032', 'timestamp': 1538248586, 'upload_date': '20180929', + 'view_count': int, + 'like_count': int, 'comment_count': int, 'repost_count': int, } - }, { - 'url': 'https://www.tiktok.com/share/video/6606727368545406213', - 'only_matching': True, }] + def _real_initialize(self): + # Setup session (will set necessary cookies) + self._request_webpage( + 'https://www.tiktok.com/', None, note='Setting up session') + def _real_extract(self, url): video_id = self._match_id(url) - webpage = self._download_webpage( - 'https://m.tiktok.com/v/%s.html' % video_id, video_id) + webpage = self._download_webpage(url, video_id) data = self._parse_json(self._search_regex( - r'\bdata\s*=\s*({.+?})\s*;', webpage, 'data'), video_id) - return self._extract_aweme(data) + r'<script[^>]+\bid=["\']__NEXT_DATA__[^>]+>\s*({.+?})\s*</script', + webpage, 'data'), video_id)['props']['pageProps']['itemInfo']['itemStruct'] + return self._extract_video(data, video_id) class TikTokUserIE(TikTokBaseIE): - _VALID_URL = r'''(?x) - https?:// - (?: - (?:m\.)?tiktok\.com/h5/share/usr| - (?:www\.)?tiktok\.com/share/user - ) - /(?P<id>\d+) - ''' + _VALID_URL = r'https://(?:www\.)?tiktok\.com/@(?P<id>[^/?#&]+)' _TESTS = [{ - 'url': 'https://m.tiktok.com/h5/share/usr/188294915489964032.html', + 'url': 'https://www.tiktok.com/@zureeal', 'info_dict': { 'id': '188294915489964032', }, 'playlist_mincount': 24, - }, { - 'url': 'https://www.tiktok.com/share/user/188294915489964032', - 'only_matching': True, }] + _WORKING = False + + @classmethod + def suitable(cls, url): + return False if TikTokIE.suitable(url) else super(TikTokUserIE, cls).suitable(url) def _real_extract(self, url): user_id = self._match_id(url) @@ -130,7 +136,7 @@ entries = [] for aweme in data['aweme_list']: try: - entry = self._extract_aweme(aweme) + entry = self._extract_video(aweme) except ExtractorError: continue entry['extractor_key'] = TikTokIE.ie_key() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/videa.py new/youtube-dl/youtube_dl/extractor/videa.py --- old/youtube-dl/youtube_dl/extractor/videa.py 2020-11-25 21:05:29.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/videa.py 2020-11-29 07:52:00.000000000 +0100 @@ -1,16 +1,25 @@ # coding: utf-8 from __future__ import unicode_literals +import random import re +import string from .common import InfoExtractor from ..utils import ( + ExtractorError, int_or_none, mimetype2ext, parse_codecs, + update_url_query, xpath_element, xpath_text, ) +from ..compat import ( + compat_b64decode, + compat_ord, + compat_struct_pack, +) class VideaIE(InfoExtractor): @@ -19,7 +28,7 @@ videa(?:kid)?\.hu/ (?: videok/(?:[^/]+/)*[^?#&]+-| - player\?.*?\bv=| + (?:videojs_)?player\?.*?\bv=| player/v/ ) (?P<id>[^?#&]+) @@ -53,6 +62,7 @@ 'url': 'https://videakid.hu/player/v/8YfIAjxwWGwT8HVQ?autoplay=1', 'only_matching': True, }] + _STATIC_SECRET = 'xHb0ZvME5q8CBcoQi6AngerDu3FGO9fkUlwPmLVY_RTzj2hJIS4NasXWKy1td7p' @staticmethod def _extract_urls(webpage): @@ -60,26 +70,84 @@ r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//videa\.hu/player\?.*?\bv=.+?)\1', webpage)] - def _real_extract(self, url): - video_id = self._match_id(url) + @staticmethod + def rc4(cipher_text, key): + res = b'' + + key_len = len(key) + S = list(range(256)) - info = self._download_xml( - 'http://videa.hu/videaplayer_get_xml.php', video_id, - query={'v': video_id}) + j = 0 + for i in range(256): + j = (j + S[i] + ord(key[i % key_len])) % 256 + S[i], S[j] = S[j], S[i] + + i = 0 + j = 0 + for m in range(len(cipher_text)): + i = (i + 1) % 256 + j = (j + S[i]) % 256 + S[i], S[j] = S[j], S[i] + k = S[(S[i] + S[j]) % 256] + res += compat_struct_pack('B', k ^ compat_ord(cipher_text[m])) - video = xpath_element(info, './/video', 'video', fatal=True) - sources = xpath_element(info, './/video_sources', 'sources', fatal=True) + return res.decode() + + def _real_extract(self, url): + video_id = self._match_id(url) + query = {'v': video_id} + player_page = self._download_webpage( + 'https://videa.hu/player', video_id, query=query) + + nonce = self._search_regex( + r'_xt\s*=\s*"([^"]+)"', player_page, 'nonce') + l = nonce[:32] + s = nonce[32:] + result = '' + for i in range(0, 32): + result += s[i - (self._STATIC_SECRET.index(l[i]) - 31)] + + random_seed = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8)) + query['_s'] = random_seed + query['_t'] = result[:16] + + b64_info, handle = self._download_webpage_handle( + 'http://videa.hu/videaplayer_get_xml.php', video_id, query=query) + if b64_info.startswith('<?xml'): + info = self._parse_xml(b64_info, video_id) + else: + key = result[16:] + random_seed + handle.headers['x-videa-xs'] + info = self._parse_xml(self.rc4( + compat_b64decode(b64_info), key), video_id) + + video = xpath_element(info, './video', 'video') + if not video: + raise ExtractorError(xpath_element( + info, './error', fatal=True), expected=True) + sources = xpath_element( + info, './video_sources', 'sources', fatal=True) + hash_values = xpath_element( + info, './hash_values', 'hash values', fatal=True) title = xpath_text(video, './title', fatal=True) formats = [] for source in sources.findall('./video_source'): source_url = source.text - if not source_url: + source_name = source.get('name') + source_exp = source.get('exp') + if not (source_url and source_name and source_exp): continue + hash_value = xpath_text(hash_values, 'hash_value_' + source_name) + if not hash_value: + continue + source_url = update_url_query(source_url, { + 'md5': hash_value, + 'expires': source_exp, + }) f = parse_codecs(source.get('codecs')) f.update({ - 'url': source_url, + 'url': self._proto_relative_url(source_url), 'ext': mimetype2ext(source.get('mimetype')) or 'mp4', 'format_id': source.get('name'), 'width': int_or_none(source.get('width')), @@ -88,8 +156,7 @@ formats.append(f) self._sort_formats(formats) - thumbnail = xpath_text(video, './poster_src') - duration = int_or_none(xpath_text(video, './duration')) + thumbnail = self._proto_relative_url(xpath_text(video, './poster_src')) age_limit = None is_adult = xpath_text(video, './is_adult_content', default=None) @@ -100,7 +167,7 @@ 'id': video_id, 'title': title, 'thumbnail': thumbnail, - 'duration': duration, + 'duration': int_or_none(xpath_text(video, './duration')), 'age_limit': age_limit, 'formats': formats, } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/extractor/youtube.py new/youtube-dl/youtube_dl/extractor/youtube.py --- old/youtube-dl/youtube_dl/extractor/youtube.py 2020-11-25 21:05:35.000000000 +0100 +++ new/youtube-dl/youtube_dl/extractor/youtube.py 2020-11-29 07:52:00.000000000 +0100 @@ -283,6 +283,7 @@ } _YT_INITIAL_DATA_RE = r'(?:window\s*\[\s*["\']ytInitialData["\']\s*\]|ytInitialData)\s*=\s*({.+?})\s*;' + _YT_INITIAL_PLAYER_RESPONSE_RE = r'ytInitialPlayerResponse\s*=\s*({.+?})\s*;' def _call_api(self, ep, query, video_id): data = self._DEFAULT_API_DATA.copy() @@ -1068,7 +1069,10 @@ }, }, { - # with '};' inside yt initial data (see https://github.com/ytdl-org/youtube-dl/issues/27093) + # with '};' inside yt initial data (see [1]) + # see [2] for an example with '};' inside ytInitialPlayerResponse + # 1. https://github.com/ytdl-org/youtube-dl/issues/27093 + # 2. https://github.com/ytdl-org/youtube-dl/issues/27216 'url': 'https://www.youtube.com/watch?v=CHqg6qOn4no', 'info_dict': { 'id': 'CHqg6qOn4no', @@ -1686,7 +1690,8 @@ if not video_info and not player_response: player_response = extract_player_response( self._search_regex( - r'ytInitialPlayerResponse\s*=\s*({.+?})\s*;', video_webpage, + (r'%s\s*(?:var\s+meta|</script|\n)' % self._YT_INITIAL_PLAYER_RESPONSE_RE, + self._YT_INITIAL_PLAYER_RESPONSE_RE), video_webpage, 'initial player response', default='{}'), video_id) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/youtube-dl/youtube_dl/version.py new/youtube-dl/youtube_dl/version.py --- old/youtube-dl/youtube_dl/version.py 2020-11-25 21:05:47.000000000 +0100 +++ new/youtube-dl/youtube_dl/version.py 2020-11-29 07:52:58.000000000 +0100 @@ -1,3 +1,3 @@ from __future__ import unicode_literals -__version__ = '2020.11.26' +__version__ = '2020.11.29'