diff --git a/.gitignore b/.gitignore index d82c827..1ddb610 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Python venv +dist .tox __pycache__ *.egg-info @@ -17,3 +18,5 @@ __pycache__ # Application data /_run +.env +*.sqlite3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1b99b3e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.2.0 + hooks: + - id: check-added-large-files + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + additional_dependencies: [ Flake8-pyproject ] + entry: flake8p diff --git a/manage.py b/manage.py new file mode 120000 index 0000000..9986394 --- /dev/null +++ b/manage.py @@ -0,0 +1 @@ +ucast/manage.py \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 7909b86..025af55 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,14 @@ +[[package]] +name = "asgiref" +version = "3.5.1" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + [[package]] name = "atomicwrites" version = "1.4.0" @@ -69,22 +80,11 @@ python-versions = ">=3.5.0" [package.extras] unicode_backport = ["unicodedata2"] -[[package]] -name = "click" -version = "8.1.2" -description = "Composable command line interface toolkit" -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -113,6 +113,23 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["tomli"] +[[package]] +name = "django" +version = "4.0.4" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +asgiref = ">=3.4.1,<4" +sqlparse = ">=0.2.2" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + [[package]] name = "feedparser" version = "6.0.8" @@ -124,24 +141,6 @@ python-versions = ">=3.6" [package.dependencies] sgmllib3k = "*" -[[package]] -name = "flask" -version = "2.1.1" -description = "A simple framework for building complex web applications." -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -click = ">=8.0" -itsdangerous = ">=2.0" -Jinja2 = ">=3.0" -Werkzeug = ">=2.0" - -[package.extras] -async = ["asgiref (>=3.2)"] -dotenv = ["python-dotenv"] - [[package]] name = "font-source-sans-pro" version = "0.0.1" @@ -182,36 +181,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "itsdangerous" -version = "2.1.2" -description = "Safely pass data to untrusted environments and back." -category = "main" -optional = false -python-versions = ">=3.7" - -[[package]] -name = "jinja2" -version = "3.1.1" -description = "A very fast and expressive template engine." -category = "main" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "markupsafe" -version = "2.1.1" -description = "Safely add untrusted strings to HTML/XML markup." -category = "main" -optional = false -python-versions = ">=3.7" - [[package]] name = "mutagen" version = "1.45.1" @@ -292,7 +261,7 @@ diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pytest" -version = "7.1.1" +version = "7.1.2" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -326,6 +295,21 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["django", "django-configurations (>=2.0)"] + [[package]] name = "requests" version = "2.27.1" @@ -372,6 +356,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "sqlparse" +version = "0.4.2" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "tomli" version = "2.0.1" @@ -382,11 +374,19 @@ python-versions = ">=3.7" [[package]] name = "typing-extensions" -version = "4.1.1" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.2.0" +description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" + +[[package]] +name = "tzdata" +version = "2022.1" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" [[package]] name = "urllib3" @@ -411,23 +411,12 @@ python-versions = "*" [[package]] name = "websockets" -version = "10.2" +version = "10.3" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" category = "main" optional = false python-versions = ">=3.7" -[[package]] -name = "werkzeug" -version = "2.1.1" -description = "The comprehensive WSGI web application library." -category = "main" -optional = false -python-versions = ">=3.7" - -[package.extras] -watchdog = ["watchdog"] - [[package]] name = "yt-dlp" version = "2022.4.8" @@ -447,9 +436,13 @@ websockets = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "cf8899258dac046f0ed3d0492161db330ab735dc8dcbe1c46d2c8d4e48b66342" +content-hash = "0288eb9eca14b78ee0cfac28cef0dcf17701165b6aa778255bfa73b94d5b2c4f" [metadata.files] +asgiref = [ + {file = "asgiref-3.5.1-py3-none-any.whl", hash = "sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1"}, + {file = "asgiref-3.5.1.tar.gz", hash = "sha256:fddeea3c53fa99d0cdb613c3941cc6e52d822491fc2753fba25768fb5bf4e865"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -614,10 +607,6 @@ charset-normalizer = [ {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] -click = [ - {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, - {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, -] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -669,14 +658,14 @@ coverage = [ {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, ] +django = [ + {file = "Django-4.0.4-py3-none-any.whl", hash = "sha256:07c8638e7a7f548dc0acaaa7825d84b7bd42b10e8d22268b3d572946f1e9b687"}, + {file = "Django-4.0.4.tar.gz", hash = "sha256:4e8177858524417563cc0430f29ea249946d831eacb0068a1455686587df40b5"}, +] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"}, ] -flask = [ - {file = "Flask-2.1.1-py3-none-any.whl", hash = "sha256:8a4cf32d904cf5621db9f0c9fbcd7efabf3003f22a04e4d0ce790c7137ec5264"}, - {file = "Flask-2.1.1.tar.gz", hash = "sha256:a8c9bd3e558ec99646d177a9739c41df1ded0629480b4c8d2975412f3c9519c8"}, -] font-source-sans-pro = [ {file = "font-source-sans-pro-0.0.1.tar.gz", hash = "sha256:3f81d8e52b0d7e930e2c867c0d3ee549312d03f97b71b664a8361006311f72e5"}, {file = "font_source_sans_pro-0.0.1-py2-none-any.whl", hash = "sha256:685c8813d59941e84ea326f46d638871adbc825a0aa5205a72ee9ed9c5fbb471"}, @@ -699,56 +688,6 @@ invoke = [ {file = "invoke-1.7.0-py3-none-any.whl", hash = "sha256:a5159fc63dba6ca2a87a1e33d282b99cea69711b03c64a35bb4e1c53c6c4afa0"}, {file = "invoke-1.7.0.tar.gz", hash = "sha256:e332e49de40463f2016315f51df42313855772be86435686156bc18f45b5cc6c"}, ] -itsdangerous = [ - {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, - {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, -] -jinja2 = [ - {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, - {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, -] -markupsafe = [ - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, - {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, -] mutagen = [ {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, {file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"}, @@ -843,13 +782,17 @@ pyparsing = [ {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, ] pytest = [ - {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, - {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, + {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, + {file = "pytest-7.1.2.tar.gz", hash = "sha256:a06a0425453864a270bc45e71f783330a7428defb4230fb5e6a731fde06ecd45"}, ] pytest-cov = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] +pytest-django = [ + {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, + {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, +] requests = [ {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, @@ -863,13 +806,21 @@ scrapetube = [ sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, ] +sqlparse = [ + {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, + {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, +] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] typing-extensions = [ - {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, - {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, + {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, + {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, +] +tzdata = [ + {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, + {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, ] urllib3 = [ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, @@ -879,58 +830,54 @@ wcag-contrast-ratio = [ {file = "wcag-contrast-ratio-0.9.tar.gz", hash = "sha256:69192b8e5c0a7d0dc5ff1187eeb3e398141633a4bde51c69c87f58fe87ed361c"}, ] websockets = [ - {file = "websockets-10.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5396710f86a306cf52f87fd8ea594a0e894ba0cc5a36059eaca3a477dc332aa"}, - {file = "websockets-10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b22bdc795e62e71118b63e14a08bacfa4f262fd2877de7e5b950f5ac16b0348f"}, - {file = "websockets-10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5b04270b5613f245ec84bb2c6a482a9d009aefad37c0575f6cda8499125d5d5c"}, - {file = "websockets-10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5c335dc0e7dc271ef36df3f439868b3c790775f345338c2f61a562f1074187b"}, - {file = "websockets-10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a009eb551c46fd79737791c0c833fc0e5b56bcd1c3057498b262d660b92e9cd"}, - {file = "websockets-10.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a10c0c1ee02164246f90053273a42d72a3b2452a7e7486fdae781138cf7fbe2d"}, - {file = "websockets-10.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b38a5c9112e3dbbe45540f7b60c5204f49b3cb501b40950d6ab34cd202ab1d0"}, - {file = "websockets-10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:2aa9b91347ecd0412683f28aabe27f6bad502d89bd363b76e0a3508b1596402e"}, - {file = "websockets-10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b7fe45ae43ac814beb8ca09d6995b56800676f2cfa8e23f42839dc69bba34a42"}, - {file = "websockets-10.2-cp310-cp310-win32.whl", hash = "sha256:cef40a1b183dcf39d23b392e9dd1d9b07ab9c46aadf294fff1350fb79146e72b"}, - {file = "websockets-10.2-cp310-cp310-win_amd64.whl", hash = "sha256:c21a67ab9a94bd53e10bba21912556027fea944648a09e6508415ad14e37c325"}, - {file = "websockets-10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cb316b87cbe3c0791c2ad92a5a36bf6adc87c457654335810b25048c1daa6fd5"}, - {file = "websockets-10.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f14bd10e170abc01682a9f8b28b16e6f20acf6175945ef38db6ffe31b0c72c3f"}, - {file = "websockets-10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fa35c5d1830d0fb7b810324e9eeab9aa92e8f273f11fdbdc0741dcded6d72b9f"}, - {file = "websockets-10.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:71a4491cfe7a9f18ee57d41163cb6a8a3fa591e0f0564ca8b0ed86b2a30cced4"}, - {file = "websockets-10.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6193bbc1ee63aadeb9a4d81de0e19477401d150d506aee772d8380943f118186"}, - {file = "websockets-10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8beac786a388bb99a66c3be4ab0fb38273c0e3bc17f612a4e0a47c4fc8b9c045"}, - {file = "websockets-10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c67d9cacb3f6537ca21e9b224d4fd08481538e43bcac08b3d93181b0816def39"}, - {file = "websockets-10.2-cp37-cp37m-win32.whl", hash = "sha256:a03a25d95cc7400bd4d61a63460b5d85a7761c12075ee2f51de1ffe73aa593d3"}, - {file = "websockets-10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f8296b8408ec6853b26771599990721a26403e62b9de7e50ac0a056772ac0b5e"}, - {file = "websockets-10.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7bb9d8a6beca478c7e9bdde0159bd810cc1006ad6a7cb460533bae39da692ca2"}, - {file = "websockets-10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:05f6e9757017270e7a92a2975e2ae88a9a582ffc4629086fd6039aa80e99cd86"}, - {file = "websockets-10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1c9031e90ebfc486e9cdad532b94004ade3aa39a31d3c46c105bb0b579cd2490"}, - {file = "websockets-10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82bc33db6d8309dc27a3bee11f7da2288ad925fcbabc2a4bb78f7e9c56249baf"}, - {file = "websockets-10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:24b879ba7db12bb525d4e58089fcbe6a3df3ce4666523183654170e86d372cbe"}, - {file = "websockets-10.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cf931c33db9c87c53d009856045dd524e4a378445693382a920fa1e0eb77c36c"}, - {file = "websockets-10.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:669e54228a4d9457abafed27cbf0e2b9f401445c4dfefc12bf8e4db9751703b8"}, - {file = "websockets-10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:bffc65442dd35c473ca9790a3fa3ba06396102a950794f536783f4b8060af8dd"}, - {file = "websockets-10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4d110a84b63c5cfdd22485acc97b8b919aefeecd6300c0c9d551e055b9a88ea"}, - {file = "websockets-10.2-cp38-cp38-win32.whl", hash = "sha256:117383d0a17a0dda349f7a8790763dde75c1508ff8e4d6e8328b898b7df48397"}, - {file = "websockets-10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b66421f9f13d4df60cd48ab977ed2c2b6c9147ae1a33caf5a9f46294422fda1"}, - {file = "websockets-10.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac081aa0307f263d63c5ff0727935c736c8dad51ddf2dc9f5d0c4759842aefaa"}, - {file = "websockets-10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b4059e2ccbe6587b6dc9a01db5fc49ead9a884faa4076eea96c5ec62cb32f42a"}, - {file = "websockets-10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9ca2ca05a4c29179f06cf6727b45dba5d228da62623ec9df4184413d8aae6cb9"}, - {file = "websockets-10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97950c7c844ec6f8d292440953ae18b99e3a6a09885e09d20d5e7ecd9b914cf8"}, - {file = "websockets-10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:98f57b3120f8331cd7440dbe0e776474f5e3632fdaa474af1f6b754955a47d71"}, - {file = "websockets-10.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a72b92f96e5e540d5dda99ee3346e199ade8df63152fa3c737260da1730c411f"}, - {file = "websockets-10.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:038afef2a05893578d10dadbdbb5f112bd115c46347e1efe99f6a356ff062138"}, - {file = "websockets-10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f09f46b1ff6d09b01c7816c50bd1903cf7d02ebbdb63726132717c2fcda835d5"}, - {file = "websockets-10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2349fa81b6b959484bb2bda556ccb9eb70ba68987646a0f8a537a1a18319fb03"}, - {file = "websockets-10.2-cp39-cp39-win32.whl", hash = "sha256:bef03a51f9657fb03d8da6ccd233fe96e04101a852f0ffd35f5b725b28221ff3"}, - {file = "websockets-10.2-cp39-cp39-win_amd64.whl", hash = "sha256:1c1f3b18c8162e3b09761d0c6a0305fd642934202541cc511ef972cb9463261e"}, - {file = "websockets-10.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5a38a0175ae82e4a8c4bac29fc01b9ee26d7d5a614e5ee11e7813c68a7d938ce"}, - {file = "websockets-10.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e56606842bb24e16e36ae7eb308d866b4249cf0be8f63b212f287eeb76b124"}, - {file = "websockets-10.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0f73cb2526d6da268e86977b2c4b58f2195994e53070fe567d5487c6436047e6"}, - {file = "websockets-10.2-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cd02f36d37e503aca88ab23cc0a1a0e92a263d37acf6331521eb38040dcf77b"}, - {file = "websockets-10.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:56d48eebe9e39ce0d68701bce3b21df923aa05dcc00f9fd8300de1df31a7c07c"}, - {file = "websockets-10.2.tar.gz", hash = "sha256:8351c3c86b08156337b0e4ece0e3c5ec3e01fcd14e8950996832a23c99416098"}, -] -werkzeug = [ - {file = "Werkzeug-2.1.1-py3-none-any.whl", hash = "sha256:3c5493ece8268fecdcdc9c0b112211acd006354723b280d643ec732b6d4063d6"}, - {file = "Werkzeug-2.1.1.tar.gz", hash = "sha256:f8e89a20aeabbe8a893c24a461d3ee5dad2123b05cc6abd73ceed01d39c3ae74"}, + {file = "websockets-10.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:661f641b44ed315556a2fa630239adfd77bd1b11cb0b9d96ed8ad90b0b1e4978"}, + {file = "websockets-10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b529fdfa881b69fe563dbd98acce84f3e5a67df13de415e143ef053ff006d500"}, + {file = "websockets-10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f351c7d7d92f67c0609329ab2735eee0426a03022771b00102816a72715bb00b"}, + {file = "websockets-10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379e03422178436af4f3abe0aa8f401aa77ae2487843738542a75faf44a31f0c"}, + {file = "websockets-10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e904c0381c014b914136c492c8fa711ca4cced4e9b3d110e5e7d436d0fc289e8"}, + {file = "websockets-10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e7e6f2d6fd48422071cc8a6f8542016f350b79cc782752de531577d35e9bd677"}, + {file = "websockets-10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9c77f0d1436ea4b4dc089ed8335fa141e6a251a92f75f675056dac4ab47a71e"}, + {file = "websockets-10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e6fa05a680e35d0fcc1470cb070b10e6fe247af54768f488ed93542e71339d6f"}, + {file = "websockets-10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2f94fa3ae454a63ea3a19f73b95deeebc9f02ba2d5617ca16f0bbdae375cda47"}, + {file = "websockets-10.3-cp310-cp310-win32.whl", hash = "sha256:6ed1d6f791eabfd9808afea1e068f5e59418e55721db8b7f3bfc39dc831c42ae"}, + {file = "websockets-10.3-cp310-cp310-win_amd64.whl", hash = "sha256:347974105bbd4ea068106ec65e8e8ebd86f28c19e529d115d89bd8cc5cda3079"}, + {file = "websockets-10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fab7c640815812ed5f10fbee7abbf58788d602046b7bb3af9b1ac753a6d5e916"}, + {file = "websockets-10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:994cdb1942a7a4c2e10098d9162948c9e7b235df755de91ca33f6e0481366fdb"}, + {file = "websockets-10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:aad5e300ab32036eb3fdc350ad30877210e2f51bceaca83fb7fef4d2b6c72b79"}, + {file = "websockets-10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e49ea4c1a9543d2bd8a747ff24411509c29e4bdcde05b5b0895e2120cb1a761d"}, + {file = "websockets-10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ea6b300a6bdd782e49922d690e11c3669828fe36fc2471408c58b93b5535a98"}, + {file = "websockets-10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ef5ce841e102278c1c2e98f043db99d6755b1c58bde475516aef3a008ed7f28e"}, + {file = "websockets-10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d1655a6fc7aecd333b079d00fb3c8132d18988e47f19740c69303bf02e9883c6"}, + {file = "websockets-10.3-cp37-cp37m-win32.whl", hash = "sha256:83e5ca0d5b743cde3d29fda74ccab37bdd0911f25bd4cdf09ff8b51b7b4f2fa1"}, + {file = "websockets-10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:da4377904a3379f0c1b75a965fff23b28315bcd516d27f99a803720dfebd94d4"}, + {file = "websockets-10.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a1e15b230c3613e8ea82c9fc6941b2093e8eb939dd794c02754d33980ba81e36"}, + {file = "websockets-10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:31564a67c3e4005f27815634343df688b25705cccb22bc1db621c781ddc64c69"}, + {file = "websockets-10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c8d1d14aa0f600b5be363077b621b1b4d1eb3fbf90af83f9281cda668e6ff7fd"}, + {file = "websockets-10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fbd7d77f8aba46d43245e86dd91a8970eac4fb74c473f8e30e9c07581f852b2"}, + {file = "websockets-10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:210aad7fdd381c52e58777560860c7e6110b6174488ef1d4b681c08b68bf7f8c"}, + {file = "websockets-10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6075fd24df23133c1b078e08a9b04a3bc40b31a8def4ee0b9f2c8865acce913e"}, + {file = "websockets-10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f6d96fdb0975044fdd7953b35d003b03f9e2bcf85f2d2cf86285ece53e9f991"}, + {file = "websockets-10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c7250848ce69559756ad0086a37b82c986cd33c2d344ab87fea596c5ac6d9442"}, + {file = "websockets-10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:28dd20b938a57c3124028680dc1600c197294da5db4292c76a0b48efb3ed7f76"}, + {file = "websockets-10.3-cp38-cp38-win32.whl", hash = "sha256:54c000abeaff6d8771a4e2cef40900919908ea7b6b6a30eae72752607c6db559"}, + {file = "websockets-10.3-cp38-cp38-win_amd64.whl", hash = "sha256:7ab36e17af592eec5747c68ef2722a74c1a4a70f3772bc661079baf4ae30e40d"}, + {file = "websockets-10.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a141de3d5a92188234afa61653ed0bbd2dde46ad47b15c3042ffb89548e77094"}, + {file = "websockets-10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:97bc9d41e69a7521a358f9b8e44871f6cdeb42af31815c17aed36372d4eec667"}, + {file = "websockets-10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d6353ba89cfc657a3f5beabb3b69be226adbb5c6c7a66398e17809b0ce3c4731"}, + {file = "websockets-10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec2b0ab7edc8cd4b0eb428b38ed89079bdc20c6bdb5f889d353011038caac2f9"}, + {file = "websockets-10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:85506b3328a9e083cc0a0fb3ba27e33c8db78341b3eb12eb72e8afd166c36680"}, + {file = "websockets-10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8af75085b4bc0b5c40c4a3c0e113fa95e84c60f4ed6786cbb675aeb1ee128247"}, + {file = "websockets-10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07cdc0a5b2549bcfbadb585ad8471ebdc7bdf91e32e34ae3889001c1c106a6af"}, + {file = "websockets-10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5b936bf552e4f6357f5727579072ff1e1324717902127ffe60c92d29b67b7be3"}, + {file = "websockets-10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e4e08305bfd76ba8edab08dcc6496f40674f44eb9d5e23153efa0a35750337e8"}, + {file = "websockets-10.3-cp39-cp39-win32.whl", hash = "sha256:bb621ec2dbbbe8df78a27dbd9dd7919f9b7d32a73fafcb4d9252fc4637343582"}, + {file = "websockets-10.3-cp39-cp39-win_amd64.whl", hash = "sha256:51695d3b199cd03098ae5b42833006a0f43dc5418d3102972addc593a783bc02"}, + {file = "websockets-10.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:907e8247480f287aa9bbc9391bd6de23c906d48af54c8c421df84655eef66af7"}, + {file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b1359aba0ff810d5830d5ab8e2c4a02bebf98a60aa0124fb29aa78cfdb8031f"}, + {file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:93d5ea0b5da8d66d868b32c614d2b52d14304444e39e13a59566d4acb8d6e2e4"}, + {file = "websockets-10.3-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7934e055fd5cd9dee60f11d16c8d79c4567315824bacb1246d0208a47eca9755"}, + {file = "websockets-10.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55"}, + {file = "websockets-10.3.tar.gz", hash = "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"}, ] yt-dlp = [ {file = "yt-dlp-2022.4.8.tar.gz", hash = "sha256:8758d016509d4574b90fbde975aa70adaef71ed5e7a195141588f6d6945205ba"}, diff --git a/pyproject.toml b/pyproject.toml index ba48a57..ba41970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,16 @@ [tool.poetry] name = "ucast" -version = "0.1.0" -description = "" +version = "0.0.1" +description = "YouTube to Podcast converter" authors = ["Theta-Dev "] +packages = [ + { include = "ucast" }, + { include = "yt2podcast" }, +] [tool.poetry.dependencies] python = "^3.10" -Flask = "^2.1.1" +Django = "^4.0.4" yt-dlp = "^2022.3.8" scrapetube = "^2.2.2" rfeed = "^1.1.1" @@ -21,7 +25,21 @@ fonts = "^0.0.3" pytest = "^7.1.1" pytest-cov = "^3.0.0" invoke = "^1.7.0" +pytest-django = "^4.5.2" + +[tool.poetry.scripts] +"ucast-manage" = "ucast.manage:main" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.flake8] +max-line-length = 88 +per-file-ignores = [ + "settings.py:E501", +] + +[tool.black] +line-length = 88 +target-version = ['py310'] diff --git a/tasks.py b/tasks.py index cafe839..d803b2d 100644 --- a/tasks.py +++ b/tasks.py @@ -1,32 +1,40 @@ import os +from pathlib import Path from invoke import task -from ucast import youtube, util, cover -import tests +from yt2podcast import tests +from yt2podcast.service import cover, util, youtube + +os.chdir(Path(__file__).absolute().parent) @task def test(c): - c.run('pytest tests', pty=True) + c.run("pytest tests", pty=True) @task -def get_cover(c, vid=''): +def makemigrations(c): + c.run("python manage.py makemigrations yt2podcast") + + +@task +def get_cover(c, vid=""): vinfo = youtube.get_video_info(vid) - title = vinfo['fulltitle'] - channel_name = vinfo['uploader'] + title = vinfo["fulltitle"] + channel_name = vinfo["uploader"] thumbnail_url = youtube.get_thumbnail_url(vinfo) - channel_url = vinfo['channel_url'] + channel_url = vinfo["channel_url"] channel_metadata = youtube.get_channel_metadata(channel_url) ti = 1 - while os.path.exists(tests.DIR_TESTFILES / 'cover' / f'c{ti}.png'): + while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}.png"): ti += 1 - tn_file = tests.DIR_TESTFILES / 'thumbnail' / f't{ti}.webp' - av_file = tests.DIR_TESTFILES / 'avatar' / f'a{ti}.jpg' - cv_file = tests.DIR_TESTFILES / 'cover' / f'c{ti}.png' + tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{ti}.webp" + av_file = tests.DIR_TESTFILES / "avatar" / f"a{ti}.jpg" + cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}.png" util.download_file(thumbnail_url, tn_file) util.download_file(channel_metadata.avatar_url, av_file) diff --git a/tests/test_cover.py b/tests/test_cover.py deleted file mode 100644 index 745c2e6..0000000 --- a/tests/test_cover.py +++ /dev/null @@ -1,84 +0,0 @@ -# coding=utf-8 -from typing import List -import tempfile -from pathlib import Path - -import pytest -from PIL import Image, ImageFont, ImageChops -from fonts.ttf import SourceSansPro - -import tests -from ucast import cover, types - - -@pytest.mark.parametrize('height,width,text,expect', [ - (40, 300, 'Hello', ['Hello']), - (40, 300, 'Hello World, this is me', ['Hello World,…']), - (90, 300, 'Hello World, this is me', ['Hello World, this', 'is me']), - (90, 300, 'Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz', ['Rindfleischettik…']), - (1000, 300, 'Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! Du nicht von Gott, Tyrann!', - ['Ha! du wärst', 'Obrigkeit von', 'Gott? Gott', 'spendet Segen', 'aus; du raubst!', 'Du nicht von Gott,', - 'Tyrann!']), -]) -def test_split_text(height: int, width: int, text: str, expect: List[str]): - font = ImageFont.truetype(SourceSansPro, 40) - lines = cover._split_text(height, width, text, font, 8) - assert lines == expect - - -@pytest.mark.parametrize('file_name,color', [ - ('t1.webp', (63, 63, 62)), - ('t2.webp', (74, 45, 37)), - ('t3.webp', (54, 24, 28)), -]) -def test_get_dominant_color(file_name: str, color: types.Color): - img = Image.open(tests.DIR_TESTFILES / 'thumbnail' / file_name) - c = cover._get_dominant_color(img) - assert c == color - - -@pytest.mark.parametrize('bg_color,text_color', [ - ((100, 0, 0), (255, 255, 255)), - ((200, 200, 0), (0, 0, 0)), -]) -def test_get_text_color(bg_color: types.Color, text_color: types.Color): - c = cover._get_text_color(bg_color) - assert c == text_color - - -@pytest.mark.parametrize('n_image,title,channel', [ - (1, 'ThetaDev @ Embedded World 2019', 'ThetaDev'), - (2, 'Sintel - Open Movie by Blender Foundation', 'Blender'), - (3, 'Systemabsturz Teaser zur DiVOC bb3', 'media.ccc.de'), -]) -def test_create_cover_image(n_image: int, title: str, channel: str): - tn_file = tests.DIR_TESTFILES / 'thumbnail' / f't{n_image}.webp' - av_file = tests.DIR_TESTFILES / 'avatar' / f'a{n_image}.jpg' - expected_cv_file = tests.DIR_TESTFILES / 'cover' / f'c{n_image}.png' - - tn_image = Image.open(tn_file) - av_image = Image.open(av_file) - expected_cv_image = Image.open(expected_cv_file) - - cv_image = cover._create_cover_image(tn_image, av_image, title, channel) - - diff = ImageChops.difference(cv_image, expected_cv_image) - assert diff.getbbox() is None - - -def test_create_cover_file(): - tn_file = tests.DIR_TESTFILES / 'thumbnail' / 't1.webp' - av_file = tests.DIR_TESTFILES / 'avatar' / 'a1.jpg' - expected_cv_file = tests.DIR_TESTFILES / 'cover' / 'c1.png' - - tmpdir_o = tempfile.TemporaryDirectory() - tmpdir = Path(tmpdir_o.name) - cv_file = tmpdir / 'cover.png' - - cover.create_cover_file(tn_file, av_file, 'ThetaDev @ Embedded World 2019', 'ThetaDev', cv_file) - - cv_image = Image.open(cv_file) - expected_cv_image = Image.open(expected_cv_file) - - diff = ImageChops.difference(cv_image, expected_cv_image) - assert diff.getbbox() is None diff --git a/ucast/__init__.py b/ucast/__init__.py index fa07f75..9bad579 100644 --- a/ucast/__init__.py +++ b/ucast/__init__.py @@ -1,36 +1 @@ -import os - -from flask import Flask - - -def create_app(test_config=None): - # create and configure the app - app = Flask(__name__, instance_relative_config=True) - app.config.from_mapping( - SECRET_KEY='dev', - DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'), - ) - - if test_config is None: - # load the instance config, if it exists, when not testing - app.config.from_pyfile('config.py', silent=True) - else: - # load the test config if passed in - app.config.from_mapping(test_config) - - # ensure the instance folder exists - try: - os.makedirs(app.instance_path) - except OSError: - pass - - # a simple page that says hello - @app.route('/') - def hello(): - return 'Hello, World!' - - @app.route('/err') - def errtest(): - raise Exception('I f*cked up') - - return app +# coding=utf-8 diff --git a/ucast/app.py b/ucast/app.py deleted file mode 100644 index 9dc7df9..0000000 --- a/ucast/app.py +++ /dev/null @@ -1,13 +0,0 @@ -# coding=utf-8 -from flask import Flask - -app = Flask(__name__) - - -@app.route('/') -def hello_world(): # put application's code here - return 'Hello World!' - - -if __name__ == '__main__': - app.run() diff --git a/ucast/asgi.py b/ucast/asgi.py new file mode 100644 index 0000000..be266bd --- /dev/null +++ b/ucast/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for ucast project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") + +application = get_asgi_application() diff --git a/ucast/manage.py b/ucast/manage.py new file mode 100755 index 0000000..4c6dfe3 --- /dev/null +++ b/ucast/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/ucast/settings.py b/ucast/settings.py new file mode 100644 index 0000000..ffcdcd0 --- /dev/null +++ b/ucast/settings.py @@ -0,0 +1,121 @@ +""" +Django settings for ucast project. + +Generated by 'django-admin startproject' using Django 4.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.0/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent / "_run" + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-$b25pq-s+(_zx2!2$+i+^0$kft0&y3kwmj7j5a#d_jop)$d061" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + "yt2podcast.apps.Yt2PodcastConfig", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "ucast.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "ucast.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/4.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "static" + +DATA_ROOT = BASE_DIR / "data" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/ucast/types.py b/ucast/types.py deleted file mode 100644 index 2b54726..0000000 --- a/ucast/types.py +++ /dev/null @@ -1,6 +0,0 @@ -# coding=utf-8 -from os import PathLike -from typing import Tuple, Union - -Color = Tuple[int, int, int] -Path = Union[str, bytes, PathLike] diff --git a/ucast/urls.py b/ucast/urls.py new file mode 100644 index 0000000..7d590bd --- /dev/null +++ b/ucast/urls.py @@ -0,0 +1,21 @@ +"""ucast URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/ucast/util.py b/ucast/util.py deleted file mode 100644 index 15cb146..0000000 --- a/ucast/util.py +++ /dev/null @@ -1,9 +0,0 @@ -# coding=utf-8 -import requests - -from ucast import types - - -def download_file(url: str, download_path: types.Path): - r = requests.get(url, allow_redirects=True) - open(download_path, 'wb').write(r.content) diff --git a/ucast/wsgi.py b/ucast/wsgi.py new file mode 100644 index 0000000..06375f4 --- /dev/null +++ b/ucast/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for ucast project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") + +application = get_wsgi_application() diff --git a/yt2podcast/__init__.py b/yt2podcast/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yt2podcast/admin.py b/yt2podcast/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/yt2podcast/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/yt2podcast/apps.py b/yt2podcast/apps.py new file mode 100644 index 0000000..563d9a1 --- /dev/null +++ b/yt2podcast/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class Yt2PodcastConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "yt2podcast" diff --git a/yt2podcast/migrations/0001_initial.py b/yt2podcast/migrations/0001_initial.py new file mode 100644 index 0000000..fed1734 --- /dev/null +++ b/yt2podcast/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 4.0.4 on 2022-05-05 00:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Channel", + fields=[ + ( + "id", + models.CharField(max_length=30, primary_key=True, serialize=False), + ), + ("name", models.CharField(max_length=100)), + ("active", models.BooleanField(default=True)), + ("skip_livestreams", models.BooleanField(default=True)), + ("skip_shorts", models.BooleanField(default=True)), + ("keep_videos", models.IntegerField(default=None, null=True)), + ], + ), + migrations.CreateModel( + name="Video", + fields=[ + ( + "id", + models.CharField(max_length=30, primary_key=True, serialize=False), + ), + ("title", models.CharField(max_length=200)), + ("slug", models.CharField(max_length=209)), + ("published", models.DateTimeField()), + ("downloaded", models.DateTimeField()), + ("description", models.TextField()), + ], + ), + ] diff --git a/yt2podcast/migrations/__init__.py b/yt2podcast/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yt2podcast/models.py b/yt2podcast/models.py new file mode 100644 index 0000000..486abad --- /dev/null +++ b/yt2podcast/models.py @@ -0,0 +1,19 @@ +from django.db import models + + +class Channel(models.Model): + id = models.CharField(max_length=30, primary_key=True) + name = models.CharField(max_length=100) + active = models.BooleanField(default=True) + skip_livestreams = models.BooleanField(default=True) + skip_shorts = models.BooleanField(default=True) + keep_videos = models.IntegerField(null=True, default=None) + + +class Video(models.Model): + id = models.CharField(max_length=30, primary_key=True) + title = models.CharField(max_length=200) + slug = models.CharField(max_length=209) + published = models.DateTimeField() + downloaded = models.DateTimeField() + description = models.TextField() diff --git a/yt2podcast/service/__init__.py b/yt2podcast/service/__init__.py new file mode 100644 index 0000000..9bad579 --- /dev/null +++ b/yt2podcast/service/__init__.py @@ -0,0 +1 @@ +# coding=utf-8 diff --git a/ucast/cover.py b/yt2podcast/service/cover.py similarity index 68% rename from ucast/cover.py rename to yt2podcast/service/cover.py index e4a4aa7..38db4da 100644 --- a/ucast/cover.py +++ b/yt2podcast/service/cover.py @@ -1,40 +1,43 @@ # coding=utf-8 import math -from typing import Tuple, List, Optional +from pathlib import Path +from typing import List, Optional, Tuple -from PIL import Image, ImageDraw, ImageFont -from colorthief import ColorThief import wcag_contrast_ratio +from colorthief import ColorThief from fonts.ttf import SourceSansPro +from PIL import Image, ImageDraw, ImageFont -from ucast import types +from yt2podcast.service import typ -CHAR_ELLIPSIS = '…' +CHAR_ELLIPSIS = "…" COVER_WIDTH = 500 -def _split_text(height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0) -> List[str]: +def _split_text( + height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0 +) -> List[str]: if height < font.size: return [] max_lines = math.floor((height - font.size) / (font.size + line_spacing)) + 1 lines = [] - line = '' + line = "" - for word in text.split(' '): + for word in text.split(" "): if len(lines) >= max_lines: line = word break - if line == '': + if line == "": nline = word else: - nline = line + ' ' + word + nline = line + " " + word if font.getsize(nline)[0] <= width: line = nline - elif line != '': + elif line != "": lines.append(line) line = word else: @@ -46,14 +49,14 @@ def _split_text(height: int, width: int, text: str, font: ImageFont.FreeTypeFont lines.append(nline_e) break - if line != '': + if line != "": if len(lines) >= max_lines: # Drop the last line and add ... to the end lastline = lines[-1] + CHAR_ELLIPSIS if font.getsize(lastline)[0] <= width: lines[-1] = lastline else: - i_last_space = lines[-1].rfind(' ') + i_last_space = lines[-1].rfind(" ") lines[-1] = lines[-1][:i_last_space] + CHAR_ELLIPSIS else: lines.append(line) @@ -61,8 +64,15 @@ def _split_text(height: int, width: int, text: str, font: ImageFont.FreeTypeFont return lines -def _draw_text_box(draw: ImageDraw.ImageDraw, box: Tuple[int, int, int, int], text: str, font: ImageFont.FreeTypeFont, - color: types.Color = (0, 0, 0), line_spacing=0, vertical_center=True): +def _draw_text_box( + draw: ImageDraw.ImageDraw, + box: Tuple[int, int, int, int], + text: str, + font: ImageFont.FreeTypeFont, + color: typ.Color = (0, 0, 0), + line_spacing=0, + vertical_center=True, +): x_tl, y_tl, x_br, y_br = box height = y_br - y_tl width = x_br - x_tl @@ -85,13 +95,13 @@ def _get_dominant_color(img: Image.Image): return thief.get_color() -def _interpolate_color(color_from: types.Color, color_to: types.Color, interval: int): +def _interpolate_color(color_from: typ.Color, color_to: typ.Color, interval: int): det_co = [(t - f) / interval for f, t in zip(color_from, color_to)] for i in range(interval): yield [round(f + det * i) for f, det in zip(color_from, det_co)] -def _get_text_color(bg_color) -> types.Color: +def _get_text_color(bg_color) -> typ.Color: color_decimal = tuple([c / 255 for c in bg_color]) c_blk = wcag_contrast_ratio.rgb((0, 0, 0), color_decimal) c_wht = wcag_contrast_ratio.rgb((1, 1, 1), color_decimal) @@ -100,7 +110,9 @@ def _get_text_color(bg_color) -> types.Color: return 0, 0, 0 -def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], title: str, channel: str) -> Image.Image: +def _create_cover_image( + thumbnail: Image.Image, avatar: Optional[Image.Image], title: str, channel: str +) -> Image.Image: # Scale the thumbnail image down to cover size tn_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height) tn = thumbnail.resize((COVER_WIDTH, tn_height), Image.Resampling.LANCZOS) @@ -112,11 +124,13 @@ def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], t bottom_color = _get_dominant_color(bottom_part) # Create new cover image - cover = Image.new('RGB', (COVER_WIDTH, COVER_WIDTH)) + cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH)) cover_draw = ImageDraw.Draw(cover) # Draw background gradient - for i, color in enumerate(_interpolate_color(top_color, bottom_color, cover.height)): + for i, color in enumerate( + _interpolate_color(top_color, bottom_color, cover.height) + ): cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1) # Insert thumbnail image in the middle @@ -133,7 +147,7 @@ def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], t avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS) - circle_mask = Image.new('L', (avt_size, avt_size)) + circle_mask = Image.new("L", (avt_size, avt_size)) circle_mask_draw = ImageDraw.Draw(circle_mask) circle_mask_draw.ellipse((0, 0, avt_size, avt_size), 255) @@ -149,18 +163,43 @@ def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], t top_text_color = _get_text_color(top_color) bottom_text_color = _get_text_color(bottom_color) - _draw_text_box(cover_draw, (text_margin_topleft, text_vertical_offset, COVER_WIDTH - text_margin_x, tn_margin), - channel, - fnt, top_text_color, text_line_space) - _draw_text_box(cover_draw, - (text_margin_x, COVER_WIDTH - tn_margin + text_vertical_offset, - COVER_WIDTH - text_margin_x, COVER_WIDTH), title, fnt, bottom_text_color, text_line_space) + _draw_text_box( + cover_draw, + ( + text_margin_topleft, + text_vertical_offset, + COVER_WIDTH - text_margin_x, + tn_margin, + ), + channel, + fnt, + top_text_color, + text_line_space, + ) + _draw_text_box( + cover_draw, + ( + text_margin_x, + COVER_WIDTH - tn_margin + text_vertical_offset, + COVER_WIDTH - text_margin_x, + COVER_WIDTH, + ), + title, + fnt, + bottom_text_color, + text_line_space, + ) return cover -def create_cover_file(thumbnail_path: types.Path, avatar_path: Optional[types.Path], title: str, channel: str, - cover_path: types.Path): +def create_cover_file( + thumbnail_path: Path, + avatar_path: Optional[Path], + title: str, + channel: str, + cover_path: Path, +): thumbnail = Image.open(thumbnail_path) avatar = None diff --git a/yt2podcast/service/typ.py b/yt2podcast/service/typ.py new file mode 100644 index 0000000..dbf0d5a --- /dev/null +++ b/yt2podcast/service/typ.py @@ -0,0 +1,4 @@ +# coding=utf-8 +from typing import Tuple + +Color = Tuple[int, int, int] diff --git a/yt2podcast/service/util.py b/yt2podcast/service/util.py new file mode 100644 index 0000000..a65bc97 --- /dev/null +++ b/yt2podcast/service/util.py @@ -0,0 +1,7 @@ +# coding=utf-8 +import requests + + +def download_file(url: str, download_path): + r = requests.get(url, allow_redirects=True) + open(download_path, "wb").write(r.content) diff --git a/ucast/youtube.py b/yt2podcast/service/youtube.py similarity index 58% rename from ucast/youtube.py rename to yt2podcast/service/youtube.py index ac68a6b..8e56d79 100644 --- a/ucast/youtube.py +++ b/yt2podcast/service/youtube.py @@ -1,16 +1,16 @@ # coding=utf-8 -from operator import itemgetter import json from dataclasses import dataclass +from operator import itemgetter -from yt_dlp import YoutubeDL -from scrapetube import scrapetube import requests +from scrapetube import scrapetube +from yt_dlp import YoutubeDL def get_thumbnail_url(vinfo): """Get the best quality thumbnail""" - return max(vinfo['thumbnails'], key=itemgetter('preference'))['url'] + return max(vinfo["thumbnails"], key=itemgetter("preference"))["url"] def get_video_info(video_id): @@ -20,29 +20,25 @@ def get_video_info(video_id): def download_video(video_id, download_path, sponsorblock=False): ydl_params = { - 'format': 'bestaudio', - 'postprocessors': [ - { - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3' - }, + "format": "bestaudio", + "postprocessors": [ + {"key": "FFmpegExtractAudio", "preferredcodec": "mp3"}, ], - 'outtmpl': download_path, + "outtmpl": download_path, } if sponsorblock: # noinspection PyTypeChecker - ydl_params['postprocessors'].extend([ - { - 'key': 'SponsorBlock', - 'categories': ['sponsor'], - 'when': 'after_filter' - }, - { - 'key': 'ModifyChapters', - 'remove_sponsor_segments': ['sponsor'] - } - ]) + ydl_params["postprocessors"].extend( + [ + { + "key": "SponsorBlock", + "categories": ["sponsor"], + "when": "after_filter", + }, + {"key": "ModifyChapters", "remove_sponsor_segments": ["sponsor"]}, + ] + ) with YoutubeDL(ydl_params) as ydl: # extract_info downloads the video and returns its metadata @@ -61,7 +57,8 @@ def get_channel_metadata(channel_url): session = requests.Session() session.headers[ "User-Agent" - ] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36" + ] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ +(KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36" url = f"{channel_url}/videos?view=0&flow=grid" @@ -69,11 +66,11 @@ def get_channel_metadata(channel_url): data = json.loads( scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}" ) - metadata = data['metadata']['channelMetadataRenderer'] + metadata = data["metadata"]["channelMetadataRenderer"] - channel_id = metadata['externalId'] - name = metadata['title'] - description = metadata['description'] - avatar = metadata['avatar']['thumbnails'][0]['url'] + channel_id = metadata["externalId"] + name = metadata["title"] + description = metadata["description"] + avatar = metadata["avatar"]["thumbnails"][0]["url"] return ChannelMetadata(channel_id, name, description, avatar) diff --git a/tests/__init__.py b/yt2podcast/tests/__init__.py similarity index 50% rename from tests/__init__.py rename to yt2podcast/tests/__init__.py index 3a4d86b..b383d20 100644 --- a/tests/__init__.py +++ b/yt2podcast/tests/__init__.py @@ -1,4 +1,4 @@ # coding=utf-8 from importlib.resources import files -DIR_TESTFILES = files('tests.testfiles') +DIR_TESTFILES = files("yt2podcast.tests.testfiles") diff --git a/yt2podcast/tests/test_cover.py b/yt2podcast/tests/test_cover.py new file mode 100644 index 0000000..518ac25 --- /dev/null +++ b/yt2podcast/tests/test_cover.py @@ -0,0 +1,115 @@ +# coding=utf-8 +import tempfile +from pathlib import Path +from typing import List + +import pytest +from fonts.ttf import SourceSansPro +from PIL import Image, ImageChops, ImageFont + +from yt2podcast import tests +from yt2podcast.service import cover, typ + + +@pytest.mark.parametrize( + "height,width,text,expect", + [ + (40, 300, "Hello", ["Hello"]), + (40, 300, "Hello World, this is me", ["Hello World,…"]), + (90, 300, "Hello World, this is me", ["Hello World, this", "is me"]), + ( + 90, + 300, + "Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz", + ["Rindfleischettik…"], + ), + ( + 1000, + 300, + "Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! \ +Du nicht von Gott, Tyrann!", + [ + "Ha! du wärst", + "Obrigkeit von", + "Gott? Gott", + "spendet Segen", + "aus; du raubst!", + "Du nicht von Gott,", + "Tyrann!", + ], + ), + ], +) +def test_split_text(height: int, width: int, text: str, expect: List[str]): + font = ImageFont.truetype(SourceSansPro, 40) + lines = cover._split_text(height, width, text, font, 8) + assert lines == expect + + +@pytest.mark.parametrize( + "file_name,color", + [ + ("t1.webp", (63, 63, 62)), + ("t2.webp", (74, 45, 37)), + ("t3.webp", (54, 24, 28)), + ], +) +def test_get_dominant_color(file_name: str, color: typ.Color): + img = Image.open(tests.DIR_TESTFILES / "thumbnail" / file_name) + c = cover._get_dominant_color(img) + assert c == color + + +@pytest.mark.parametrize( + "bg_color,text_color", + [ + ((100, 0, 0), (255, 255, 255)), + ((200, 200, 0), (0, 0, 0)), + ], +) +def test_get_text_color(bg_color: typ.Color, text_color: typ.Color): + c = cover._get_text_color(bg_color) + assert c == text_color + + +@pytest.mark.parametrize( + "n_image,title,channel", + [ + (1, "ThetaDev @ Embedded World 2019", "ThetaDev"), + (2, "Sintel - Open Movie by Blender Foundation", "Blender"), + (3, "Systemabsturz Teaser zur DiVOC bb3", "media.ccc.de"), + ], +) +def test_create_cover_image(n_image: int, title: str, channel: str): + tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{n_image}.webp" + av_file = tests.DIR_TESTFILES / "avatar" / f"a{n_image}.jpg" + expected_cv_file = tests.DIR_TESTFILES / "cover" / f"c{n_image}.png" + + tn_image = Image.open(tn_file) + av_image = Image.open(av_file) + expected_cv_image = Image.open(expected_cv_file) + + cv_image = cover._create_cover_image(tn_image, av_image, title, channel) + + diff = ImageChops.difference(cv_image, expected_cv_image) + assert diff.getbbox() is None + + +def test_create_cover_file(): + tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp" + av_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg" + expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1.png" + + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + cv_file = tmpdir / "cover.png" + + cover.create_cover_file( + tn_file, av_file, "ThetaDev @ Embedded World 2019", "ThetaDev", cv_file + ) + + cv_image = Image.open(cv_file) + expected_cv_image = Image.open(expected_cv_file) + + diff = ImageChops.difference(cv_image, expected_cv_image) + assert diff.getbbox() is None diff --git a/tests/testfiles/avatar/a1.jpg b/yt2podcast/tests/testfiles/avatar/a1.jpg similarity index 100% rename from tests/testfiles/avatar/a1.jpg rename to yt2podcast/tests/testfiles/avatar/a1.jpg diff --git a/tests/testfiles/avatar/a2.jpg b/yt2podcast/tests/testfiles/avatar/a2.jpg similarity index 100% rename from tests/testfiles/avatar/a2.jpg rename to yt2podcast/tests/testfiles/avatar/a2.jpg diff --git a/tests/testfiles/avatar/a3.jpg b/yt2podcast/tests/testfiles/avatar/a3.jpg similarity index 100% rename from tests/testfiles/avatar/a3.jpg rename to yt2podcast/tests/testfiles/avatar/a3.jpg diff --git a/tests/testfiles/cover/c1.png b/yt2podcast/tests/testfiles/cover/c1.png similarity index 100% rename from tests/testfiles/cover/c1.png rename to yt2podcast/tests/testfiles/cover/c1.png diff --git a/tests/testfiles/cover/c2.png b/yt2podcast/tests/testfiles/cover/c2.png similarity index 100% rename from tests/testfiles/cover/c2.png rename to yt2podcast/tests/testfiles/cover/c2.png diff --git a/tests/testfiles/cover/c3.png b/yt2podcast/tests/testfiles/cover/c3.png similarity index 100% rename from tests/testfiles/cover/c3.png rename to yt2podcast/tests/testfiles/cover/c3.png diff --git a/tests/testfiles/sources.md b/yt2podcast/tests/testfiles/sources.md similarity index 100% rename from tests/testfiles/sources.md rename to yt2podcast/tests/testfiles/sources.md diff --git a/tests/testfiles/thumbnail/t1.webp b/yt2podcast/tests/testfiles/thumbnail/t1.webp similarity index 100% rename from tests/testfiles/thumbnail/t1.webp rename to yt2podcast/tests/testfiles/thumbnail/t1.webp diff --git a/tests/testfiles/thumbnail/t2.webp b/yt2podcast/tests/testfiles/thumbnail/t2.webp similarity index 100% rename from tests/testfiles/thumbnail/t2.webp rename to yt2podcast/tests/testfiles/thumbnail/t2.webp diff --git a/tests/testfiles/thumbnail/t3.webp b/yt2podcast/tests/testfiles/thumbnail/t3.webp similarity index 100% rename from tests/testfiles/thumbnail/t3.webp rename to yt2podcast/tests/testfiles/thumbnail/t3.webp diff --git a/yt2podcast/views.py b/yt2podcast/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/yt2podcast/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here.