From 2c77d4d3a33ed4b5556efeaf0695848392644d52 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 9 Nov 2020 18:49:13 +0100 Subject: [PATCH 01/28] extend-playlist-support Support playlists for other websites than youtube. - Rework the downloader interface to only expose metadata downloading capabilities instead of leaking implementation details. --- OpenCast/domain/service/source.py | 15 +++++--- OpenCast/infra/media/downloader.py | 42 ++++++---------------- test/integration/app/service/test_video.py | 8 ++--- test/unit/domain/service/test_source.py | 24 +++++++++---- 4 files changed, 42 insertions(+), 47 deletions(-) diff --git a/OpenCast/domain/service/source.py b/OpenCast/domain/service/source.py index c01f94a1..b225e112 100644 --- a/OpenCast/domain/service/source.py +++ b/OpenCast/domain/service/source.py @@ -19,13 +19,20 @@ def __init__(self, downloader, video_parser): } def is_playlist(self, source): - return "/playlist" in source + data = self._downloader.download_metadata(source, process_ie_data=False) + return data.get("_type", None) == "playlist" def unfold(self, source): - return self._downloader.unfold_playlist(source) + self._logger.info("Unfolding playlist", url=source) + data = self._downloader.download_metadata(source, process_ie_data=True) + if data is None: + return [] + + entries = data.get("entries", []) + return [entry["webpage_url"] for entry in entries if "webpage_url" in entry] def pick_stream_metadata(self, source: str): - data = self._downloader.pick_stream_metadata(source) + data = self._downloader.download_metadata(source, process_ie_data=True) if data is None: return None @@ -47,7 +54,7 @@ def pick_file_metadata(self, source: Path): return metadata def fetch_stream_link(self, source: str): - data = self._downloader.pick_stream_metadata(source) + data = self._downloader.download_metadata(source, process_ie_data=True) return data.get("url", None) def list_streams(self, video) -> List[Stream]: diff --git a/OpenCast/infra/media/downloader.py b/OpenCast/infra/media/downloader.py index 0db69a66..0bd29fc2 100644 --- a/OpenCast/infra/media/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -87,42 +87,20 @@ def download_subtitle(self, url: str, dest: str, lang: str, exts: List[str]): ) return None - def pick_stream_metadata(self, url): - self._logger.debug("Downloading stream metadata", url=url) + def download_metadata(self, url: str, process_ie_data: bool): + self._logger.debug("Downloading metadata", url=url) options = { - "noplaylist": True, + "noplaylist": True, # Allow getting the _type value set to URL when passing a playlist entry + "extract_flat": False, + "ignoreerrors": True, # Causes ydl to return None on error + "debug_printtraffic": self._log_debug, + "quiet": True, + "progress_hooks": [self._dl_logger.log_download_progress], } - return self._download_stream_metadata(url, options) - - def unfold_playlist(self, url): - self._logger.debug("Unfolding playlist", url=url) - options = { - "extract_flat": "in_playlist", - } - # Download the playlist data without downloading the videos. - data = self._download_stream_metadata(url, options) - if data is None: - return [] - - # NOTE(specific) youtube specific - base_url = url.split("/playlist", 1)[0] - urls = [base_url + "/watch?v=" + entry["id"] for entry in data["entries"]] - return urls - - def _download_stream_metadata(self, url, options): - self._logger.debug("Fetching metadata", url=url) - options.update( - { - "ignoreerrors": True, # Causes ydl to return None on error - "debug_printtraffic": self._log_debug, - "quiet": True, - "progress_hooks": [self._dl_logger.log_download_progress], - } - ) ydl = YoutubeDL(options) with ydl: try: - return ydl.extract_info(url, download=False) + return ydl.extract_info(url, download=False, process=process_ie_data) except Exception as e: - self._logger.error("Fetching metadata error", url=url, error=e) + self._logger.error("Downloading metadata error", url=url, error=e) return None diff --git a/test/integration/app/service/test_video.py b/test/integration/app/service/test_video.py index 3734803e..58d6a395 100644 --- a/test/integration/app/service/test_video.py +++ b/test/integration/app/service/test_video.py @@ -31,7 +31,7 @@ def test_create_video(self): "collection_name": "album", "thumbnail": "thumbnail_url", } - self.downloader.pick_stream_metadata.return_value = metadata + self.downloader.download_metadata.return_value = metadata self.evt_expecter.expect( VideoEvt.VideoCreated, video_id, source, **metadata @@ -64,7 +64,7 @@ def test_create_video_missing_metadata(self): video_id = IdentityService.id_video(source) metadata = None - self.downloader.pick_stream_metadata.return_value = metadata + self.downloader.download_metadata.return_value = metadata self.evt_expecter.expect(OperationError, "Can't fetch metadata").from_( Cmd.CreateVideo, video_id, source @@ -108,7 +108,7 @@ def test_retrieve_video_from_stream_success(self): metadata = { "url": "http://stream-url.m3u8", } - self.downloader.pick_stream_metadata.return_value = metadata + self.downloader.download_metadata.return_value = metadata output_dir = config["downloader.output_directory"] self.evt_expecter.expect( @@ -122,7 +122,7 @@ def test_retrieve_video_from_stream_error(self): ) metadata = {} - self.downloader.pick_stream_metadata.return_value = metadata + self.downloader.download_metadata.return_value = metadata output_dir = config["downloader.output_directory"] self.evt_expecter.expect( diff --git a/test/unit/domain/service/test_source.py b/test/unit/domain/service/test_source.py index 06b2396e..256fb78c 100644 --- a/test/unit/domain/service/test_source.py +++ b/test/unit/domain/service/test_source.py @@ -12,18 +12,28 @@ def setUp(self): self.video_parser = Mock() self.service = SourceService(self.downloader, self.video_parser) - def test_is_playlist(self): + def test_is_playlist_with_playlist(self): + self.downloader.download_metadata.return_value = { + "_type": "playlist", + } + self.assertTrue( self.service.is_playlist("https://www.youtube.com/playlist?list=id") ) + + def test_is_playlist_with_video(self): + self.downloader.download_metadata.return_value = { + "_type": "video", + } + self.assertFalse( self.service.is_playlist( - "https://www.youtube.com/watch?v=id&list=id&index=2" + "https://www.youtube.com/watch?v=ciXp&list=cCY&index=1" ) ) def test_pick_stream_metadata(self): - self.downloader.pick_stream_metadata.return_value = { + self.downloader.download_metadata.return_value = { "source_protocol": "http", "title": "test", "collection_name": "collection", @@ -39,7 +49,7 @@ def test_pick_stream_metadata(self): self.assertEqual(expected, metadata) def test_pick_stream_metadata_partial(self): - self.downloader.pick_stream_metadata.return_value = { + self.downloader.download_metadata.return_value = { "title": "test", } metadata = self.service.pick_stream_metadata("source") @@ -52,7 +62,7 @@ def test_pick_stream_metadata_partial(self): self.assertEqual(expected, metadata) def test_pick_stream_metadata_alternative_fields(self): - self.downloader.pick_stream_metadata.return_value = { + self.downloader.download_metadata.return_value = { "protocol": "http", "title": "test", "album": "album_name", @@ -78,14 +88,14 @@ def test_pick_file_metadata(self): self.assertEqual(expected, metadata) def test_fetch_stream_link(self): - self.downloader.pick_stream_metadata.return_value = { + self.downloader.download_metadata.return_value = { "url": "test_url", } url = self.service.fetch_stream_link("source") self.assertEqual("test_url", url) def test_fetch_stream_link_missing(self): - self.downloader.pick_stream_metadata.return_value = {} + self.downloader.download_metadata.return_value = {} url = self.service.fetch_stream_link("source") self.assertEqual(None, url) From e6cb76e59bf399df7adffdf98ac9180e43783b99 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 10:55:38 +0100 Subject: [PATCH 02/28] extend-playlist-support Provide tests for the media downloader. - Place the downloading Logger object alongside with the downloader. --- OpenCast/infra/media/download_logger.py | 55 -------------- OpenCast/infra/media/downloader.py | 59 +++++++++++++-- test/unit/infra/media/test_downloader.py | 92 ++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 62 deletions(-) delete mode 100644 OpenCast/infra/media/download_logger.py create mode 100644 test/unit/infra/media/test_downloader.py diff --git a/OpenCast/infra/media/download_logger.py b/OpenCast/infra/media/download_logger.py deleted file mode 100644 index 4f5f5917..00000000 --- a/OpenCast/infra/media/download_logger.py +++ /dev/null @@ -1,55 +0,0 @@ -""" Logger displaying download progress """ - -from hurry.filesize import alternative, size - - -class DownloadLogger: - def __init__(self, logger): - self._logger = logger - - def is_enabled_for(self, level): - return False - # return self._logger.isEnabledFor(level) - - def log_download_progress(self, d): - status = d.get("status", "N/A") - if status not in ["downloading", "error", "finished"]: - return - - getattr(self, f"_log_{status}")(d) - - def _log_downloading(self, d): - filename = d.get("filename", "unknown") - self._logger.info( - "Downloading", - filename=filename, - ratio=self._format_ratio(d), - speed=self._format_speed(d), - ) - - def _log_error(self, d): - filename = d.get("filename", "unknown") - self._logger.error("Error downloading", filename=filename, error=d) - - def _log_finished(self, d): - filename = d.get("filename", "unknown") - total = d.get("total_bytes", 0) - self._logger.info( - "Finished downloading", - filename=filename, - size=size(total, system=alternative), - ) - - def _format_ratio(self, d): - downloaded = d.get("downloaded_bytes", None) - total = d.get("total_bytes", None) - if downloaded is None or total is None: - return "N/A %" - - return "{0:.2f}%".format(100 * (downloaded / total)) - - def _format_speed(self, d): - speed = d.get("speed", 0) - if speed is None: - speed = 0 - return "{}/s".format(size(speed, system=alternative)) diff --git a/OpenCast/infra/media/downloader.py b/OpenCast/infra/media/downloader.py index 0bd29fc2..3aec658b 100644 --- a/OpenCast/infra/media/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -5,12 +5,60 @@ from typing import List import structlog +from hurry.filesize import alternative, size from youtube_dlc import YoutubeDL from youtube_dlc.utils import ISO639Utils +from OpenCast.infra import Id from OpenCast.infra.event.downloader import DownloadError, DownloadSuccess -from .download_logger import DownloadLogger + +class Logger: + def __init__(self, logger): + self._logger = logger + + def log_download_progress(self, d): + status = d.get("status", "N/A") + if status not in ["downloading", "error", "finished"]: + return + + getattr(self, f"_log_{status}")(d) + + def _log_downloading(self, d): + filename = d.get("filename", "unknown") + self._logger.info( + "Downloading", + filename=filename, + ratio=self._format_ratio(d), + speed=self._format_speed(d), + ) + + def _log_error(self, d): + filename = d.get("filename", "unknown") + self._logger.error("Error downloading", filename=filename, error=d) + + def _log_finished(self, d): + filename = d.get("filename", "unknown") + total = d.get("total_bytes", 0) + self._logger.info( + "Finished downloading", + filename=filename, + size=size(total, system=alternative), + ) + + def _format_ratio(self, d): + downloaded = d.get("downloaded_bytes", None) + total = d.get("total_bytes", None) + if downloaded is None or total is None: + return "N/A" + + return "{0:.2f}%".format(100 * (downloaded / total)) + + def _format_speed(self, d): + speed = d.get("speed", 0) + if speed is None: + speed = 0 + return "{}/s".format(size(speed, system=alternative)) class Downloader: @@ -18,16 +66,14 @@ def __init__(self, executor, evt_dispatcher): self._executor = executor self._evt_dispatcher = evt_dispatcher self._logger = structlog.get_logger(__name__) - self._dl_logger = DownloadLogger(self._logger) - self._log_debug = False # self._dl_logger.is_enabled_for(logging.DEBUG) + self._dl_logger = Logger(self._logger) - def download_video(self, op_id, source: str, dest: str): + def download_video(self, op_id: Id, source: str, dest: str): def impl(): self._logger.debug("Downloading", video=dest) options = { "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/" "bestvideo+bestaudio/best", - "debug_printtraffic": self._log_debug, "noplaylist": True, "merge_output_format": "mp4", "outtmpl": dest, @@ -53,7 +99,7 @@ def impl(): source=source, error=error, ) - self._evt_dispatcher.dispatch(DownloadError(op_id, str(error))) + self._evt_dispatcher.dispatch(DownloadError(op_id, error)) return self._logger.debug("Download success", video=dest) @@ -93,7 +139,6 @@ def download_metadata(self, url: str, process_ie_data: bool): "noplaylist": True, # Allow getting the _type value set to URL when passing a playlist entry "extract_flat": False, "ignoreerrors": True, # Causes ydl to return None on error - "debug_printtraffic": self._log_debug, "quiet": True, "progress_hooks": [self._dl_logger.log_download_progress], } diff --git a/test/unit/infra/media/test_downloader.py b/test/unit/infra/media/test_downloader.py new file mode 100644 index 00000000..a7a13a44 --- /dev/null +++ b/test/unit/infra/media/test_downloader.py @@ -0,0 +1,92 @@ +from test.util import TestCase +from unittest.mock import Mock, patch + +from OpenCast.domain.service.identity import IdentityService +from OpenCast.infra.media.downloader import Downloader, DownloadError, DownloadSuccess + + +class DownloaderTest(TestCase): + def setUp(self): + patcher = patch("OpenCast.infra.media.downloader.YoutubeDL") + self.addCleanup(patcher.stop) + ydl_cls_mock = patcher.start() + self.ydl = ydl_cls_mock.return_value + + def execute_handler(handler, *args): + handler(*args) + + self.executor = Mock() + self.executor.submit = Mock(side_effect=execute_handler) + self.dispatcher = Mock() + self.downloader = Downloader(self.executor, self.dispatcher) + + @patch("OpenCast.infra.media.downloader.Path") + def test_download_video(self, path_cls): + path_mock = path_cls.return_value + path_mock.exists.return_value = True + + op_id = IdentityService.random() + self.downloader.download_video(op_id, "url", "/tmp/media.mp4") + + self.dispatcher.dispatch.assert_called_with(DownloadSuccess(op_id)) + + def test_download_video_error(self): + self.ydl.download.side_effect = RuntimeError("error") + + op_id = IdentityService.random() + self.downloader.download_video(op_id, "url", "/tmp/media.mp4") + + self.dispatcher.dispatch.assert_called_with(DownloadError(op_id, "error")) + + @patch("OpenCast.infra.media.downloader.Path") + def test_download_video_missing(self, path_cls): + path_mock = path_cls.return_value + path_mock.exists.return_value = False + + op_id = IdentityService.random() + self.downloader.download_video(op_id, "url", "/tmp/media.mp4") + + self.dispatcher.dispatch.assert_called_with( + DownloadError(op_id, "video path points to non existent file") + ) + + def test_download_subtitle(self): + subtitle = "/tmp/media.en.vtt" + self.assertEqual( + subtitle, + self.downloader.download_subtitle( + "url", dest="/tmp/media", lang="eng", exts=["vtt"] + ), + ) + + def test_download_subtitle_second_choice(self): + step = 0 + + def raise_once(*args, **kwargs): + nonlocal step + + step += 1 + if step == 1: + raise RuntimeError() + + subtitle = "/tmp/media.en.srt" + self.ydl.download.side_effect = raise_once + self.assertEqual( + subtitle, + self.downloader.download_subtitle( + "url", dest="/tmp/media", lang="eng", exts=["vtt", "srt"] + ), + ) + + def test_download_metadata(self): + metadata = {"url": "url", "title": "title"} + self.ydl.extract_info.return_value = metadata + self.assertEqual( + metadata, self.downloader.download_metadata("url", process_ie_data=True) + ) + + def test_download_metadata_error(self): + self.ydl.extract_info.side_effect = RuntimeError() + self.assertEqual( + None, self.downloader.download_metadata("url", process_ie_data=True) + ) From b92ff319c16f44009d1fc3f19186003a9a12865e Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 11:16:17 +0100 Subject: [PATCH 03/28] extend-playlist-support Add tests for the downloading logger. --- OpenCast/infra/media/downloader.py | 6 +- test/unit/infra/media/test_downloader.py | 87 +++++++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/OpenCast/infra/media/downloader.py b/OpenCast/infra/media/downloader.py index 3aec658b..574fa24c 100644 --- a/OpenCast/infra/media/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -35,13 +35,13 @@ def _log_downloading(self, d): def _log_error(self, d): filename = d.get("filename", "unknown") - self._logger.error("Error downloading", filename=filename, error=d) + self._logger.error("Downloading error", filename=filename, error=d) def _log_finished(self, d): filename = d.get("filename", "unknown") total = d.get("total_bytes", 0) self._logger.info( - "Finished downloading", + "Downloading success", filename=filename, size=size(total, system=alternative), ) @@ -55,7 +55,7 @@ def _format_ratio(self, d): return "{0:.2f}%".format(100 * (downloaded / total)) def _format_speed(self, d): - speed = d.get("speed", 0) + speed = d.get("speed", None) if speed is None: speed = 0 return "{}/s".format(size(speed, system=alternative)) diff --git a/test/unit/infra/media/test_downloader.py b/test/unit/infra/media/test_downloader.py index a7a13a44..fce94e2b 100644 --- a/test/unit/infra/media/test_downloader.py +++ b/test/unit/infra/media/test_downloader.py @@ -2,7 +2,92 @@ from unittest.mock import Mock, patch from OpenCast.domain.service.identity import IdentityService -from OpenCast.infra.media.downloader import Downloader, DownloadError, DownloadSuccess +from OpenCast.infra.media.downloader import ( + Downloader, + DownloadError, + DownloadSuccess, + Logger, +) + + +class LoggerTest(TestCase): + def setUp(self): + self.impl = Mock() + self.logger = Logger(self.impl) + + def test_downloading_log_missing_status(self): + data = {} + self.logger.log_download_progress(data) + + self.impl.info.assert_not_called() + self.impl.error.assert_not_called() + + def test_downloading_log(self): + data = { + "status": "downloading", + "filename": "/tmp/media.mp4", + "downloaded_bytes": 50, + "total_bytes": 100, + "speed": 10, + } + self.logger.log_download_progress(data) + + self.impl.info.assert_called_with( + "Downloading", filename="/tmp/media.mp4", ratio="50.00%", speed="10 bytes/s" + ) + + def test_downloading_log_missing_data(self): + data = { + "status": "downloading", + } + self.logger.log_download_progress(data) + + self.impl.info.assert_called_with( + "Downloading", filename="unknown", ratio="N/A", speed="0 bytes/s" + ) + + def test_error_log(self): + data = { + "status": "error", + "filename": "/tmp/media.mp4", + } + self.logger.log_download_progress(data) + + self.impl.error.assert_called_with( + "Downloading error", filename="/tmp/media.mp4", error=data + ) + + def test_error_log_missing_data(self): + data = { + "status": "error", + } + self.logger.log_download_progress(data) + + self.impl.error.assert_called_with( + "Downloading error", filename="unknown", error=data + ) + + def test_finished_log(self): + data = { + "status": "finished", + "filename": "/tmp/media.mp4", + "total_bytes": 100, + } + self.logger.log_download_progress(data) + + self.impl.info.assert_called_with( + "Downloading success", filename="/tmp/media.mp4", size="100 bytes" + ) + + def test_finished_log_missing_data(self): + data = { + "status": "finished", + } + self.logger.log_download_progress(data) + + self.impl.info.assert_called_with( + "Downloading success", filename="unknown", size="0 bytes" + ) class DownloaderTest(TestCase): From 005f4e731c57842bf76c27963dd595c239ba9ea3 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 11:20:51 +0100 Subject: [PATCH 04/28] extend-playlist-support Fix crash on purging videos with None location. - Update related test. --- OpenCast/app/workflow/app.py | 3 ++- test/unit/app/workflow/test_app.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/OpenCast/app/workflow/app.py b/OpenCast/app/workflow/app.py index dc1aa5b5..2042ab5f 100644 --- a/OpenCast/app/workflow/app.py +++ b/OpenCast/app/workflow/app.py @@ -80,7 +80,8 @@ def on_enter_PURGING_VIDEOS(self, *_): self._missing_videos = [ video.id for video in videos - if not (video.streamable() or Path(video.location).exists()) + if video.location is None + or not (video.streamable() or Path(video.location).exists()) ] if not self._missing_videos: self.to_COMPLETED() diff --git a/test/unit/app/workflow/test_app.py b/test/unit/app/workflow/test_app.py index 0e0ded1b..510c68f3 100644 --- a/test/unit/app/workflow/test_app.py +++ b/test/unit/app/workflow/test_app.py @@ -134,7 +134,7 @@ def test_purging_videos_to_completed_with_deletion(self, path_cls_mock): video2 = Mock() video2.id = IdentityService.id_video("mock2") - video2.streamable.return_value = False + video2.location = None self.video_repo.list.return_value = [video1, video2] self.workflow.to_PURGING_VIDEOS() From 61ad6a2ab439a60086e1164ace92b72872fdf847 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 12:01:18 +0100 Subject: [PATCH 05/28] extend-playlist-support Fix linting error. --- OpenCast/infra/media/downloader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/OpenCast/infra/media/downloader.py b/OpenCast/infra/media/downloader.py index 574fa24c..759e3254 100644 --- a/OpenCast/infra/media/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -136,9 +136,11 @@ def download_subtitle(self, url: str, dest: str, lang: str, exts: List[str]): def download_metadata(self, url: str, process_ie_data: bool): self._logger.debug("Downloading metadata", url=url) options = { - "noplaylist": True, # Allow getting the _type value set to URL when passing a playlist entry + # Allow getting the _type value set to URL when passing a playlist entry + "noplaylist": True, "extract_flat": False, - "ignoreerrors": True, # Causes ydl to return None on error + # Causes ydl to return None on error + "ignoreerrors": True, "quiet": True, "progress_hooks": [self._dl_logger.log_download_progress], } From 4245b4d4e31829e627bafddeef74785ed1b27fa7 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 12:16:02 +0100 Subject: [PATCH 06/28] extend-playlist-support Add missing test case for subtitle downloading. --- test/unit/infra/media/test_downloader.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/unit/infra/media/test_downloader.py b/test/unit/infra/media/test_downloader.py index fce94e2b..08078e51 100644 --- a/test/unit/infra/media/test_downloader.py +++ b/test/unit/infra/media/test_downloader.py @@ -144,6 +144,15 @@ def test_download_subtitle(self): ), ) + def test_download_subtitle_not_found(self): + self.ydl.download.side_effect = RuntimeError() + self.assertEqual( + None, + self.downloader.download_subtitle( + "url", dest="/tmp/media", lang="eng", exts=["vtt", "srt"] + ), + ) + def test_download_subtitle_second_choice(self): step = 0 From 69a9013009526bb479b5a8a516b3c5620263eb4c Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 12:17:12 +0100 Subject: [PATCH 07/28] extend-playlist-support Add missing tests for the unfold method. - Add missing type annotations, specially for return values --- OpenCast/domain/service/source.py | 12 +++++----- test/unit/domain/service/test_source.py | 31 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/OpenCast/domain/service/source.py b/OpenCast/domain/service/source.py index b225e112..07aaaacf 100644 --- a/OpenCast/domain/service/source.py +++ b/OpenCast/domain/service/source.py @@ -1,7 +1,7 @@ """ Media source operations """ from pathlib import Path -from typing import List +from typing import List, Optional import structlog @@ -18,11 +18,11 @@ def __init__(self, downloader, video_parser): "collection_name": ["album"], } - def is_playlist(self, source): + def is_playlist(self, source: str) -> bool: data = self._downloader.download_metadata(source, process_ie_data=False) return data.get("_type", None) == "playlist" - def unfold(self, source): + def unfold(self, source: str) -> List[str]: self._logger.info("Unfolding playlist", url=source) data = self._downloader.download_metadata(source, process_ie_data=True) if data is None: @@ -31,7 +31,7 @@ def unfold(self, source): entries = data.get("entries", []) return [entry["webpage_url"] for entry in entries if "webpage_url" in entry] - def pick_stream_metadata(self, source: str): + def pick_stream_metadata(self, source: str) -> Optional[dict]: data = self._downloader.download_metadata(source, process_ie_data=True) if data is None: return None @@ -48,12 +48,12 @@ def pick_stream_metadata(self, source: str): return metadata - def pick_file_metadata(self, source: Path): + def pick_file_metadata(self, source: Path) -> dict: metadata = {field: None for field in Video.METADATA_FIELDS} metadata["title"] = source.stem return metadata - def fetch_stream_link(self, source: str): + def fetch_stream_link(self, source: str) -> Optional[str]: data = self._downloader.download_metadata(source, process_ie_data=True) return data.get("url", None) diff --git a/test/unit/domain/service/test_source.py b/test/unit/domain/service/test_source.py index 256fb78c..e5b455c8 100644 --- a/test/unit/domain/service/test_source.py +++ b/test/unit/domain/service/test_source.py @@ -32,6 +32,37 @@ def test_is_playlist_with_video(self): ) ) + def test_unfold(self): + self.downloader.download_metadata.return_value = { + "entries": [ + {"webpage_url": "url1"}, + {"webpage_url": "url2"}, + ], + } + + self.assertEqual( + ["url1", "url2"], + self.service.unfold("https://www.youtube.com/playlist?list=id"), + ) + + def test_unfold_partial_metadata(self): + self.downloader.download_metadata.return_value = { + "entries": [], + } + + self.assertEqual( + [], + self.service.unfold("https://www.youtube.com/playlist?list=id"), + ) + + def test_unfold_no_metadata(self): + self.downloader.download_metadata.return_value = None + + self.assertEqual( + [], + self.service.unfold("https://www.youtube.com/playlist?list=id"), + ) + def test_pick_stream_metadata(self): self.downloader.download_metadata.return_value = { "source_protocol": "http", From 952f9e3b78611d6aec07172cf464efd95679b4c6 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 20:19:23 +0100 Subject: [PATCH 08/28] develop Return an internal error when streaming/queueing fails because the playlist URL can't be unfolded. --- OpenCast/app/controller/monitor.py | 6 +++ OpenCast/app/controller/player_monitor.py | 6 +++ .../app/controller/test_player_monitor.py | 40 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/OpenCast/app/controller/monitor.py b/OpenCast/app/controller/monitor.py index ce7cccbc..19b7cbec 100644 --- a/OpenCast/app/controller/monitor.py +++ b/OpenCast/app/controller/monitor.py @@ -47,6 +47,12 @@ def _bad_request(self, message: str = None, details: dict = None): def _not_found(self): return self._make_response(404, None) + def _internal_error(self, message: str = None, details: dict = None): + body = None + if message is not None: + body = {"error": {"message": message, "detail": details}} + return self._make_response(500, body) + def _make_response(self, status, body): return self._server.make_json_response(status, body, self._model_dumps) diff --git a/OpenCast/app/controller/player_monitor.py b/OpenCast/app/controller/player_monitor.py index c696d5d1..16ae5db8 100755 --- a/OpenCast/app/controller/player_monitor.py +++ b/OpenCast/app/controller/player_monitor.py @@ -57,6 +57,9 @@ async def stream(self, req): if self._source_service.is_playlist(source): sources = self._source_service.unfold(source) + if not sources: + return self._internal_error("Could not unfold the playlist URL") + videos = [ Video(IdentityService.id_video(source), source) for source in sources ] @@ -78,6 +81,9 @@ async def queue(self, req): if self._source_service.is_playlist(source): sources = self._source_service.unfold(source) + if not sources: + return self._internal_error("Could not unfold the playlist URL") + videos = [ Video(IdentityService.id_video(source), source) for source in sources ] diff --git a/test/unit/app/controller/test_player_monitor.py b/test/unit/app/controller/test_player_monitor.py index 9845ced1..d02c526d 100644 --- a/test/unit/app/controller/test_player_monitor.py +++ b/test/unit/app/controller/test_player_monitor.py @@ -73,6 +73,26 @@ def make_workflow(*args, **kwargs): self.assertEqual(resp, (200, None)) self.app_facade.workflow_manager.start.assert_called_with(workflow) + async def test_stream_playlist_non_unfoldable(self): + url = "http://video-provider/watch&video=id" + req = self.make_request("POST", "/stream", query={"url": url}) + self.source_service.is_playlist.return_value = True + self.source_service.unfold.return_value = [] + + resp = await self.route(self.controller.stream, req) + self.assertEqual( + resp, + ( + 500, + { + "error": { + "detail": None, + "message": "Could not unfold the playlist URL", + } + }, + ), + ) + async def test_queue_simple(self): url = "http://video-provider/watch&video=id" req = self.make_request("POST", "/queue", query={"url": url}) @@ -110,6 +130,26 @@ def make_workflow(*args, **kwargs): self.assertEqual(resp, (200, None)) self.app_facade.workflow_manager.start.assert_called_with(workflow) + async def test_queue_playlist_non_unfoldable(self): + url = "http://video-provider/watch&video=id" + req = self.make_request("POST", "/queue", query={"url": url}) + self.source_service.is_playlist.return_value = True + self.source_service.unfold.return_value = [] + + resp = await self.route(self.controller.stream, req) + self.assertEqual( + resp, + ( + 500, + { + "error": { + "detail": None, + "message": "Could not unfold the playlist URL", + } + }, + ), + ) + async def test_play(self): self.data_producer.video("source").populate(self.data_facade) video_id = IdentityService.id_video("source") From 3c8046867cf2f6c45be21b5d06487d5efac76090 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 15:08:26 +0100 Subject: [PATCH 09/28] config-cleanup Remove unused config entries. --- OpenCast/config.py | 2 -- config.yml | 4 ---- 2 files changed, 6 deletions(-) diff --git a/OpenCast/config.py b/OpenCast/config.py index 0a4d75ab..b055ce9d 100644 --- a/OpenCast/config.py +++ b/OpenCast/config.py @@ -172,9 +172,7 @@ def _override_from_env(self, content: dict, env: dict, key: list): "file": "opencast.db", }, "player": { - "hide_background": True, "loop_last": True, - "history_size": 15 }, "downloader": { "output_directory": "/tmp", diff --git a/config.yml b/config.yml index 29de325e..bd90ca19 100644 --- a/config.yml +++ b/config.yml @@ -13,12 +13,8 @@ database: file: opencast.db player: - # Fill the background with black to not see what's behind the player. - hide_background: True # Loop the last video until a new one becomes available. loop_last: True - # The maximum number of files on disk. - history_size: 15 downloader: # The directory used to store downloaded videos. From b4167cb470d7c6fc5ec1adc063fa9c27a45e129a Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 10 Nov 2020 16:13:58 +0100 Subject: [PATCH 10/28] config-cleanup Add a subtitle.enabled config entry. - Rename FINALISING video workflow state to SUB_FETCHING. --- OpenCast/app/workflow/video.py | 30 +++++++++++++++------------ OpenCast/config.py | 1 + config.yml | 2 ++ test/unit/app/workflow/test_video.py | 31 +++++++++++++++++++++------- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/OpenCast/app/workflow/video.py b/OpenCast/app/workflow/video.py index fa05b0ba..5f12afda 100644 --- a/OpenCast/app/workflow/video.py +++ b/OpenCast/app/workflow/video.py @@ -33,24 +33,25 @@ class States(Enum): CREATING = auto() RETRIEVING = auto() PARSING = auto() - FINALISING = auto() + SUB_RETRIEVING = auto() COMPLETED = auto() DELETING = auto() ABORTED = auto() # Trigger - Source - Dest - Conditions - Unless - Before - After - Prepare transitions = [ - ["_create", States.INITIAL, States.COMPLETED, "is_complete"], # noqa: E501 - ["_create", States.INITIAL, States.CREATING], - ["_video_created", States.CREATING, States.RETRIEVING], - ["_video_retrieved", States.RETRIEVING, States.COMPLETED, "is_stream"], # noqa: E501 - ["_video_retrieved", States.RETRIEVING, States.PARSING], - ["_video_parsed", States.PARSING, States.FINALISING], - ["_video_subtitle_fetched", States.FINALISING, States.COMPLETED], - - ["_operation_error", States.CREATING, States.ABORTED], - ["_operation_error", '*', States.DELETING], - ["_video_deleted", States.DELETING, States.ABORTED], + ["_create", States.INITIAL, States.COMPLETED, "is_complete"], # noqa: E501 + ["_create", States.INITIAL, States.CREATING], + ["_video_created", States.CREATING, States.RETRIEVING], + ["_video_retrieved", States.RETRIEVING, States.COMPLETED, "is_stream"], # noqa: E501 + ["_video_retrieved", States.RETRIEVING, States.PARSING], + ["_video_parsed", States.PARSING, States.COMPLETED, "subtitle_disabled"], # noqa: E501 + ["_video_parsed", States.PARSING, States.SUB_RETRIEVING], + ["_video_subtitle_fetched", States.SUB_RETRIEVING, States.COMPLETED], + + ["_operation_error", States.CREATING, States.ABORTED], + ["_operation_error", '*', States.DELETING], + ["_video_deleted", States.DELETING, States.ABORTED], ] # fmt: on @@ -90,7 +91,7 @@ def on_enter_PARSING(self, _): self._video.id, ) - def on_enter_FINALISING(self, _): + def on_enter_SUB_RETRIEVING(self, _): self._observe_dispatch( VideoEvt.VideoSubtitleFetched, Cmd.FetchVideoSubtitle, @@ -113,3 +114,6 @@ def is_complete(self): def is_stream(self, _): return self._video_repo.get(self._video.id).streamable() + + def subtitle_disabled(self, _): + return not config["subtitle.enabled"] diff --git a/OpenCast/config.py b/OpenCast/config.py index b055ce9d..4368ce04 100644 --- a/OpenCast/config.py +++ b/OpenCast/config.py @@ -179,6 +179,7 @@ def _override_from_env(self, content: dict, env: dict, key: list): "max_concurrency": 3 }, "subtitle": { + "enabled": True, "language": "eng" } }, check_env=True) diff --git a/config.yml b/config.yml index bd90ca19..36271ec2 100644 --- a/config.yml +++ b/config.yml @@ -23,5 +23,7 @@ downloader: max_concurrency: 3 subtitle: + # The flag to enable/disable subtitle retrieval + enabled: True # The default language for subtitles. language: eng diff --git a/test/unit/app/workflow/test_video.py b/test/unit/app/workflow/test_video.py index 4f17f452..e1143918 100644 --- a/test/unit/app/workflow/test_video.py +++ b/test/unit/app/workflow/test_video.py @@ -16,6 +16,10 @@ def setUp(self): self.video = Video(IdentityService.id_video("source"), "source") self.workflow = self.make_workflow(VideoWorkflow, self.video) + def tearDown(self): + # Reset modified config entries to their default value + config.load_from_dict({"subtitle": {"enabled": True}}) + def test_initial(self): self.assertTrue(self.workflow.is_INITIAL()) @@ -47,7 +51,7 @@ def test_creating_to_retrieving(self): "http", "title", "album", - "thumbnail" + "thumbnail", ) self.assertTrue(self.workflow.is_RETRIEVING()) @@ -103,7 +107,20 @@ def test_parsing_to_deleting(self): self.raise_error(cmd) self.assertTrue(self.workflow.is_DELETING()) - def test_parsing_to_finalising(self): + def test_parsing_to_completed(self): + config.load_from_dict({"subtitle": {"enabled": False}}) + event = Evt.VideoRetrieved(None, self.video.id, "/tmp") + self.workflow.to_PARSING(event) + cmd = self.expect_dispatch(Cmd.ParseVideo, self.video.id) + self.raise_event( + Evt.VideoParsed, + cmd.id, + self.video.id, + {}, + ) + self.assertTrue(self.workflow.is_COMPLETED()) + + def test_parsing_to_sub_fetching(self): event = Evt.VideoRetrieved(None, self.video.id, "/tmp") self.workflow.to_PARSING(event) cmd = self.expect_dispatch(Cmd.ParseVideo, self.video.id) @@ -113,20 +130,20 @@ def test_parsing_to_finalising(self): self.video.id, {}, ) - self.assertTrue(self.workflow.is_FINALISING()) + self.assertTrue(self.workflow.is_SUB_RETRIEVING()) - def test_finalising_to_deleting(self): + def test_sub_fetching_to_deleting(self): event = Evt.VideoParsed(None, self.video.id, {}) - self.workflow.to_FINALISING(event) + self.workflow.to_SUB_RETRIEVING(event) cmd = self.expect_dispatch( Cmd.FetchVideoSubtitle, self.video.id, config["subtitle.language"] ) self.raise_error(cmd) self.assertTrue(self.workflow.is_DELETING()) - def test_finalising_to_completed(self): + def test_sub_fetching_to_completed(self): event = Evt.VideoParsed(None, self.video.id, {}) - self.workflow.to_FINALISING(event) + self.workflow.to_SUB_RETRIEVING(event) cmd = self.expect_dispatch( Cmd.FetchVideoSubtitle, self.video.id, config["subtitle.language"] ) From a6715a61716dde9085a22449d64d09ab926bf88a Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Wed, 11 Nov 2020 16:11:37 +0100 Subject: [PATCH 11/28] develop Always check for None metadata. --- OpenCast/domain/service/source.py | 6 ++++++ test/unit/domain/service/test_source.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/OpenCast/domain/service/source.py b/OpenCast/domain/service/source.py index 07aaaacf..f6de0bef 100644 --- a/OpenCast/domain/service/source.py +++ b/OpenCast/domain/service/source.py @@ -20,6 +20,9 @@ def __init__(self, downloader, video_parser): def is_playlist(self, source: str) -> bool: data = self._downloader.download_metadata(source, process_ie_data=False) + if data is None: + return False + return data.get("_type", None) == "playlist" def unfold(self, source: str) -> List[str]: @@ -55,6 +58,9 @@ def pick_file_metadata(self, source: Path) -> dict: def fetch_stream_link(self, source: str) -> Optional[str]: data = self._downloader.download_metadata(source, process_ie_data=True) + if data is None: + return None + return data.get("url", None) def list_streams(self, video) -> List[Stream]: diff --git a/test/unit/domain/service/test_source.py b/test/unit/domain/service/test_source.py index e5b455c8..b5f12618 100644 --- a/test/unit/domain/service/test_source.py +++ b/test/unit/domain/service/test_source.py @@ -32,6 +32,15 @@ def test_is_playlist_with_video(self): ) ) + def test_is_playlist_no_metadata(self): + self.downloader.download_metadata.return_value = None + + self.assertFalse( + self.service.is_playlist( + "https://www.youtube.com/watch?v=ciXp&list=cCY&index=1" + ) + ) + def test_unfold(self): self.downloader.download_metadata.return_value = { "entries": [ @@ -108,6 +117,11 @@ def test_pick_stream_metadata_alternative_fields(self): } self.assertEqual(expected, metadata) + def test_pick_stream_metadata_no_metadata(self): + self.downloader.download_metadata.return_value = None + metadata = self.service.pick_stream_metadata("source") + self.assertEqual(None, metadata) + def test_pick_file_metadata(self): metadata = self.service.pick_file_metadata(Path("/tmp/video.mp4")) expected = { @@ -130,6 +144,11 @@ def test_fetch_stream_link_missing(self): url = self.service.fetch_stream_link("source") self.assertEqual(None, url) + def test_fetch_stream_link_no_metadata(self): + self.downloader.download_metadata.return_value = None + url = self.service.fetch_stream_link("source") + self.assertEqual(None, url) + def test_list_streams(self): video = Mock() video.Path = Path("/tmp/toto.mp4") From f81568323ddb07b83166cdf613ed1210d860cdb8 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Sun, 15 Nov 2020 12:15:54 +0100 Subject: [PATCH 12/28] install-npm-at-setup Install nvm at setup. - Use nvm to install node and npm. --- setup.sh | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/setup.sh b/setup.sh index ca4a5e77..a01f1647 100755 --- a/setup.sh +++ b/setup.sh @@ -19,7 +19,7 @@ source "$ROOT/script/logging.sh" # Install system dependencies. check_system_deps() { log_info "Checking system dependencies..." - local -a deps=("curl" "lsof" "python" "python3" "pip3" "node" "npm") + local -a deps=("curl" "lsof" "python" "python3" "pip3" "npm" "node") local status fail # Set flags to false by default [[ -z "${ARGS["--ci"]}" ]] && deps+=("ffmpeg" "vlc") @@ -28,8 +28,17 @@ check_system_deps() { for dep in "${deps[@]}"; do command -v "$dep" &>/dev/null status="$?" - [[ "$status" = "1" ]] && fail=true log_status "$dep" "$status" + + if [[ "$status" != "0" ]]; then + case "$dep" in + "npm" | "node") + install_nvm + status="$?" + ;; + esac + fi + [[ "$status" != "0" ]] && fail=true done if [[ "$fail" = true ]]; then @@ -38,6 +47,16 @@ check_system_deps() { fi } +# Install node version manager (nvm) +# Install node and npm using nvm +install_nvm() { + log_info "Installing nvm..." + curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install.sh" | PROFILE=~/.profile bash + source ~/.profile + # Install npm as well + nvm install node +} + # Install project dependencies. install_project_deps() { log_info "Installing project dependencies..." From d5d044266c00db7a33054c91557b2862ebe9b64b Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Sun, 15 Nov 2020 12:17:29 +0100 Subject: [PATCH 13/28] install-npm-at-setup Specify the stop command in the systemd service file. --- dist/opencast.service | 4 +++- setup.sh | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dist/opencast.service b/dist/opencast.service index 97fc08cb..b64b48a2 100644 --- a/dist/opencast.service +++ b/dist/opencast.service @@ -4,10 +4,12 @@ After=network.target [Service] Type=simple +User={ USER } RemainAfterExit=true TimeoutStartSec=infinity -User={ USER } +TimeoutStopSec=infinity ExecStart={ START_COMMAND } +ExecStop={ STOP_COMMAND } [Install] WantedBy=multi-user.target diff --git a/setup.sh b/setup.sh index a01f1647..f8c4692a 100755 --- a/setup.sh +++ b/setup.sh @@ -75,6 +75,7 @@ start_at_boot() { config="$ROOT/dist/$service_name.service" sed -i "s/{ USER }/$USER/g" "$config" sed -i "s#{ START_COMMAND }#$ROOT/$INTERNAL_NAME.sh service start#g" "$config" + sed -i "s#{ STOP_COMMAND }#$ROOT/$INTERNAL_NAME.sh service stop#g" "$config" sudo cp "$config" "$SYSTEMD_CONFIG_DIR" sudo systemctl daemon-reload sudo systemctl enable "$service_name" From 6edb50556a674d6af5fae7442822fb17b8e8ed14 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Sun, 15 Nov 2020 12:18:35 +0100 Subject: [PATCH 14/28] install-npm-at-setup Source the .profile file at the top level script (OpenCast.sh). - This allows always finding poetry and npm when using OpenCast.sh as entrypoint (as it should be). --- OpenCast.sh | 5 +++++ script/env.sh | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/OpenCast.sh b/OpenCast.sh index 156faf6b..a3c83423 100755 --- a/OpenCast.sh +++ b/OpenCast.sh @@ -12,6 +12,11 @@ ROOT="$(cd "$(dirname "$0")" && pwd)" +# Source profile file as poetry and nvm use it to modify the PATH +# This is likely to be done by the display manager, but not always (lightdm). +# shellcheck source=/dev/null +source ~/.profile + # shellcheck source=script/cli_builder.sh source "$ROOT/script/cli_builder.sh" diff --git a/script/env.sh b/script/env.sh index f9b71ecd..4e00294e 100755 --- a/script/env.sh +++ b/script/env.sh @@ -3,11 +3,6 @@ HERE="$(cd "$(dirname "${BASH_SOURCE:-0}")" && pwd)" ROOT="$(cd "$HERE/.." && pwd)" -# Source profile file as poetry use it to modify the PATH -# This is likely to be done by the display manager, but not always (lightdm). -# shellcheck source=/dev/null -source ~/.profile - penv() { if [[ "$(pwd)" == "$ROOT"* ]]; then poetry run "$@" From 825eb920fa955a3e742a99ba2ad32e2f57a3561c Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Sun, 15 Nov 2020 12:28:12 +0100 Subject: [PATCH 15/28] install-npm-at-setup Add missing shellcheck directive. --- setup.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.sh b/setup.sh index f8c4692a..9794af35 100755 --- a/setup.sh +++ b/setup.sh @@ -52,6 +52,7 @@ check_system_deps() { install_nvm() { log_info "Installing nvm..." curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install.sh" | PROFILE=~/.profile bash + # shellcheck source=/dev/null source ~/.profile # Install npm as well nvm install node From b86d1073d2269fbe24bbfdc98ee51fd0784ac9c0 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 15:48:26 +0100 Subject: [PATCH 16/28] improve-logging Rework usage of logging levels. --- OpenCast/app/command/dispatcher.py | 2 +- OpenCast/app/workflow/manager.py | 4 ++-- OpenCast/app/workflow/workflow.py | 2 +- OpenCast/domain/event/dispatcher.py | 2 +- OpenCast/domain/service/player.py | 2 +- OpenCast/infra/media/downloader.py | 9 ++++----- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/OpenCast/app/command/dispatcher.py b/OpenCast/app/command/dispatcher.py index dc9f7a80..d6e470c1 100644 --- a/OpenCast/app/command/dispatcher.py +++ b/OpenCast/app/command/dispatcher.py @@ -18,7 +18,7 @@ def observe(self, cmd_cls, handler): def dispatch(self, cmd): def impl(): - self._logger.debug(type(cmd).__name__, cmd=cmd) + self._logger.info(type(cmd).__name__, cmd=cmd) cmd_id = id(type(cmd)) if cmd_id in self._handlers_map: handlers = self._handlers_map[cmd_id] diff --git a/OpenCast/app/workflow/manager.py b/OpenCast/app/workflow/manager.py index 03526b0c..9648470d 100644 --- a/OpenCast/app/workflow/manager.py +++ b/OpenCast/app/workflow/manager.py @@ -26,7 +26,7 @@ def on_completion(_): with self._lock: if self.is_running(workflow.id): - self._logger.debug("workflow already active", workflow=workflow) + self._logger.info("workflow already active", workflow=workflow) return False self._workflow_ids.append(workflow.id) @@ -36,6 +36,6 @@ def on_completion(_): times=1, ) - self._logger.debug("Starting workflow", workflow=workflow) + self._logger.info("Starting workflow", workflow=workflow) workflow.start(*args, **kwargs) return True diff --git a/OpenCast/app/workflow/workflow.py b/OpenCast/app/workflow/workflow.py index 42bd8665..108577b2 100644 --- a/OpenCast/app/workflow/workflow.py +++ b/OpenCast/app/workflow/workflow.py @@ -91,7 +91,7 @@ def _event_handler(self, evt_cls): try: return getattr(self.__derived, handler_name) except AttributeError: - self._logger.error( + self._logger.critical( "Missing handler", handler=handler_name, cls=evt_cls.__name__ ) # Raise the exception as it is a developer error diff --git a/OpenCast/domain/event/dispatcher.py b/OpenCast/domain/event/dispatcher.py index e332597d..13ae9881 100644 --- a/OpenCast/domain/event/dispatcher.py +++ b/OpenCast/domain/event/dispatcher.py @@ -53,7 +53,7 @@ def remove_handler_links(evt_id, handler): self._evt_to_handler_ids[evt_hash].remove(handler_id) self._handler_map.pop(handler_id) - self._logger.debug(type(evt).__name__, evt=evt) + self._logger.info(type(evt).__name__, evt=evt) handlers = [] with self._lock: diff --git a/OpenCast/domain/service/player.py b/OpenCast/domain/service/player.py index 3a2494c3..257d27f3 100644 --- a/OpenCast/domain/service/player.py +++ b/OpenCast/domain/service/player.py @@ -43,7 +43,7 @@ def queue( def next_video(self, playlist_id: Id, video_id: Id) -> Id: playlist = self._playlist_repo.get(playlist_id) if video_id not in playlist.ids: - self._logger.error("unknown video", video=video_id, playlist=playlist) + self._logger.warning("unknown video", video=video_id, playlist=playlist) return None video_idx = playlist.ids.index(video_id) diff --git a/OpenCast/infra/media/downloader.py b/OpenCast/infra/media/downloader.py index 759e3254..48291ad8 100644 --- a/OpenCast/infra/media/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -26,7 +26,7 @@ def log_download_progress(self, d): def _log_downloading(self, d): filename = d.get("filename", "unknown") - self._logger.info( + self._logger.debug( "Downloading", filename=filename, ratio=self._format_ratio(d), @@ -40,7 +40,7 @@ def _log_error(self, d): def _log_finished(self, d): filename = d.get("filename", "unknown") total = d.get("total_bytes", 0) - self._logger.info( + self._logger.debug( "Downloading success", filename=filename, size=size(total, system=alternative), @@ -70,7 +70,7 @@ def __init__(self, executor, evt_dispatcher): def download_video(self, op_id: Id, source: str, dest: str): def impl(): - self._logger.debug("Downloading", video=dest) + self._logger.info("Downloading", video=dest) options = { "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/" "bestvideo+bestaudio/best", @@ -102,14 +102,13 @@ def impl(): self._evt_dispatcher.dispatch(DownloadError(op_id, error)) return - self._logger.debug("Download success", video=dest) self._evt_dispatcher.dispatch(DownloadSuccess(op_id)) self._logger.debug("Queing", video=dest) self._executor.submit(impl) def download_subtitle(self, url: str, dest: str, lang: str, exts: List[str]): - self._logger.debug("Downloading subtitle", subtitle=dest, lang=lang) + self._logger.info("Downloading subtitle", subtitle=dest, lang=lang) lang = ISO639Utils.long2short(lang) for ext in exts: From 0834fc9c093da18b8b8729682618d561160deb4d Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 16:42:51 +0100 Subject: [PATCH 17/28] improve-logging Improve aborted operation logging. --- OpenCast/app/service/service.py | 19 ++++++------------- OpenCast/app/service/video.py | 6 +++--- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/OpenCast/app/service/service.py b/OpenCast/app/service/service.py index 377a21c4..4050bcbf 100644 --- a/OpenCast/app/service/service.py +++ b/OpenCast/app/service/service.py @@ -45,13 +45,9 @@ def _dispatch_cmd_to_handler(self, cmd): self._logger.error("Repo error", cmd=cmd, error=e) try_count -= 1 except Exception as e: - self._logger.error( - "Operation error", - cmd=cmd, - error=e, - traceback=traceback.format_exc(), + self._abort_operation( + cmd.id, str(e), cmd=cmd, traceback=traceback.format_exc() ) - self._abort_operation(cmd.id, str(e)) return def _dispatch_evt_to_handler(self, evt): @@ -60,15 +56,12 @@ def _dispatch_evt_to_handler(self, evt): getattr(self, handler_name)(evt) return except Exception as e: - self._logger.error( - "Operation error", - evt=evt, - error=e, - traceback=traceback.format_exc(), + self._abort_operation( + evt.id, str(e), evt=evt, traceback=traceback.format_exc() ) - self._abort_operation(evt.id, str(e)) - def _abort_operation(self, cmd_id: Id, error: str): + def _abort_operation(self, cmd_id: Id, error: str, **logging_attrs): + self._logger.error(error, **logging_attrs) self._evt_dispatcher.dispatch(OperationError(cmd_id, error)) def _start_transaction(self, repo, cmd_id, impl, *args): diff --git a/OpenCast/app/service/video.py b/OpenCast/app/service/video.py index b2e92abc..b198f1f8 100644 --- a/OpenCast/app/service/video.py +++ b/OpenCast/app/service/video.py @@ -34,7 +34,7 @@ def impl(ctx, metadata): metadata = self._source_service.pick_stream_metadata(cmd.source) if metadata is None: - self._abort_operation(cmd.id, "Can't fetch metadata") + self._abort_operation(cmd.id, "Unavailable metadata", cmd=cmd) return self._start_transaction(self._video_repo, cmd.id, impl, metadata) @@ -66,7 +66,7 @@ def stream_fetched(ctx, video, link): if video.streamable(): link = self._source_service.fetch_stream_link(video.source) if link is None: - self._abort_operation(cmd.id, "Could not fetch the streaming URL") + self._abort_operation(cmd.id, "Unavailable stream URL", cmd=cmd) return self._start_transaction( @@ -82,7 +82,7 @@ def impl(ctx): self._start_transaction(self._video_repo, cmd.id, impl) def abort_operation(evt): - self._abort_operation(cmd.id, evt.error) + self._abort_operation(cmd.id, evt.error, cmd=cmd) video.location = str(Path(cmd.output_directory) / f"{video.title}.mp4") self._evt_dispatcher.observe_result( From 21c4d5a2dc7e6a49445cfa67114c04ce8e2170b3 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 17:43:25 +0100 Subject: [PATCH 18/28] improve-logging Add debug log for monitoring API request/responses. --- OpenCast/infra/io/server.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/OpenCast/infra/io/server.py b/OpenCast/infra/io/server.py index ff64fb23..6c3a875c 100644 --- a/OpenCast/infra/io/server.py +++ b/OpenCast/infra/io/server.py @@ -2,9 +2,24 @@ import structlog from aiohttp import web +from aiohttp.abc import AbstractAccessLogger from aiohttp_middlewares import cors_middleware +class AccessLogger(AbstractAccessLogger): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._logger = structlog.get_logger(__name__) + + def log(self, request, response, time): + self._logger.debug( + f"{response.status}", + method=request.method, + path=request.path, + duration=f"{0:.3f}s".format(time), + ) + + class Server: def __init__(self, app): self._app = app @@ -18,7 +33,7 @@ def route(self, method, route, handle): def start(self, host, port): self._logger.info("Started", host=host, port=port) - web.run_app(self._app, host=host, port=port) + web.run_app(self._app, host=host, port=port, access_log_class=AccessLogger) def make_web_socket(self): ws = web.WebSocketResponse() From 4d0ee0405529d0d6fcf6fb1bc08ab3e2a0a67cdb Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 17:46:34 +0100 Subject: [PATCH 19/28] improve-logging Fix mismatching exception expectations. --- test/integration/app/service/test_video.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/app/service/test_video.py b/test/integration/app/service/test_video.py index 58d6a395..19a59572 100644 --- a/test/integration/app/service/test_video.py +++ b/test/integration/app/service/test_video.py @@ -66,7 +66,7 @@ def test_create_video_missing_metadata(self): metadata = None self.downloader.download_metadata.return_value = metadata - self.evt_expecter.expect(OperationError, "Can't fetch metadata").from_( + self.evt_expecter.expect(OperationError, "Unavailable metadata").from_( Cmd.CreateVideo, video_id, source ) @@ -125,9 +125,9 @@ def test_retrieve_video_from_stream_error(self): self.downloader.download_metadata.return_value = metadata output_dir = config["downloader.output_directory"] - self.evt_expecter.expect( - OperationError, "Could not fetch the streaming URL" - ).from_(Cmd.RetrieveVideo, video_id, output_dir) + self.evt_expecter.expect(OperationError, "Unavailable stream URL").from_( + Cmd.RetrieveVideo, video_id, output_dir + ) def test_retrieve_video_download_success(self): video_id = IdentityService.id_video("source") From fcb12d557244142f5112f524724c14a8e9b5f442 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 20:09:50 +0100 Subject: [PATCH 20/28] improve-logging Correct tests for loading the configuration from a file. - Rename import config in init to conf to be able to patch objects from the config package. --- OpenCast/__init__.py | 13 ++++---- OpenCast/config.py | 64 +++++++++++++++++++++------------------- test/unit/test_config.py | 51 +++++++++++++++++++++++++------- 3 files changed, 82 insertions(+), 46 deletions(-) diff --git a/OpenCast/__init__.py b/OpenCast/__init__.py index 870597e8..571f17ea 100644 --- a/OpenCast/__init__.py +++ b/OpenCast/__init__.py @@ -13,7 +13,8 @@ from .app.facade import AppFacade from .app.service.module import ServiceModule from .app.tool.json_encoder import ModelEncoder -from .config import ConfigError, config +from .config import ConfigError +from .config import config as conf from .domain.service.factory import ServiceFactory from .domain.service.identity import IdentityService from .infra.data.manager import DataManager, StorageType @@ -27,7 +28,7 @@ def run_server(logger, infra_facade): try: - infra_facade.server.start(config["server.host"], config["server.port"]) + infra_facade.server.start(conf["server.host"], conf["server.port"]) except Exception as e: logger.error( "Server exception caught", error=e, traceback=traceback.format_exc() @@ -58,12 +59,12 @@ def main(argv=None): init_logging(__name__) try: - config.load_from_file("{}/config.yml".format(app_path)) + conf.load_from_file("{}/config.yml".format(app_path)) except ConfigError: return # Get and update the log level - logging.getLogger(__name__).setLevel(config["log.level"]) + logging.getLogger(__name__).setLevel(conf["log.level"]) logger = structlog.get_logger(__name__) # TODO: make worker count configurable @@ -77,7 +78,7 @@ def main(argv=None): data_manager = DataManager(repo_factory) data_facade = data_manager.connect( StorageType.JSON, - path=config["database.file"], + path=conf["database.file"], indent=4, separators=(",", ": "), cls=ModelEncoder, @@ -85,7 +86,7 @@ def main(argv=None): io_factory = IoFactory() media_factory = MediaFactory( - VlcInstance(), ThreadPoolExecutor(config["downloader.max_concurrency"]) + VlcInstance(), ThreadPoolExecutor(conf["downloader.max_concurrency"]) ) infra_facade = InfraFacade(io_factory, media_factory, infra_service_factory) diff --git a/OpenCast/config.py b/OpenCast/config.py index 4368ce04..a6e68d32 100644 --- a/OpenCast/config.py +++ b/OpenCast/config.py @@ -7,7 +7,34 @@ from os import environ import structlog -import yaml +from yaml import YAMLError +from yaml import safe_load as yaml_safe_load + +# fmt: off +DEFAULT_CONFIG={ + "log": { + "level": "INFO" + }, + "server": { + "host": "0.0.0.0", + "port": 2020 + }, + "database": { + "file": "opencast.db", + }, + "player": { + "loop_last": True, + }, + "downloader": { + "output_directory": "/tmp", + "max_concurrency": 3 + }, + "subtitle": { + "enabled": True, + "language": "eng" + } +} +# fmt: on class ConfigError(Exception): @@ -90,18 +117,19 @@ def load_from_file(self, path: str): """ try: stream = open(path, "r") - except OSError as e: + except Exception as e: self._logger.error("Can't open the configuration", error=e) raise ConfigError("Can't open the configuration") from e with stream: try: - content = yaml.safe_load(stream) - self.load_from_dict(content) - except yaml.YAMLError as e: + content = yaml_safe_load(stream) + except YAMLError as e: self._logger.error("invalid file", error=e) raise ConfigError("Can't load the file's content") from e + self.load_from_dict(content) + def load_from_dict(self, content: dict): """Loads the configuration from a dict. @@ -159,28 +187,4 @@ def _override_from_env(self, content: dict, env: dict, key: list): content[k] = type(content[k])(env[env_key]) -# fmt: off -config = Config({ - "log": { - "level": "INFO" - }, - "server": { - "host": "0.0.0.0", - "port": 2020 - }, - "database": { - "file": "opencast.db", - }, - "player": { - "loop_last": True, - }, - "downloader": { - "output_directory": "/tmp", - "max_concurrency": 3 - }, - "subtitle": { - "enabled": True, - "language": "eng" - } -}, check_env=True) -# fmt: on +config = Config(DEFAULT_CONFIG, check_env=True) diff --git a/test/unit/test_config.py b/test/unit/test_config.py index 116affc5..0b5a00e9 100644 --- a/test/unit/test_config.py +++ b/test/unit/test_config.py @@ -1,5 +1,8 @@ from os import environ from test.util import TestCase +from unittest.mock import Mock, mock_open, patch + +from yaml import YAMLError from OpenCast.config import Config, ConfigContentError, ConfigError @@ -23,12 +26,12 @@ def test_override_from_env(self): config = Config({"a": 1}, check_env=True) self.assertEqual(2, config["a"]) - def test_load_from_dict_override(self): + def test_load_from_dict(self): config = Config({"a": 1}) config.load_from_dict({"a": 2}) self.assertEqual(2, config["a"]) - def test_load_from_dict_nested_override(self): + def test_load_from_dict_nested(self): config = Config({"a": {"b": 1, "c": 2}}) config.load_from_dict({"a": {"b": 3}}) expected = Config({"a": {"b": 3, "c": 2}}) @@ -63,12 +66,40 @@ def test_load_from_dict_invalid_value_type(self): ctx.exception.errors, ) - def test_load_from_file_is_directory(self): - config = Config(None) - with self.assertRaises(ConfigError): - config.load_from_file("/tmp") + def test_load_from_file(self): + config = Config({"a": 0}) + + mock = mock_open(read_data="a: 1") + with patch("builtins.open", mock): + config.load_from_file("file") + + self.assertEqual(1, config["a"]) - def test_load_from_file_missing(self): - config = Config(None) - with self.assertRaises(ConfigError): - config.load_from_file("/foo/bar") + def test_load_from_file_open_error(self): + config = Config({}) + + with self.assertRaises(ConfigError) as ctx: + mock = mock_open() + with patch("builtins.open", mock): + mock.side_effect = OSError + config.load_from_file("file") + + self.assertEqual( + "Can't open the configuration", + str(ctx.exception), + ) + + @patch("OpenCast.config.yaml_safe_load") + def test_load_from_file_format_error(self, yaml_loader_mock): + config = Config({}) + + with self.assertRaises(ConfigError) as ctx: + mock = mock_open(read_data="{a = 1}") + with patch("builtins.open", mock): + yaml_loader_mock.side_effect = YAMLError + config.load_from_file("file") + + self.assertEqual( + "Can't load the file's content", + str(ctx.exception), + ) From 148706db42f08a56029541b413a477d156973ebf Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 20:19:05 +0100 Subject: [PATCH 21/28] improve-logging Expose override_from_env publicly + add tests. - improve the method's interface. --- OpenCast/config.py | 31 ++++++++++++++++++++----------- test/unit/test_config.py | 10 ++++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/OpenCast/config.py b/OpenCast/config.py index a6e68d32..38a02849 100644 --- a/OpenCast/config.py +++ b/OpenCast/config.py @@ -81,7 +81,7 @@ def __init__(self, content: dict, check_env=False): self._logger = structlog.get_logger(__name__) self._content = content if check_env: - self._override_from_env(self._content, environ, ["OpenCast"]) + self.override_from_env(environ, prefix="OPENCAST") def __getitem__(self, key: str): """Access a configuration by keys @@ -145,6 +145,25 @@ def load_from_dict(self, content: dict): self._update_content(self._content, content) + def override_from_env(self, env: dict, prefix: str): + """Override the existing configuration with values from the environment. + + Args: + env: A dictionary representing the environment variables. + prefix: The prefix identifying the environment variables. + """ + + def override_content(content, prefix): + for k, v in content.items(): + env_key = f"{prefix}_{k}".upper() + if type(v) is dict: + override_content(content[k], env_key) + return + if env_key in env: + content[k] = type(content[k])(env[env_key]) + + override_content(self._content, prefix) + def _update_content(self, content, updates): for k, v in updates.items(): if isinstance(v, collections.abc.Mapping): @@ -176,15 +195,5 @@ def _validate_input(self, source, dest, path): return errors - def _override_from_env(self, content: dict, env: dict, key: list): - for k, v in content.items(): - env_keys = [*key, k] - if type(v) is dict: - self._override_from_env(content[k], env, env_keys) - return - env_key = "_".join(env_keys).upper() - if env_key in env: - content[k] = type(content[k])(env[env_key]) - config = Config(DEFAULT_CONFIG, check_env=True) diff --git a/test/unit/test_config.py b/test/unit/test_config.py index 0b5a00e9..ad04bb99 100644 --- a/test/unit/test_config.py +++ b/test/unit/test_config.py @@ -103,3 +103,13 @@ def test_load_from_file_format_error(self, yaml_loader_mock): "Can't load the file's content", str(ctx.exception), ) + + def test_override_from_env_simple(self): + config = Config({"a": 1}) + config.override_from_env({"TEST_A": 0}, prefix="TEST") + self.assertEqual(0, config["a"]) + + def test_override_from_env_nested(self): + config = Config({"a": {"b": 1}}) + config.override_from_env({"TEST_A_B": 0}, prefix="TEST") + self.assertEqual(0, config["a.b"]) From 01fa65453658222ad5b28bcb9e6f61c9c194a492 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 20:20:46 +0100 Subject: [PATCH 22/28] improve-logging Set the default log level to CRITICAL for tests. - Made explicit the possibility to change the level using environment overrides. --- test/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/__init__.py b/test/__init__.py index 3afaee2f..a3aebb97 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -1,3 +1,13 @@ +import logging +from os import environ + +from OpenCast.config import config from OpenCast.infra.log.module import init as init_logging +# Default the log level to critical for tests. +# Override OPENCAST_LOG_LEVEL to change its value. +config.load_from_dict({"log": {"level": "CRITICAL"}}) +config.override_from_env(environ, prefix="OPENCAST") + init_logging("OpenCast") +logging.getLogger("OpenCast").setLevel(config["log.level"]) From a09a0f49d5087a93acefafde83120334890c748b Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Mon, 16 Nov 2020 20:48:28 +0100 Subject: [PATCH 23/28] improve-logging Fix downloading logger expectations. - Fix linter errors. --- OpenCast/config.py | 2 +- test/unit/infra/media/test_downloader.py | 10 +++++----- test/unit/test_config.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/OpenCast/config.py b/OpenCast/config.py index 38a02849..1f14372d 100644 --- a/OpenCast/config.py +++ b/OpenCast/config.py @@ -11,7 +11,7 @@ from yaml import safe_load as yaml_safe_load # fmt: off -DEFAULT_CONFIG={ +DEFAULT_CONFIG = { "log": { "level": "INFO" }, diff --git a/test/unit/infra/media/test_downloader.py b/test/unit/infra/media/test_downloader.py index 08078e51..acfbc93e 100644 --- a/test/unit/infra/media/test_downloader.py +++ b/test/unit/infra/media/test_downloader.py @@ -19,7 +19,7 @@ def test_downloading_log_missing_status(self): data = {} self.logger.log_download_progress(data) - self.impl.info.assert_not_called() + self.impl.debug.assert_not_called() self.impl.error.assert_not_called() def test_downloading_log(self): @@ -32,7 +32,7 @@ def test_downloading_log(self): } self.logger.log_download_progress(data) - self.impl.info.assert_called_with( + self.impl.debug.assert_called_with( "Downloading", filename="/tmp/media.mp4", ratio="50.00%", speed="10 bytes/s" ) @@ -42,7 +42,7 @@ def test_downloading_log_missing_data(self): } self.logger.log_download_progress(data) - self.impl.info.assert_called_with( + self.impl.debug.assert_called_with( "Downloading", filename="unknown", ratio="N/A", speed="0 bytes/s" ) @@ -75,7 +75,7 @@ def test_finished_log(self): } self.logger.log_download_progress(data) - self.impl.info.assert_called_with( + self.impl.debug.assert_called_with( "Downloading success", filename="/tmp/media.mp4", size="100 bytes" ) @@ -85,7 +85,7 @@ def test_finished_log_missing_data(self): } self.logger.log_download_progress(data) - self.impl.info.assert_called_with( + self.impl.debug.assert_called_with( "Downloading success", filename="unknown", size="0 bytes" ) diff --git a/test/unit/test_config.py b/test/unit/test_config.py index ad04bb99..c40172ff 100644 --- a/test/unit/test_config.py +++ b/test/unit/test_config.py @@ -1,6 +1,6 @@ from os import environ from test.util import TestCase -from unittest.mock import Mock, mock_open, patch +from unittest.mock import mock_open, patch from yaml import YAMLError From 5d7a783663fce525c041d79d9ae3b1b2199af6ba Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 17 Nov 2020 09:38:42 +0100 Subject: [PATCH 24/28] improve-logging Add test case to cover management of duplicate workflows. --- test/unit/app/workflow/test_manager.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/unit/app/workflow/test_manager.py b/test/unit/app/workflow/test_manager.py index 3f6ffa98..4aad2368 100644 --- a/test/unit/app/workflow/test_manager.py +++ b/test/unit/app/workflow/test_manager.py @@ -21,7 +21,7 @@ def setUp(self): def test_is_running_none(self): self.assertFalse(self.manager.is_running(self.queue_workflow_id)) - def test_is_running_one(self): + def test_start(self): self.workflow.id = self.queue_workflow_id self.workflow.complete = None @@ -40,3 +40,21 @@ def fake_observe(workflow_id: Id, evtcls_handler: dict, times: int): self.workflow.complete(None) self.assertFalse(self.manager.is_running(self.workflow.id)) + + def test_start_duplicate(self): + self.workflow.id = self.queue_workflow_id + self.workflow.complete = None + + def fake_observe(workflow_id: Id, evtcls_handler: dict, times: int): + self.workflow.complete = evtcls_handler.get(self.workflow.Completed, None) + + self.evt_dispatcher.observe_result.side_effect = fake_observe + + self.assertTrue(self.manager.start(self.workflow)) + self.assertFalse(self.manager.start(self.workflow)) + self.assertTrue(self.manager.is_running(self.workflow.id)) + + self.workflow.complete(None) + self.assertFalse(self.manager.is_running(self.workflow.id)) + + self.assertTrue(self.manager.start(self.workflow)) From 6840c6b24b710f3be9ad0d94e64586f819ffb681 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Tue, 17 Nov 2020 12:46:06 +0100 Subject: [PATCH 25/28] improve-logging Remove unused workflow reset method. - Stop passing derived instance to base class as using self resolves to derived. --- OpenCast/app/service/player.py | 2 +- OpenCast/app/service/playlist.py | 2 +- OpenCast/app/service/service.py | 3 +-- OpenCast/app/service/video.py | 2 +- OpenCast/app/workflow/app.py | 1 - OpenCast/app/workflow/player.py | 4 ---- OpenCast/app/workflow/video.py | 1 - OpenCast/app/workflow/workflow.py | 24 ++++++------------------ 8 files changed, 10 insertions(+), 29 deletions(-) diff --git a/OpenCast/app/service/player.py b/OpenCast/app/service/player.py index ca5668c0..878da5a5 100644 --- a/OpenCast/app/service/player.py +++ b/OpenCast/app/service/player.py @@ -15,7 +15,7 @@ class PlayerService(Service): def __init__(self, app_facade, data_facade, media_factory): logger = structlog.get_logger(__name__) - super().__init__(app_facade, logger, self, player_cmds) + super().__init__(app_facade, logger, player_cmds) self._observe_event(PlayerEvt.PlayerCreated) diff --git a/OpenCast/app/service/playlist.py b/OpenCast/app/service/playlist.py index 3ece1d3e..2320fd18 100644 --- a/OpenCast/app/service/playlist.py +++ b/OpenCast/app/service/playlist.py @@ -12,7 +12,7 @@ class PlaylistService(Service): def __init__(self, app_facade, service_factory, data_facade): logger = structlog.get_logger(__name__) - super().__init__(app_facade, logger, self, playlist_cmds) + super().__init__(app_facade, logger, playlist_cmds) self._observe_event(VideoEvt.VideoDeleted) diff --git a/OpenCast/app/service/service.py b/OpenCast/app/service/service.py index 4050bcbf..b93e5310 100644 --- a/OpenCast/app/service/service.py +++ b/OpenCast/app/service/service.py @@ -12,11 +12,10 @@ class Service: - def __init__(self, app_facade, logger, derived, cmd_module, evt_module=None): + def __init__(self, app_facade, logger, cmd_module, evt_module=None): self._cmd_dispatcher = app_facade.cmd_dispatcher self._evt_dispatcher = app_facade.evt_dispatcher self._logger = logger - self.__derived = derived self._observe(cmd_module, self._observe_command) if evt_module is not None: self._observe(evt_module, partial(self._observe_event, None)) diff --git a/OpenCast/app/service/video.py b/OpenCast/app/service/video.py index b198f1f8..9892aa72 100644 --- a/OpenCast/app/service/video.py +++ b/OpenCast/app/service/video.py @@ -14,7 +14,7 @@ class VideoService(Service): def __init__(self, app_facade, service_factory, data_facade, media_factory): logger = structlog.get_logger(__name__) - super().__init__(app_facade, logger, self, video_cmds) + super().__init__(app_facade, logger, video_cmds) self._video_repo = data_facade.video_repo self._downloader = media_factory.make_downloader(app_facade.evt_dispatcher) self._source_service = service_factory.make_source_service( diff --git a/OpenCast/app/workflow/app.py b/OpenCast/app/workflow/app.py index 2042ab5f..307d6175 100644 --- a/OpenCast/app/workflow/app.py +++ b/OpenCast/app/workflow/app.py @@ -50,7 +50,6 @@ def __init__( logger = structlog.get_logger(__name__) super().__init__( logger, - self, id, app_facade, initial=InitWorkflow.States.INITIAL, diff --git a/OpenCast/app/workflow/player.py b/OpenCast/app/workflow/player.py index f4f9d8c6..a371a73e 100644 --- a/OpenCast/app/workflow/player.py +++ b/OpenCast/app/workflow/player.py @@ -51,7 +51,6 @@ def __init__( logger = structlog.get_logger(__name__) super().__init__( logger, - self, id, app_facade, initial=QueueVideoWorkflow.States.INITIAL, @@ -117,7 +116,6 @@ def __init__( logger = structlog.get_logger(__name__) super().__init__( logger, - self, id, app_facade, initial=StreamVideoWorkflow.States.INITIAL, @@ -175,7 +173,6 @@ def __init__(self, id, app_facade, data_facade, video: Video): logger = structlog.get_logger(__name__) super().__init__( logger, - self, id, app_facade, initial=StreamVideoWorkflow.States.INITIAL, @@ -247,7 +244,6 @@ def __init__( logger = structlog.get_logger(__name__) super().__init__( logger, - self, id, app_facade, initial=StreamVideoWorkflow.States.INITIAL, diff --git a/OpenCast/app/workflow/video.py b/OpenCast/app/workflow/video.py index 5f12afda..0d06213f 100644 --- a/OpenCast/app/workflow/video.py +++ b/OpenCast/app/workflow/video.py @@ -59,7 +59,6 @@ def __init__(self, id, app_facade, data_facade, video: Video): logger = structlog.get_logger(__name__) super().__init__( logger, - self, id, app_facade, initial=VideoWorkflow.States.INITIAL, diff --git a/OpenCast/app/workflow/workflow.py b/OpenCast/app/workflow/workflow.py index 108577b2..cfae6fe0 100644 --- a/OpenCast/app/workflow/workflow.py +++ b/OpenCast/app/workflow/workflow.py @@ -14,7 +14,6 @@ class Workflow(Machine): Args: logger: The workflow's logger. - derived: The instance of the derived class. id: The workflow's ID. app_facade: The application facade. @@ -25,7 +24,6 @@ class Workflow(Machine): def __init__( self, logger, - derived, id_: Id, app_facade, *args, @@ -33,8 +31,8 @@ def __init__( ): super().__init__( model=self, - states=derived.States, - transitions=derived.transitions, + states=self.States, + transitions=self.transitions, *args, **kwargs, ) @@ -45,33 +43,23 @@ def __init__( self._factory = app_facade.workflow_factory self._cmd_dispatcher = app_facade.cmd_dispatcher self._evt_dispatcher = app_facade.evt_dispatcher - self.__derived = derived - self._sub_workflows = [] def __repr__(self): return f"{type(self).__name__}(id={self.id})" - def reset(self): - """ Reset the workflow and its sub-workflows to their initial state""" - # TODO: Remove as unused and incompatible with the workflow manager - self.set_state(self._initial) - for workflow in self._sub_workflows: - workflow.reset() - def _cancel(self, *args): """ Cancel the workflow and dispatch the related event""" - self._evt_dispatcher.dispatch(self.__derived.Aborted(self.id, *args)) + self._evt_dispatcher.dispatch(self.Aborted(self.id, *args)) def _complete(self, *args): - self._evt_dispatcher.dispatch(self.__derived.Completed(self.id, *args)) + self._evt_dispatcher.dispatch(self.Completed(self.id, *args)) def _observe_start(self, workflow, *args, **kwargs): self._observe(workflow.id, [workflow.Completed, workflow.Aborted]) self._start_workflow(workflow, *args, **kwargs) def _start_workflow(self, workflow, *args, **kwargs): - if self._app_facade.workflow_manager.start(workflow, *args, **kwargs): - self._sub_workflows.append(workflow) + self._app_facade.workflow_manager.start(workflow, *args, **kwargs) def _observe_dispatch(self, evt_cls, cmd_cls, model_id: Id, *args, **kwargs): # TODO consider using the workflow id for commands from a same workflow @@ -89,7 +77,7 @@ def _observe(self, cmd_id: Id, evt_clss: list): def _event_handler(self, evt_cls): handler_name = name_handler_method(evt_cls) try: - return getattr(self.__derived, handler_name) + return getattr(self, handler_name) except AttributeError: self._logger.critical( "Missing handler", handler=handler_name, cls=evt_cls.__name__ From 9fa1cc47da0af1c68a49d3f561a888f15cae3198 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Wed, 18 Nov 2020 10:25:30 +0100 Subject: [PATCH 26/28] develop Welcome back youtube-dl ! - Downgrade aiohttp to v3.6.2 as I get a 'not a supported wheel on this platform' with the latest. --- OpenCast/infra/media/downloader.py | 4 ++-- poetry.lock | 29 ++++++++++++----------------- pyproject.toml | 4 ++-- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/OpenCast/infra/media/downloader.py b/OpenCast/infra/media/downloader.py index 48291ad8..259c1476 100644 --- a/OpenCast/infra/media/downloader.py +++ b/OpenCast/infra/media/downloader.py @@ -6,8 +6,8 @@ import structlog from hurry.filesize import alternative, size -from youtube_dlc import YoutubeDL -from youtube_dlc.utils import ISO639Utils +from youtube_dl import YoutubeDL +from youtube_dl.utils import ISO639Utils from OpenCast.infra import Id from OpenCast.infra.event.downloader import DownloadError, DownloadSuccess diff --git a/poetry.lock b/poetry.lock index bd4df572..6273e33f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -812,12 +812,12 @@ multidict = ">=4.0" typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] -name = "youtube-dlc" -version = "2020.11.11.post3" -description = "Media downloader supporting various sites such as youtube" +name = "youtube-dl" +version = "2020.11.18" +description = "YouTube video downloader" category = "main" optional = false -python-versions = ">=2.6" +python-versions = "*" [[package]] name = "zipp" @@ -834,7 +834,11 @@ testing = ["jaraco.itertools", "func-timeout"] [metadata] lock-version = "1.1" python-versions = "^3.7" +<<<<<<< HEAD content-hash = "cb38aaee6a31769fde050c64750c4f12db5b29d00f9867778af382d762e63418" +======= +content-hash = "f526b28b3c87a36b63c785afb47d786ce62363c802eb9cd007c79ec6df56ea5e" +>>>>>>> 79155d7 (develop Welcome back youtube-dl !) [metadata.files] aiohttp = [ @@ -901,6 +905,7 @@ babel = [ {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, ] black = [ + {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"}, {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] certifi = [ @@ -917,7 +922,6 @@ click = [ ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] coverage = [ {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, @@ -1261,28 +1265,19 @@ typed-ast = [ {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, - {file = "typed_ast-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f"}, {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, - {file = "typed_ast-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298"}, {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, - {file = "typed_ast-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d"}, {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, - {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91"}, - {file = "typed_ast-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d"}, - {file = "typed_ast-1.4.1-cp39-cp39-win32.whl", hash = "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395"}, - {file = "typed_ast-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c"}, {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] typing-extensions = [ @@ -1313,9 +1308,9 @@ yarl = [ {file = "yarl-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692"}, {file = "yarl-1.5.1.tar.gz", hash = "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6"}, ] -youtube-dlc = [ - {file = "youtube_dlc-2020.11.11.post3-py2.py3-none-any.whl", hash = "sha256:bbd4f554e9d63b6e8de7cbace0abd0b9707ed32798fbeb2abbb9c17fcb9f7ec3"}, - {file = "youtube_dlc-2020.11.11.post3.tar.gz", hash = "sha256:5aaa0aa5fbd53d9acdfa95bab3c26802926eb27e425f23dc83d55cb18f11d053"}, +youtube-dl = [ + {file = "youtube_dl-2020.11.18-py2.py3-none-any.whl", hash = "sha256:b1f9544d6f6046e9195280eaed6e25d3e0064906eb123899b4bdb736becf8115"}, + {file = "youtube_dl-2020.11.18.tar.gz", hash = "sha256:fd879801004d80d875680041d8dcba25bd36cfdaeb0ca704607f16b3709a4f21"}, ] zipp = [ {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, diff --git a/pyproject.toml b/pyproject.toml index 0a126659..22cfb616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,14 +21,14 @@ transitions = "^0.8.5" structlog = "^20.1.0" colorama = "^0.4.4" python-vlc = "^3.0.9113" -aiohttp = "^3.7.2" +aiohttp = "^3.6.2" janus = "^0.6.1" aiohttp-middlewares = "^1.1.0" tinydb = "^4.3.0" dacite = "^1.5.1" marshmallow = "^3.9.1" marshmallow_enum = "^1.5.1" -youtube-dlc = "^2020.11.11" +youtube_dl = "^2020.11.18" [tool.poetry.dev-dependencies] pytest = "^6.1" From ef192e10b1afd3ce7ddc4dc943437199c1a6f0ae Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Wed, 18 Nov 2020 11:23:07 +0100 Subject: [PATCH 27/28] develop Bump version to 1.4.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 22cfb616..d02d131b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "Opencast" -version = "1.4.1" +version = "1.4.2" description = "A client-server media player that transforms your Raspberry Pi into an awesome streaming device" authors = [ "Luc Sinet " From c6a5e64d5d14a58335790b7fae83383babaaae30 Mon Sep 17 00:00:00 2001 From: Luc Sinet Date: Wed, 18 Nov 2020 11:23:15 +0100 Subject: [PATCH 28/28] develop Regenerate lock file. --- poetry.lock | 263 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 159 insertions(+), 104 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6273e33f..a1b05d4f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -63,21 +63,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.2.0" +version = "20.3.0" description = "Classes Without Boilerplate" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] [[package]] name = "babel" -version = "2.8.0" +version = "2.9.0" description = "Internationalization utilities" category = "dev" optional = false @@ -110,7 +110,7 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] name = "certifi" -version = "2020.6.20" +version = "2020.11.8" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -210,7 +210,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "1.7.0" +version = "2.0.0" description = "Read metadata from Python packages" category = "dev" optional = false @@ -225,7 +225,7 @@ testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" -version = "1.0.1" +version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" category = "dev" optional = false @@ -347,7 +347,7 @@ python-versions = "*" [[package]] name = "multidict" -version = "4.7.6" +version = "5.0.2" description = "multidict implementation" category = "main" optional = false @@ -391,7 +391,7 @@ six = "*" [[package]] name = "pathspec" -version = "0.8.0" +version = "0.8.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false @@ -437,7 +437,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.6.1" +version = "2.7.2" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -453,7 +453,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "pyrsistent" -version = "0.17.2" +version = "0.17.3" description = "Persistent/Functional/Immutable data structures" category = "dev" optional = false @@ -507,7 +507,7 @@ python-versions = "*" [[package]] name = "pytz" -version = "2020.1" +version = "2020.4" description = "World timezone definitions, modern and historical" category = "dev" optional = false @@ -523,7 +523,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "regex" -version = "2020.7.14" +version = "2020.11.13" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -531,7 +531,7 @@ python-versions = "*" [[package]] name = "requests" -version = "2.24.0" +version = "2.25.0" description = "Python HTTP for Humans." category = "dev" optional = false @@ -541,7 +541,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" certifi = ">=2017.4.17" chardet = ">=3.0.2,<4" idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +urllib3 = ">=1.21.1,<1.27" [package.extras] security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] @@ -787,7 +787,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.25.10" +version = "1.26.2" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false @@ -795,16 +795,16 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] brotli = ["brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "yarl" -version = "1.5.1" +version = "1.6.3" description = "Yet another URL library" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] idna = ">=2.0" @@ -821,7 +821,7 @@ python-versions = "*" [[package]] name = "zipp" -version = "3.1.0" +version = "3.4.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false @@ -829,16 +829,12 @@ python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["jaraco.itertools", "func-timeout"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" python-versions = "^3.7" -<<<<<<< HEAD -content-hash = "cb38aaee6a31769fde050c64750c4f12db5b29d00f9867778af382d762e63418" -======= -content-hash = "f526b28b3c87a36b63c785afb47d786ce62363c802eb9cd007c79ec6df56ea5e" ->>>>>>> 79155d7 (develop Welcome back youtube-dl !) +content-hash = "24acb22eaf24d07cfedb9c1c039304a58df2de55f299ea4b1cff074258090546" [metadata.files] aiohttp = [ @@ -897,20 +893,20 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, - {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, + {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, + {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, ] babel = [ - {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, - {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, + {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, + {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, ] black = [ {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"}, {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] certifi = [ - {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, - {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, + {file = "certifi-2020.11.8-py2.py3-none-any.whl", hash = "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd"}, + {file = "certifi-2020.11.8.tar.gz", hash = "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, @@ -983,12 +979,11 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, + {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"}, + {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"}, ] iniconfig = [ - {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"}, - {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ {file = "isort-5.6.4-py3-none-any.whl", hash = "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7"}, @@ -1061,23 +1056,43 @@ mistune = [ {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, ] multidict = [ - {file = "multidict-4.7.6-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000"}, - {file = "multidict-4.7.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a"}, - {file = "multidict-4.7.6-cp35-cp35m-win32.whl", hash = "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5"}, - {file = "multidict-4.7.6-cp35-cp35m-win_amd64.whl", hash = "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3"}, - {file = "multidict-4.7.6-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87"}, - {file = "multidict-4.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2"}, - {file = "multidict-4.7.6-cp36-cp36m-win32.whl", hash = "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7"}, - {file = "multidict-4.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463"}, - {file = "multidict-4.7.6-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"}, - {file = "multidict-4.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255"}, - {file = "multidict-4.7.6-cp37-cp37m-win32.whl", hash = "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507"}, - {file = "multidict-4.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c"}, - {file = "multidict-4.7.6-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b"}, - {file = "multidict-4.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7"}, - {file = "multidict-4.7.6-cp38-cp38-win32.whl", hash = "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d"}, - {file = "multidict-4.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19"}, - {file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"}, + {file = "multidict-5.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b82400ef848bbac6b9035a105ac6acaa1fb3eea0d164e35bbb21619b88e49fed"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b98af08d7bb37d3456a22f689819ea793e8d6961b9629322d7728c4039071641"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d4a6fb98e9e9be3f7d70fd3e852369c00a027bd5ed0f3e8ade3821bcad257408"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:2ab9cad4c5ef5c41e1123ed1f89f555aabefb9391d4e01fd6182de970b7267ed"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:62abab8088704121297d39c8f47156cb8fab1da731f513e59ba73946b22cf3d0"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:59182e975b8c197d0146a003d0f0d5dc5487ce4899502061d8df585b0f51fba2"}, + {file = "multidict-5.0.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:76cbdb22f48de64811f9ce1dd4dee09665f84f32d6a26de249a50c1e90e244e0"}, + {file = "multidict-5.0.2-cp36-cp36m-win32.whl", hash = "sha256:653b2bbb0bbf282c37279dd04f429947ac92713049e1efc615f68d4e64b1dbc2"}, + {file = "multidict-5.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:c58e53e1c73109fdf4b759db9f2939325f510a8a5215135330fe6755921e4886"}, + {file = "multidict-5.0.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:359ea00e1b53ceef282232308da9d9a3f60d645868a97f64df19485c7f9ef628"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b561e76c9e21402d9a446cdae13398f9942388b9bff529f32dfa46220af54d00"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:9380b3f2b00b23a4106ba9dd022df3e6e2e84e1788acdbdd27603b621b3288df"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:1cd102057b09223b919f9447c669cf2efabeefb42a42ae6233f25ffd7ee31a79"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:d99da85d6890267292065e654a329e1d2f483a5d2485e347383800e616a8c0b1"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:f612e8ef8408391a4a3366e3508bab8ef97b063b4918a317cb6e6de4415f01af"}, + {file = "multidict-5.0.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:6128d2c0956fd60e39ec7d1c8f79426f0c915d36458df59ddd1f0cff0340305f"}, + {file = "multidict-5.0.2-cp37-cp37m-win32.whl", hash = "sha256:9ed9b280f7778ad6f71826b38a73c2fdca4077817c64bc1102fdada58e75c03c"}, + {file = "multidict-5.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f65a2442c113afde52fb09f9a6276bbc31da71add99dc76c3adf6083234e07c6"}, + {file = "multidict-5.0.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:2576e30bbec004e863d87216bc34abe24962cc2e964613241a1c01c7681092ab"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:20cc9b2dd31761990abff7d0e63cd14dbfca4ebb52a77afc917b603473951a38"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:6566749cd78cb37cbf8e8171b5cd2cbfc03c99f0891de12255cf17a11c07b1a3"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6168839491a533fa75f3f5d48acbb829475e6c7d9fa5c6e245153b5f79b986a3"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:e58db0e0d60029915f7fc95a8683fa815e204f2e1990f1fb46a7778d57ca8c35"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:8fa4549f341a057feec4c3139056ba73e17ed03a506469f447797a51f85081b5"}, + {file = "multidict-5.0.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:06f39f0ddc308dab4e5fa282d145f90cd38d7ed75390fc83335636909a9ec191"}, + {file = "multidict-5.0.2-cp38-cp38-win32.whl", hash = "sha256:8efcf070d60fd497db771429b1c769a3783e3a0dd96c78c027e676990176adc5"}, + {file = "multidict-5.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:060d68ae3e674c913ec41a464916f12c4d7ff17a3a9ebbf37ba7f2c681c2b33e"}, + {file = "multidict-5.0.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:4a3f19da871befa53b48dd81ee48542f519beffa13090dc135fffc18d8fe36db"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:af271c2540d1cd2a137bef8d95a8052230aa1cda26dd3b2c73d858d89993d518"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3e61cc244fd30bd9fdfae13bdd0c5ec65da51a86575ff1191255cae677045ffe"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:4df708ef412fd9b59b7e6c77857e64c1f6b4c0116b751cb399384ec9a28baa66"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:cbabfc12b401d074298bfda099c58dfa5348415ae2e4ec841290627cb7cb6b2e"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:43c7a87d8c31913311a1ab24b138254a0ee89142983b327a2c2eab7a7d10fea9"}, + {file = "multidict-5.0.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fa0503947a99a1be94f799fac89d67a5e20c333e78ddae16e8534b151cdc588a"}, + {file = "multidict-5.0.2-cp39-cp39-win32.whl", hash = "sha256:17847fede1aafdb7e74e01bb34ab47a1a1ea726e8184c623c45d7e428d2d5d34"}, + {file = "multidict-5.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a7b8b5bd16376c8ac2977748bd978a200326af5145d8d0e7f799e2b355d425b6"}, + {file = "multidict-5.0.2.tar.gz", hash = "sha256:e5bf89fe57f702a046c7ec718fe330ed50efd4bcf74722940db2eb0919cddb1c"}, ] mypy = [ {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, @@ -1104,8 +1119,8 @@ packaging = [ {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, ] pathspec = [ - {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, - {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, + {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, + {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, @@ -1124,15 +1139,15 @@ pyflakes = [ {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] pygments = [ - {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, - {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, + {file = "Pygments-2.7.2-py3-none-any.whl", hash = "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"}, + {file = "Pygments-2.7.2.tar.gz", hash = "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pyrsistent = [ - {file = "pyrsistent-0.17.2.tar.gz", hash = "sha256:27515d2d5db0629c7dadf6fbe76973eb56f098c1b01d36de42eb69220d2c19e4"}, + {file = "pyrsistent-0.17.3.tar.gz", hash = "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"}, ] pytest = [ {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, @@ -1147,8 +1162,8 @@ python-vlc = [ {file = "python_vlc-3.0.11115-py3-none-any.whl", hash = "sha256:508bc5b4b4fd72b4e23c926795bdcd38c7c1c08a4dd6b8cc87b0abd1d7118aa1"}, ] pytz = [ - {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, - {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, + {file = "pytz-2020.4-py2.py3-none-any.whl", hash = "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd"}, + {file = "pytz-2020.4.tar.gz", hash = "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268"}, ] pyyaml = [ {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, @@ -1164,31 +1179,51 @@ pyyaml = [ {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, ] regex = [ - {file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"}, - {file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"}, - {file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"}, - {file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"}, - {file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"}, - {file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"}, - {file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"}, - {file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"}, - {file = "regex-2020.7.14-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a"}, - {file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"}, - {file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"}, - {file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"}, + {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, + {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, + {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, + {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, + {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, + {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, + {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, + {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, + {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, + {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, + {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, + {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, + {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, + {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, + {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, + {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, + {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, ] requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, + {file = "requests-2.25.0-py2.py3-none-any.whl", hash = "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998"}, + {file = "requests-2.25.0.tar.gz", hash = "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8"}, ] six = [ {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, @@ -1286,33 +1321,53 @@ typing-extensions = [ {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, ] urllib3 = [ - {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, - {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, + {file = "urllib3-1.26.2-py2.py3-none-any.whl", hash = "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473"}, + {file = "urllib3-1.26.2.tar.gz", hash = "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08"}, ] yarl = [ - {file = "yarl-1.5.1-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb"}, - {file = "yarl-1.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593"}, - {file = "yarl-1.5.1-cp35-cp35m-win32.whl", hash = "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409"}, - {file = "yarl-1.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317"}, - {file = "yarl-1.5.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511"}, - {file = "yarl-1.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e"}, - {file = "yarl-1.5.1-cp36-cp36m-win32.whl", hash = "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f"}, - {file = "yarl-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2"}, - {file = "yarl-1.5.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a"}, - {file = "yarl-1.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8"}, - {file = "yarl-1.5.1-cp37-cp37m-win32.whl", hash = "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8"}, - {file = "yarl-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d"}, - {file = "yarl-1.5.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02"}, - {file = "yarl-1.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a"}, - {file = "yarl-1.5.1-cp38-cp38-win32.whl", hash = "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"}, - {file = "yarl-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692"}, - {file = "yarl-1.5.1.tar.gz", hash = "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6"}, + {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, + {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, + {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, + {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, + {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, + {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, + {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, + {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, + {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, + {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, + {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, + {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, + {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, + {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, + {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, + {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, + {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, ] youtube-dl = [ {file = "youtube_dl-2020.11.18-py2.py3-none-any.whl", hash = "sha256:b1f9544d6f6046e9195280eaed6e25d3e0064906eb123899b4bdb736becf8115"}, {file = "youtube_dl-2020.11.18.tar.gz", hash = "sha256:fd879801004d80d875680041d8dcba25bd36cfdaeb0ca704607f16b3709a4f21"}, ] zipp = [ - {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, - {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, + {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, + {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, ]