diff --git a/.gitignore b/.gitignore index 366f892..d4414bf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ node_modules *.mp3 # Application data -/_run +/_run* *.sqlite3 # Generated assets diff --git a/deploy/docker-compose.yml b/deploy/docker-compose_develop.yml similarity index 100% rename from deploy/docker-compose.yml rename to deploy/docker-compose_develop.yml diff --git a/notes/Speicher.md b/notes/Speicher.md index 7b3857e..215bb94 100644 --- a/notes/Speicher.md +++ b/notes/Speicher.md @@ -5,14 +5,19 @@ ```txt _ data |_ LinusTechTips - |_ .ucast - |_ avatar.png # Profilbild des Kanals - |_ feed.xml # RSS-Feed + |_ _ucast + |_ avatar.jpg # Profilbild des Kanals + |_ avatar_sm.webp |_ covers # Cover-Bilder - |_ 220409_Building a _1_000_000 Computer.png - |_ 220410_Apple makes GREAT Gaming Computers.png - |_ 220409_Building a _1_000_000 Computer.mp3 - |_ 220410_Apple makes GREAT Gaming Computers.mp3 + |_ 220409_Building_a_1_000_000_Computer.png + |_ 220410_Apple_makes_GREAT_Gaming_Computers.png + |_ thumbnails + |_ 220409_Building_a_1_000_000_Computer.webp + |_ 220409_Building_a_1_000_000_Computer_sm.webp + |_ 220410_Apple_makes_GREAT_Gaming_Computers.webp + |_ 220410_Apple_makes_GREAT_Gaming_Computers_sm.webp + |_ 220409_Building_a_1_000_000_Computer.mp3 + |_ 220410_Apple_makes_GREAT_Gaming_Computers.mp3 |_ Andreas Spiess |_ ... diff --git a/poetry.lock b/poetry.lock index eeea053..6889046 100644 --- a/poetry.lock +++ b/poetry.lock @@ -9,6 +9,14 @@ python-versions = ">=3.7" [package.extras] tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "atomicwrites" version = "1.4.0" @@ -31,17 +39,6 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] -[[package]] -name = "bordercrop" -version = "1.0.0" -description = "A black borders cropping module" -category = "main" -optional = false -python-versions = ">=3.2, <4" - -[package.dependencies] -Pillow = "*" - [[package]] name = "brotli" version = "1.0.9" @@ -99,11 +96,22 @@ python-versions = ">=3.5.0" [package.extras] unicode_backport = ["unicodedata2"] +[[package]] +name = "click" +version = "8.1.3" +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 = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -120,7 +128,7 @@ Pillow = "*" [[package]] name = "coverage" -version = "6.3.2" +version = "6.3.3" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -132,6 +140,20 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["tomli"] +[[package]] +name = "deprecated" +version = "1.2.13" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] + [[package]] name = "distlib" version = "0.3.4" @@ -236,7 +258,7 @@ python-versions = "*" [[package]] name = "invoke" -version = "1.7.0" +version = "1.7.1" description = "Pythonic task execution" category = "dev" optional = false @@ -270,7 +292,7 @@ python-versions = "*" name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -363,9 +385,9 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pyparsing" -version = "3.0.8" +version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" +category = "main" optional = false python-versions = ">=3.6.8" @@ -442,6 +464,23 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "redis" +version = "4.3.1" +description = "Python client for Redis database and key-value store" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = ">=4.0.2" +deprecated = ">=1.2.3" +packaging = ">=20.4" + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "requests" version = "2.27.1" @@ -469,16 +508,16 @@ optional = false python-versions = "*" [[package]] -name = "scrapetube" -version = "2.2.2" -description = "Scrape youtube without the official youtube api and without selenium." +name = "rq" +version = "1.10.1" +description = "RQ is a simple, lightweight, library for creating background jobs, and processing them." category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.5" [package.dependencies] -requests = "*" -typing-extensions = "*" +click = ">=5.0.0" +redis = ">=3.5.0" [[package]] name = "sgmllib3k" @@ -496,6 +535,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "slugify" +version = "0.0.1" +description = "A generic slugifier." +category = "main" +optional = false +python-versions = "*" + [[package]] name = "sqlparse" version = "0.4.2" @@ -520,14 +567,6 @@ category = "dev" optional = false python-versions = ">=3.7" -[[package]] -name = "typing-extensions" -version = "4.2.0" -description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" -optional = false -python-versions = ">=3.7" - [[package]] name = "tzdata" version = "2022.1" @@ -583,6 +622,14 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + [[package]] name = "yt-dlp" version = "2022.4.8" @@ -602,13 +649,17 @@ websockets = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "8609785f53a44a16f3c5c1d5042ab2627bb198f3c7daa8ea18e55bf1e66c4345" +content-hash = "2d9aa9c628676b6c9981964a7e01a8d0b0a291025b695c5d98441d29720bced0" [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"}, ] +async-timeout = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -617,10 +668,6 @@ attrs = [ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] -bordercrop = [ - {file = "bordercrop-1.0.0-py3-none-any.whl", hash = "sha256:50342a4a7d3b37bd1188faf3bedcb4d4b264c3d7cc51a59d082d3afeaab86c0f"}, - {file = "bordercrop-1.0.0.tar.gz", hash = "sha256:2cfd078f8214fcecc304ee9bc8e96b38c9decc3db96ee5301e31e60678322990"}, -] brotli = [ {file = "Brotli-1.0.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70"}, {file = "Brotli-1.0.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b"}, @@ -781,6 +828,10 @@ 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.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -790,47 +841,51 @@ colorthief = [ {file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"}, ] coverage = [ - {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, - {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, - {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, - {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, - {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, - {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, - {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, - {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, - {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, - {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, - {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, - {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, - {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, + {file = "coverage-6.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df32ee0f4935a101e4b9a5f07b617d884a531ed5666671ff6ac66d2e8e8246d8"}, + {file = "coverage-6.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75b5dbffc334e0beb4f6c503fb95e6d422770fd2d1b40a64898ea26d6c02742d"}, + {file = "coverage-6.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:114944e6061b68a801c5da5427b9173a0dd9d32cd5fcc18a13de90352843737d"}, + {file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab88a01cd180b5640ccc9c47232e31924d5f9967ab7edd7e5c91c68eee47a69"}, + {file = "coverage-6.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad8f9068f5972a46d50fe5f32c09d6ee11da69c560fcb1b4c3baea246ca4109b"}, + {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4cd696aa712e6cd16898d63cf66139dc70d998f8121ab558f0e1936396dbc579"}, + {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c1a9942e282cc9d3ed522cd3e3cab081149b27ea3bda72d6f61f84eaf88c1a63"}, + {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c06455121a089252b5943ea682187a4e0a5cf0a3fb980eb8e7ce394b144430a9"}, + {file = "coverage-6.3.3-cp310-cp310-win32.whl", hash = "sha256:cb5311d6ccbd22578c80028c5e292a7ab9adb91bd62c1982087fad75abe2e63d"}, + {file = "coverage-6.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:6d4a6f30f611e657495cc81a07ff7aa8cd949144e7667c5d3e680d73ba7a70e4"}, + {file = "coverage-6.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79bf405432428e989cad7b8bc60581963238f7645ae8a404f5dce90236cc0293"}, + {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:338c417613f15596af9eb7a39353b60abec9d8ce1080aedba5ecee6a5d85f8d3"}, + {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db094a6a4ae6329ed322a8973f83630b12715654c197dd392410400a5bfa1a73"}, + {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1414e8b124611bf4df8d77215bd32cba6e3425da8ce9c1f1046149615e3a9a31"}, + {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:93b16b08f94c92cab88073ffd185070cdcb29f1b98df8b28e6649145b7f2c90d"}, + {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fbc86ae8cc129c801e7baaafe3addf3c8d49c9c1597c44bdf2d78139707c3c62"}, + {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b5ba058610e8289a07db2a57bce45a1793ec0d3d11db28c047aae2aa1a832572"}, + {file = "coverage-6.3.3-cp37-cp37m-win32.whl", hash = "sha256:8329635c0781927a2c6ae068461e19674c564e05b86736ab8eb29c420ee7dc20"}, + {file = "coverage-6.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e5af1feee71099ae2e3b086ec04f57f9950e1be9ecf6c420696fea7977b84738"}, + {file = "coverage-6.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e814a4a5a1d95223b08cdb0f4f57029e8eab22ffdbae2f97107aeef28554517e"}, + {file = "coverage-6.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f4fbf3633cb0713437291b8848634ea97f89c7e849c2be17a665611e433f53"}, + {file = "coverage-6.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3401b0d2ed9f726fadbfa35102e00d1b3547b73772a1de5508ef3bdbcb36afe7"}, + {file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8586b177b4407f988731eb7f41967415b2197f35e2a6ee1a9b9b561f6323c8e9"}, + {file = "coverage-6.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:892e7fe32191960da559a14536768a62e83e87bbb867e1b9c643e7e0fbce2579"}, + {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:afb03f981fadb5aed1ac6e3dd34f0488e1a0875623d557b6fad09b97a942b38a"}, + {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cbe91bc84be4e5ef0b1480d15c7b18e29c73bdfa33e07d3725da7d18e1b0aff2"}, + {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:91502bf27cbd5c83c95cfea291ef387469f2387508645602e1ca0fd8a4ba7548"}, + {file = "coverage-6.3.3-cp38-cp38-win32.whl", hash = "sha256:c488db059848702aff30aa1d90ef87928d4e72e4f00717343800546fdbff0a94"}, + {file = "coverage-6.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6534fcdfb5c503affb6b1130db7b5bfc8a0f77fa34880146f7a5c117987d0"}, + {file = "coverage-6.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc692c9ee18f0dd3214843779ba6b275ee4bb9b9a5745ba64265bce911aefd1a"}, + {file = "coverage-6.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:462105283de203df8de58a68c1bb4ba2a8a164097c2379f664fa81d6baf94b81"}, + {file = "coverage-6.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc972d829ad5ef4d4c5fcabd2bbe2add84ce8236f64ba1c0c72185da3a273130"}, + {file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06f54765cdbce99901871d50fe9f41d58213f18e98b170a30ca34f47de7dd5e8"}, + {file = "coverage-6.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7835f76a081787f0ca62a53504361b3869840a1620049b56d803a8cb3a9eeea3"}, + {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6f5fee77ec3384b934797f1873758f796dfb4f167e1296dc00f8b2e023ce6ee9"}, + {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:baa8be8aba3dd1e976e68677be68a960a633a6d44c325757aefaa4d66175050f"}, + {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d06380e777dd6b35ee936f333d55b53dc4a8271036ff884c909cf6e94be8b6c"}, + {file = "coverage-6.3.3-cp39-cp39-win32.whl", hash = "sha256:f8cabc5fd0091976ab7b020f5708335033e422de25e20ddf9416bdce2b7e07d8"}, + {file = "coverage-6.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c9441d57b0963cf8340268ad62fc83de61f1613034b79c2b1053046af0c5284"}, + {file = "coverage-6.3.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:d522f1dc49127eab0bfbba4e90fa068ecff0899bbf61bf4065c790ddd6c177fe"}, + {file = "coverage-6.3.3.tar.gz", hash = "sha256:2781c43bffbbec2b8867376d4d61916f5e9c4cc168232528562a61d1b4b01879"}, +] +deprecated = [ + {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, + {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, ] distlib = [ {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, @@ -875,8 +930,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] invoke = [ - {file = "invoke-1.7.0-py3-none-any.whl", hash = "sha256:a5159fc63dba6ca2a87a1e33d282b99cea69711b03c64a35bb4e1c53c6c4afa0"}, - {file = "invoke-1.7.0.tar.gz", hash = "sha256:e332e49de40463f2016315f51df42313855772be86435686156bc18f45b5cc6c"}, + {file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"}, + {file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"}, ] mutagen = [ {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, @@ -1000,8 +1055,8 @@ pycryptodomex = [ {file = "pycryptodomex-3.14.1.tar.gz", hash = "sha256:2ce76ed0081fd6ac8c74edc75b9d14eca2064173af79843c24fa62573263c1f2"}, ] pyparsing = [ - {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, - {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytest = [ {file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, @@ -1054,6 +1109,10 @@ pyyaml = [ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] +redis = [ + {file = "redis-4.3.1-py3-none-any.whl", hash = "sha256:84316970995a7adb907a56754d2b92d88fc2d252963dc5ac34c88f0f1a22c25d"}, + {file = "redis-4.3.1.tar.gz", hash = "sha256:94b617b4cd296e94991146f66fc5559756fbefe9493604f0312e4d3298ac63e9"}, +] requests = [ {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, @@ -1061,8 +1120,9 @@ requests = [ rfeed = [ {file = "rfeed-1.1.1.tar.gz", hash = "sha256:aa9506f2866b74f5a322d394a14a63c19a6825c2d94755ff19d46dd1e2434819"}, ] -scrapetube = [ - {file = "scrapetube-2.2.2-py3-none-any.whl", hash = "sha256:73aef77d42aa182bcd3cc7f9ebee28bc01d6b34d615d205679ebc54be1f9807f"}, +rq = [ + {file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"}, + {file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -1071,6 +1131,9 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +slugify = [ + {file = "slugify-0.0.1.tar.gz", hash = "sha256:c5703cc11c1a6947536f3ce8bb306766b8bb5a84a53717f5a703ce0f18235e4c"}, +] sqlparse = [ {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, @@ -1083,10 +1146,6 @@ 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.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"}, @@ -1152,6 +1211,72 @@ websockets = [ {file = "websockets-10.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55"}, {file = "websockets-10.3.tar.gz", hash = "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"}, ] +wrapt = [ + {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, + {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, + {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, + {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, + {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, + {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, + {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, + {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, + {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, + {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, + {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, + {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, + {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, + {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, + {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, + {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +] yt-dlp = [ {file = "yt-dlp-2022.4.8.tar.gz", hash = "sha256:8758d016509d4574b90fbde975aa70adaef71ed5e7a195141588f6d6945205ba"}, {file = "yt_dlp-2022.4.8-py2.py3-none-any.whl", hash = "sha256:6edefe326b1e1478fdbe627a66203e5248a6b0dd50c101e682cf700ab70cdf72"}, diff --git a/pyproject.toml b/pyproject.toml index d783024..e7115ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [ python = "^3.10" Django = "^4.0.4" yt-dlp = "^2022.3.8" -scrapetube = "^2.2.2" +requests = "^2.27.1" rfeed = "^1.1.1" feedparser = "^6.0.8" Pillow = "^9.1.0" @@ -20,11 +20,13 @@ colorthief = "^0.2.1" wcag-contrast-ratio = "^0.9" font-source-sans-pro = "^0.0.1" fonts = "^0.0.3" -bordercrop = "^1.0.0" django-bulma = "^0.8.3" python-dotenv = "^0.20.0" psycopg2 = "^2.9.3" mysqlclient = "^2.1.0" +slugify = "^0.0.1" +rq = "^1.10.1" +mutagen = "^1.45.1" [tool.poetry.dev-dependencies] pytest = "^7.1.1" @@ -41,10 +43,7 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.flake8] -max-line-length = 88 -per-file-ignores = [ - "settings.py:E501", -] +extend-ignore = "E501" [tool.black] line-length = 88 diff --git a/tasks.py b/tasks.py index 3c9a2df..e699f29 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,5 @@ import os +import shutil from pathlib import Path from invoke import Responder, task @@ -8,6 +9,11 @@ from ucast.service import cover, util, youtube os.chdir(Path(__file__).absolute().parent) +DIR_RUN = Path("_run").absolute() +DIR_STATIC = DIR_RUN / "static" +DIR_DOWNLOAD = DIR_RUN / "data" +FILE_DB = DIR_RUN / "db.sqlite" + @task def test(c): @@ -66,35 +72,46 @@ def get_cover(c, vid=""): The images are stored in the ``ucast/tests/testfiles`` directory. """ - vinfo = youtube.get_video_info(vid) + vinfo = youtube.get_video_details(vid) title = vinfo.title channel_name = vinfo.channel_name - channel_url = vinfo.channel_url - channel_metadata = youtube.get_channel_metadata(channel_url) + channel_id = vinfo.channel_id + channel_metadata = youtube.get_channel_metadata( + youtube.channel_url_from_id(channel_id) + ) ti = 1 - while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}_classic.png"): + while os.path.exists(tests.DIR_TESTFILES / "avatar" / f"a{ti}.jpg"): 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}_classic.png" + cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_gradient.png" cv_blur_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_blur.png" - youtube.download_thumbnail(vinfo, tn_file) + tn_file = youtube.download_thumbnail(vinfo, tn_file) util.download_file(channel_metadata.avatar_url, av_file) cover.create_cover_file( - tn_file, av_file, title, channel_name, cover.CoverStyle.CLASSIC, cv_file + tn_file, av_file, title, channel_name, cover.COVER_STYLE_GRADIENT, cv_file ) cover.create_cover_file( - tn_file, av_file, title, channel_name, cover.CoverStyle.BLUR, cv_blur_file + tn_file, av_file, title, channel_name, cover.COVER_STYLE_BLUR, cv_blur_file ) @task def build_devcontainer(c): c.run( - "docker buildx build -t thetadev256/ucast-dev --push \ ---platform amd64,arm64,armhf -f deploy/Devcontainer.Dockerfile deploy" + "docker buildx build -t thetadev256/ucast-dev --push --platform amd64,arm64,armhf -f deploy/Devcontainer.Dockerfile deploy" ) + + +@task +def reset(c): + if DIR_DOWNLOAD.exists(): + shutil.rmtree(DIR_DOWNLOAD) + if FILE_DB.exists(): + os.remove(FILE_DB) + migrate(c) + create_testuser(c) diff --git a/ucast/migrations/0001_initial.py b/ucast/migrations/0001_initial.py index d9d9bd5..014360e 100644 --- a/ucast/migrations/0001_initial.py +++ b/ucast/migrations/0001_initial.py @@ -1,5 +1,6 @@ # Generated by Django 4.0.4 on 2022-05-05 00:02 +import django.db.models.deletion from django.db import migrations, models @@ -18,6 +19,8 @@ class Migration(migrations.Migration): models.CharField(max_length=30, primary_key=True, serialize=False), ), ("name", models.CharField(max_length=100)), + ("slug", models.CharField(max_length=100)), + ("description", models.TextField()), ("active", models.BooleanField(default=True)), ("skip_livestreams", models.BooleanField(default=True)), ("skip_shorts", models.BooleanField(default=True)), @@ -33,9 +36,18 @@ class Migration(migrations.Migration): ), ("title", models.CharField(max_length=200)), ("slug", models.CharField(max_length=209)), + ( + "channel", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="ucast.channel" + ), + ), ("published", models.DateTimeField()), ("downloaded", models.DateTimeField(null=True)), ("description", models.TextField()), + ("duration", models.IntegerField()), + ("is_livestream", models.BooleanField(default=False)), + ("is_short", models.BooleanField(default=False)), ], ), ] diff --git a/ucast/models.py b/ucast/models.py index 7f6711e..f45b3cf 100644 --- a/ucast/models.py +++ b/ucast/models.py @@ -1,19 +1,63 @@ +import datetime + from django.db import models +from ucast.service import util + + +def _get_unique_slug( + str_in: str, objects: models.query.QuerySet, model_name: str +) -> str: + """ + Get a new, unique slug for a database item + + :param str_in: Input string to slugify + :param objects: Django query set + :return: Slug + """ + original_slug = util.get_slug(str_in) + slug = original_slug + + for i in range(1, objects.count() + 2): + if not objects.filter(slug=slug).exists(): + return slug + + slug = f"{original_slug}_{i}" + + raise Exception(f"unique {model_name} slug for {original_slug} could not be found") + class Channel(models.Model): id = models.CharField(max_length=30, primary_key=True) name = models.CharField(max_length=100) + slug = models.CharField(max_length=100) + description = models.TextField() 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) + @classmethod + def get_new_slug(cls, name: str) -> str: + return _get_unique_slug(name, cls.objects, "channel") + 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) + channel = models.ForeignKey(Channel, on_delete=models.CASCADE) published = models.DateTimeField() downloaded = models.DateTimeField(null=True) description = models.TextField() + duration = models.IntegerField() + is_livestream = models.BooleanField(default=False) + is_short = models.BooleanField(default=False) + + @classmethod + def get_new_slug(cls, title: str, date: datetime.date, channel_id: str) -> str: + title_w_date = f"{date.strftime('%Y%m%d')}_{title}" + + return _get_unique_slug( + title_w_date, cls.objects.filter(channel_id=channel_id), "video" + ) diff --git a/ucast/service/cover.py b/ucast/service/cover.py index 5e6444d..670268c 100644 --- a/ucast/service/cover.py +++ b/ucast/service/cover.py @@ -1,26 +1,23 @@ -import enum import math from importlib import resources from pathlib import Path -from typing import List, Optional, Tuple +from typing import List, Literal, Optional, Tuple import wcag_contrast_ratio -from bordercrop import bordercrop from colorthief import ColorThief from fonts.ttf import SourceSansPro from PIL import Image, ImageDraw, ImageFilter, ImageFont from ucast.service import typ +COVER_STYLE_BLUR = "blur" +COVER_STYLE_GRADIENT = "gradient" +CoverStyle = Literal["blur", "gradient"] + CHAR_ELLIPSIS = "…" COVER_WIDTH = 500 -class CoverStyle(enum.Enum): - CLASSIC = enum.auto() - BLUR = enum.auto() - - def _split_text( height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0 ) -> List[str]: @@ -180,22 +177,23 @@ def _get_baseimage( """ cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH)) - if style == CoverStyle.BLUR: - ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width) - ctn_x_left = int((ctn_width - COVER_WIDTH) / 2) - - ctn = thumbnail.resize( - (ctn_width, COVER_WIDTH), Image.Resampling.LANCZOS - ).filter(ImageFilter.GaussianBlur(20)) - cover.paste(ctn, (-ctn_x_left, 0)) - else: + if style == COVER_STYLE_GRADIENT: + # Thumbnail with color gradient background cover_draw = ImageDraw.Draw(cover) - # Draw background gradient for i, color in enumerate( _interpolate_color(top_color, bottom_color, cover.height) ): cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1) + else: + # Thumbnail with blurred background + ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width) + ctn_x_left = int((ctn_width - COVER_WIDTH) / 2) + + ctn = thumbnail.resize((ctn_width, COVER_WIDTH), Image.LANCZOS).filter( + ImageFilter.GaussianBlur(20) + ) + cover.paste(ctn, (-ctn_x_left, 0)) return cover @@ -207,12 +205,6 @@ def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image: :param thumbnail: Thumbnail image object :return: Resized thumbnail image object """ - thumbnail = bordercrop.crop( - thumbnail, - MINIMUM_ROWS=int(thumbnail.height * 0.1), - MINIMUM_THRESHOLD_HITTING=int(thumbnail.width * 0.3), - ) - # Scale the thumbnail image down to cover size tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height) tn_16_9_height = int(COVER_WIDTH / 16 * 9) @@ -220,9 +212,9 @@ def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image: tn_crop_y_top = int((tn_resize_height - tn_height) / 2) tn_crop_y_bottom = tn_resize_height - tn_crop_y_top - return thumbnail.resize( - (COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS - ).crop((0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom)) + return thumbnail.resize((COVER_WIDTH, tn_resize_height), Image.LANCZOS).crop( + (0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom) + ) def _draw_text_avatar( @@ -246,7 +238,7 @@ def _draw_text_avatar( avt_margin = int(tn_16_9_margin * 0.05) avt_size = tn_16_9_margin - 2 * avt_margin - avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS) + avt = avatar.resize((avt_size, avt_size), Image.LANCZOS) circle_mask = Image.new("L", (avt_size, avt_size)) circle_mask_draw = ImageDraw.Draw(circle_mask) diff --git a/ucast/service/scrapetube.py b/ucast/service/scrapetube.py new file mode 100644 index 0000000..d6b5b29 --- /dev/null +++ b/ucast/service/scrapetube.py @@ -0,0 +1,244 @@ +""" +Based on the scrapetube package from dermasmid (MIT License) +https://github.com/dermasmid/scrapetube +""" +import json +import time +from typing import Generator, Literal, Optional + +import requests + + +def get_channel( + channel_url: str, + limit: int = None, + sleep: int = 1, + sort_by: Literal["newest", "oldest", "popular"] = "newest", +) -> Generator[dict, None, None]: + """ + Get videos for a channel. + + :param channel_url: The url of the channel you want to get the videos for. + :param limit: Limit the number of videos you want to get. + :param sleep: Seconds to sleep between API calls to youtube, in order to prevent + getting blocked. Defaults to ``1``. + :param sort_by: In what order to retrive to videos. Pass one of the following values. + ``"newest"``: Get the new videos first. + ``"oldest"``: Get the old videos first. + ``"popular"``: Get the popular videos first. + Defaults to ``"newest"``. + :return: Generator providing the videos + """ + + sort_by_map = {"newest": "dd", "oldest": "da", "popular": "p"} + url = "{url}/videos?view=0&sort={sort_by}&flow=grid".format( + url=channel_url, + sort_by=sort_by_map[sort_by], + ) + api_endpoint = "https://www.youtube.com/youtubei/v1/browse" + videos = _get_videos(url, api_endpoint, "gridVideoRenderer", limit, sleep) + for video in videos: + yield video + + +def get_channel_metadata(channel_url: str) -> dict: + """ + Get metadata of a channel. + + :param channel_url: Channel URL + :return: Raw channel metadata + """ + session = _new_session() + + url = f"{channel_url}/videos?view=0&flow=grid" + + html = _get_initial_data(session, url) + return json.loads(_get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}") + + +def get_playlist( + playlist_id: str, limit: int = None, sleep: int = 1 +) -> Generator[dict, None, None]: + """ + Get videos for a playlist. + + :param playlist_id: The playlist id from the playlist you want to get the videos for. + :param limit: Limit the number of videos you want to get. + :param sleep: Seconds to sleep between API calls to youtube, in order to prevent + getting blocked. Defaults to ``1``. + :return: Generator providing the videos + """ + + url = f"https://www.youtube.com/playlist?list={playlist_id}" + api_endpoint = "https://www.youtube.com/youtubei/v1/browse" + videos = _get_videos(url, api_endpoint, "playlistVideoRenderer", limit, sleep) + for video in videos: + yield video + + +def get_search( + query: str, + limit: int = None, + sleep: int = 1, + sort_by: Literal["relevance", "upload_date", "view_count", "rating"] = "relevance", + results_type: Literal["video", "channel", "playlist", "movie"] = "video", +) -> Generator[dict, None, None]: + """ + Search youtube and get videos. + + :param query: The term you want to search for. + :param limit: Limit the number of videos you want to get. + :param sleep: Seconds to sleep between API calls to youtube, in order to prevent + getting blocked. Defaults to ``1``. + :param sort_by: In what order to retrive to videos. Pass one of the following values. + ``"relevance"``: Get the new videos in order of relevance. + ``"upload_date"``: Get the new videos first. + ``"view_count"``: Get the popular videos first. + ``"rating"``: Get videos with more likes first. + Defaults to ``"relevance"``. + :param results_type: What type you want to search for. + Pass one of the following values: ``"video"|"channel"| + "playlist"|"movie"``. Defaults to ``"video"``. + :return: Generator providing the videos + """ + + sort_by_map = { + "relevance": "A", + "upload_date": "I", + "view_count": "M", + "rating": "E", + } + + results_type_map = { + "video": ["B", "videoRenderer"], + "channel": ["C", "channelRenderer"], + "playlist": ["D", "playlistRenderer"], + "movie": ["E", "videoRenderer"], + } + + param_string = f"CA{sort_by_map[sort_by]}SAhA{results_type_map[results_type][0]}" + url = f"https://www.youtube.com/results?search_query={query}&sp={param_string}" + api_endpoint = "https://www.youtube.com/youtubei/v1/search" + videos = _get_videos( + url, api_endpoint, results_type_map[results_type][1], limit, sleep + ) + for video in videos: + yield video + + +def _get_videos( + url: str, api_endpoint: str, selector: str, limit: int, sleep: int +) -> Generator[dict, None, None]: + session = _new_session() + is_first = True + quit = False + count = 0 + while True: + if is_first: + html = _get_initial_data(session, url) + client = json.loads( + _get_json_from_html(html, "INNERTUBE_CONTEXT", 2, '"}},') + '"}}' + )["client"] + api_key = _get_json_from_html(html, "innertubeApiKey", 3) + session.headers["X-YouTube-Client-Name"] = "1" + session.headers["X-YouTube-Client-Version"] = client["clientVersion"] + data = json.loads( + _get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}" + ) + next_data = _get_next_data(data) + is_first = False + else: + data = _get_ajax_data(session, api_endpoint, api_key, next_data, client) + next_data = _get_next_data(data) + for result in _get_videos_items(data, selector): + try: + count += 1 + yield result + if count == limit: + quit = True + break + except GeneratorExit: + quit = True + break + + if not next_data or quit: + break + + time.sleep(sleep) + + session.close() + + +def _new_session() -> requests.Session: + 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" + session.headers["Accept-Language"] = "en" + return session + + +def _get_initial_data(session: requests.Session, url: str) -> str: + response = session.get(url) + response.raise_for_status() + + if "uxe=" in response.request.url: + session.cookies.set("CONSENT", "YES+cb", domain=".youtube.com") + response = session.get(url) + + html = response.text + return html + + +def _get_ajax_data( + session: requests.Session, + api_endpoint: str, + api_key: str, + next_data: dict, + client: dict, +) -> dict: + data = { + "context": {"clickTracking": next_data["click_params"], "client": client}, + "continuation": next_data["token"], + } + response = session.post(api_endpoint, params={"key": api_key}, json=data) + return response.json() + + +def _get_json_from_html( + html: str, key: str, num_chars: int = 2, stop: str = '"' +) -> str: + pos_begin = html.find(key) + len(key) + num_chars + pos_end = html.find(stop, pos_begin) + return html[pos_begin:pos_end] + + +def _get_next_data(data: dict) -> Optional[dict]: + raw_next_data = next(_search_dict(data, "continuationEndpoint"), None) + if not raw_next_data: + return None + next_data = { + "token": raw_next_data["continuationCommand"]["token"], + "click_params": {"clickTrackingParams": raw_next_data["clickTrackingParams"]}, + } + + return next_data + + +def _search_dict(partial: dict, search_key: str) -> Generator[dict, None, None]: + stack = [partial] + while stack: + current_item = stack.pop(0) + if isinstance(current_item, dict): + for key, value in current_item.items(): + if key == search_key: + yield value + else: + stack.append(value) + elif isinstance(current_item, list): + for value in current_item: + stack.append(value) + + +def _get_videos_items(data: dict, selector: str) -> Generator[dict, None, None]: + return _search_dict(data, selector) diff --git a/ucast/service/storage.py b/ucast/service/storage.py new file mode 100644 index 0000000..a8e41b7 --- /dev/null +++ b/ucast/service/storage.py @@ -0,0 +1,79 @@ +import os +from pathlib import Path +from typing import Tuple + +import slugify +from django.conf import settings + +UCAST_DIRNAME = "_ucast" + + +def _get_slug(str_in: str) -> str: + return slugify.slugify(str_in, lowercase=False, separator="_") + + +def _get_unique_slug(str_in: str, root_dir: Path, extension="") -> Tuple[Path, str]: + original_slug = _get_slug(str_in) + slug = original_slug + i = 0 + + while True: + testfile = root_dir / (slug + extension) + + if not testfile.exists(): + return testfile, slug + + i += 1 + slug = f"{original_slug}_{i}" + + +class ChannelFolder: + def __init__(self, dir_root: Path): + self.dir_root = dir_root + dir_ucast = self.dir_root / UCAST_DIRNAME + + self.file_avatar = dir_ucast / "avatar.jpg" + self.file_avatar_sm = dir_ucast / "avatar_sm.webp" + + self.dir_covers = dir_ucast / "covers" + self.dir_thumbnails = dir_ucast / "thumbnails" + + @staticmethod + def _glob_file(parent_dir: Path, glob: str, default_filename: str = None) -> Path: + try: + return parent_dir.glob(glob).__next__() + except StopIteration: + if default_filename: + return parent_dir / default_filename + raise FileNotFoundError(f"file {str(parent_dir)}/{glob} not found") + + def does_exist(self) -> bool: + return os.path.isdir(self.dir_covers) + + def create(self): + os.makedirs(self.dir_covers, exist_ok=True) + os.makedirs(self.dir_thumbnails, exist_ok=True) + + def get_cover(self, title_slug: str) -> Path: + return self.dir_covers / f"{title_slug}.png" + + def get_thumbnail(self, title_slug: str, sm=False) -> Path: + filename = title_slug + if sm: + filename += "_sm" + + return self._glob_file(self.dir_thumbnails, f"{filename}.*", f"{filename}.webp") + + def get_audio(self, title_slug: str) -> Path: + return self.dir_root / f"{title_slug}.mp3" + + +class Storage: + def __init__(self): + self.dir_data = settings.DOWNLOAD_ROOT + + def get_channel_folder(self, channel_slug: str) -> ChannelFolder: + cf = ChannelFolder(self.dir_data / channel_slug) + if not cf.does_exist(): + cf.create() + return cf diff --git a/ucast/service/util.py b/ucast/service/util.py index 011bedd..a918721 100644 --- a/ucast/service/util.py +++ b/ucast/service/util.py @@ -1,7 +1,47 @@ +import shutil +from pathlib import Path + import requests +import slugify +from PIL import Image + +AVATAR_SM_WIDTH = 100 +THUMBNAIL_SM_WIDTH = 360 -def download_file(url: str, download_path): +def download_file(url: str, download_path: Path): r = requests.get(url, allow_redirects=True) r.raise_for_status() open(download_path, "wb").write(r.content) + + +def download_image_file(url: str, download_path: Path) -> Path: + download_file(url, download_path) + img = Image.open(download_path) + img_ext = img.format.lower() + img.close() + + if img_ext == "jpeg": + img_ext = "jpg" + + new_path = download_path.with_suffix("." + img_ext) + shutil.move(download_path, new_path) + return new_path + + +def resize_avatar(original_file: Path, new_file: Path): + avatar = Image.open(original_file) + avatar_new_height = int(AVATAR_SM_WIDTH / avatar.width * avatar.height) + avatar = avatar.resize((AVATAR_SM_WIDTH, avatar_new_height), Image.LANCZOS) + avatar.save(new_file) + + +def resize_thumbnail(original_file: Path, new_file: Path): + thumbnail = Image.open(original_file) + tn_new_height = int(THUMBNAIL_SM_WIDTH / thumbnail.width * thumbnail.height) + thumbnail = thumbnail.resize((THUMBNAIL_SM_WIDTH, tn_new_height), Image.LANCZOS) + thumbnail.save(new_file) + + +def get_slug(str_in: str) -> str: + return slugify.slugify(str_in, lowercase=False, separator="_") diff --git a/ucast/service/youtube.py b/ucast/service/youtube.py index b02a3cd..04df14c 100644 --- a/ucast/service/youtube.py +++ b/ucast/service/youtube.py @@ -1,94 +1,139 @@ -import json +import datetime +import logging +import re +import shutil from dataclasses import dataclass -from datetime import datetime from operator import itemgetter +from pathlib import Path +from typing import List, Optional +import feedparser import requests -from scrapetube import scrapetube +from django.conf import settings +from mutagen import id3 from yt_dlp import YoutubeDL -from ucast.service import util +from ucast.service import scrapetube, util -class VideoInfo: - """Mapping of YoutubeDL's video information""" - - def __init__(self, info: dict): - self._info = info - - self.id = info["id"] - self.title = info["title"] - self.description = info["description"] - self.channel_id = info["channel_id"] - self.channel_name = info["uploader"] - self.duration = info["duration"] - self.published = self.__approx_published_time( - datetime.strptime(info["upload_date"], "%Y%m%d") - ) - self.thumbnails = info["thumbnails"] - self.is_currently_live = bool(info.get("is_live")) - self.is_livestream = info.get("is_live") or info.get("was_live") - self.is_short = self.duration <= 60 and info["width"] < info["height"] - - @staticmethod - def __approx_published_time(time_in: datetime) -> datetime: - """ - Assume that a video published on the current day is published now. - Eventually add an option to get the exact upload time from Google's API. - - :param time_in: - :return: - """ - now = datetime.now() - if time_in.date() == now.date(): - return now - return time_in - - def __str__(self): - return f"{self.title} ({self.id})" +class ItemNotFoundError(Exception): + pass class ThumbnailNotFoundError(Exception): pass -def download_thumbnail(vinfo: VideoInfo, download_path): +@dataclass +class VideoScraped: + """ + Video object, as it is scraped from the website/rss feed. + RSS feeds contain the second-accurate publishing date, which cannot + be scraped from the video info and is therefore included in this object. + """ + + id: str + published: Optional[datetime.datetime] + + def __str__(self): + return self.id + + +@dataclass +class VideoDetails: + """Mapping of YoutubeDL's video information""" + + id: str + title: str + description: str + channel_id: str + channel_name: str + duration: int + published: datetime.datetime + thumbnails: List[dict] + is_currently_live: bool + is_livestream: bool + is_short: bool + + @classmethod + def from_vinfo(cls, info: dict): + published_date = datetime.datetime.strptime( + info["upload_date"], "%Y%m%d" + ).replace(tzinfo=datetime.timezone.utc) + + return VideoDetails( + id=info["id"], + title=info["title"], + description=info["description"], + channel_id=info["channel_id"], + channel_name=info["uploader"], + duration=info["duration"], + published=published_date, + thumbnails=info["thumbnails"], + is_currently_live=bool(info.get("is_live")), + is_livestream=info.get("is_live") or info.get("was_live"), + is_short=info["duration"] <= 60 and info["width"] < info["height"], + ) + + def add_scraped_data(self, scraped: VideoScraped): + if scraped.id != self.id: + raise ValueError("scraped data does not belong to video") + + if scraped.published: + self.published = scraped.published + + +@dataclass +class ChannelMetadata: + """Channel information""" + + id: str + name: str + description: str + avatar_url: str + + +def download_thumbnail(vinfo: VideoDetails, download_path: Path) -> Path: """ Download the thumbnail image of a YouTube video and save it at the given filepath. - Does not add the correct file ending (jpg or webp), we are converting it with - Pillow anyway. + The thumbnail file ending is added to the path. :param vinfo: Video info (from ``get_video_info()``) :param download_path: Path of the thumbnail file :raise ThumbnailNotFoundError: if no thumbnail could be found (YT returned 404) + :return: Path with file ending """ + for tn in sorted(vinfo.thumbnails, key=itemgetter("preference"), reverse=True): url = tn["url"] - print(f"downloading thumbnail {url}...") + logging.info(f"downloading thumbnail {url}...") try: - util.download_file(url, download_path) - return + return util.download_image_file(url, download_path) except requests.HTTPError: - print(f"downloading thumbnail {url} failed") + logging.warning(f"downloading thumbnail {url} failed") pass raise ThumbnailNotFoundError(f"could not find thumbnail for video {vinfo}") -def get_video_info(video_id) -> VideoInfo: +def get_video_details(video_id: str) -> VideoDetails: with YoutubeDL() as ydl: info = ydl.extract_info(video_id, download=False) - return VideoInfo(info) + return VideoDetails.from_vinfo(info) -def download_video(video_id, download_path, sponsorblock=False) -> VideoInfo: +def download_audio( + video_id: str, download_path: Path, sponsorblock=False +) -> VideoDetails: + tmp_dld_file = download_path.with_suffix(".dld" + download_path.suffix) + ydl_params = { "format": "bestaudio", "postprocessors": [ {"key": "FFmpegExtractAudio", "preferredcodec": "mp3"}, ], - "outtmpl": str(download_path), + "outtmpl": str(tmp_dld_file), } if sponsorblock: @@ -107,34 +152,76 @@ def download_video(video_id, download_path, sponsorblock=False) -> VideoInfo: with YoutubeDL(ydl_params) as ydl: # extract_info downloads the video and returns its metadata info = ydl.extract_info(video_id) - return VideoInfo(info) + + shutil.move(tmp_dld_file, download_path) + return VideoDetails.from_vinfo(info) -@dataclass -class ChannelMetadata: - id: str - name: str - description: str - avatar_url: str +def tag_audio(audio_path: Path, vinfo: VideoDetails, cover_path: Path): + title_text = f"{vinfo.published.date().isoformat()} {vinfo.title}" + + audio = id3.ID3(audio_path) + audio["TPE1"] = id3.TPE1(encoding=3, text=vinfo.channel_name) # Artist + audio["TALB"] = id3.TALB(encoding=3, text=vinfo.channel_name) # Album + audio["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title + audio["TYER"] = id3.TYER(encoding=3, text=str(vinfo.published.year)) # Year + audio["TDAT"] = id3.TDAT(encoding=3, text=vinfo.published.strftime("%d%m")) # Date + audio["COMM"] = id3.COMM(encoding=3, text=f"YT-ID: {vinfo.id}") # Comment + + with open(cover_path, "rb") as albumart: + audio["APIC"] = id3.APIC( + encoding=3, mime="image/png", type=3, desc="Cover", data=albumart.read() + ) + audio.save() def channel_url_from_id(channel_id: str) -> str: return "https://www.youtube.com/channel/" + channel_id -def get_channel_metadata(channel_url: str) -> ChannelMetadata: - 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" +def channel_url_from_str(channel_str: str) -> str: + """ + Get the channel URL from user input. The following types are accepted: - url = f"{channel_url}/videos?view=0&flow=grid" + - Channel ID URL: https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q + - Vanity URL: https://www.youtube.com/c/MrBeast6000 + - User URL: https://www.youtube.com/user/LinusTechTips + - Channel ID: ``UCGiJh0NZ52wRhYKYnuZI08Q`` - html = scrapetube.get_initial_data(session, url) - data = json.loads( - scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}" + :param channel_str: Channel string + :return: Channel URL + """ + channel_url_regex = re.compile( + r"""(?:https?://)?[-a-zA-Z\d@:%._+~#=]+\.[a-zA-Z\d]{1,6}/(?:(channel|c|user)/)?([-_a-zA-Z\d]*)""" ) + + match = channel_url_regex.match(channel_str) + if match: + url_type = match[1] + # Vanity URL + if not url_type or url_type == "c": + return "https://www.youtube.com/c/" + match[2] + # Username + if url_type == "user": + return "https://www.youtube.com/user/" + match[2] + # Channel ID + return "https://www.youtube.com/channel/" + match[2] + + chanid_regex = re.compile(r"""[-_a-zA-Z\d]{24}""") + if chanid_regex.match(channel_str): + return "https://www.youtube.com/channel/" + channel_str + + raise ValueError("invalid channel string") + + +def get_channel_metadata(channel_url: str) -> ChannelMetadata: + """ + Get the metadata of a channel + + :param channel_url: Channel-URL + :return: Channel metadata + """ + data = scrapetube.get_channel_metadata(channel_url) metadata = data["metadata"]["channelMetadataRenderer"] channel_id = metadata["externalId"] @@ -143,3 +230,77 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata: avatar = metadata["avatar"]["thumbnails"][0]["url"] return ChannelMetadata(channel_id, name, description, avatar) + + +def download_avatar(avatar_url: str, download_path: Path) -> Path: + """ + Download the avatar image of a channel. The .jpg file ending + is added to the path. + + :param avatar_url: Channel avatar URL + :param download_path: Download path + :return: Path with file ending + """ + logging.info(f"downloading avatar {avatar_url}...") + + download_path = download_path.with_suffix(".jpg") + util.download_file(avatar_url, download_path) + return download_path + + +def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]: + """ + Return videos of a channel using YouTube's RSS feed. Using the feed is fast, + but you only get the 15 latest videos. + + :param channel_id: YouTube channel id + :return: Videos: video_id -> VideoScraped + """ + feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" + feed = feedparser.parse(feed_url) + videos = [] + + for item in feed["entries"]: + video_id = item.get("yt_videoid") + if not video_id: + logging.warning( + f"found invalid item in rss feed of channel {channel_id}: {item}" + ) + continue + + publish_date_str = item.get("published") + publish_date = None + if publish_date_str: + publish_date = datetime.datetime.fromisoformat(publish_date_str) + + videos.append(VideoScraped(video_id, publish_date)) + + return videos + + +def get_channel_videos_from_scraper( + channel_id: str, limit: int = None +) -> List[VideoScraped]: + """ + Return all videos of a channel by scraping the YouTube website. + May take a while depending on the number of videos. + + :param channel_id: YouTube channel id + :param limit: Limit number of scraped videos + :return: Videos: video_id -> VideoScraped + """ + videos = [] + + for item in scrapetube.get_channel( + channel_url_from_id(channel_id), limit, settings.YOUTUBE_SCRAPE_DELAY + ): + video_id = item.get("videoId") + if not video_id: + logging.warning( + f"found invalid item in scraped feed of channel {channel_id}: {item}" + ) + continue + + videos.append(VideoScraped(video_id, None)) + + return videos diff --git a/ucast/tasks/__init__.py b/ucast/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ucast/tasks/download.py b/ucast/tasks/download.py new file mode 100644 index 0000000..9e45081 --- /dev/null +++ b/ucast/tasks/download.py @@ -0,0 +1,113 @@ +from django.utils import timezone + +from ucast.models import Channel, Video +from ucast.service import cover, storage, util, youtube + +store = storage.Storage() + + +def _get_or_create_channel(channel_id: str) -> Channel: + try: + return Channel.objects.get(id=channel_id) + except Channel.DoesNotExist: + channel_data = youtube.get_channel_metadata( + youtube.channel_url_from_id(channel_id) + ) + channel_slug = Channel.get_new_slug(channel_data.name) + channel_folder = store.get_channel_folder(channel_slug) + + avatar_file = youtube.download_avatar( + channel_data.avatar_url, channel_folder.file_avatar + ) + util.resize_avatar(avatar_file, channel_folder.file_avatar_sm) + + channel = Channel( + id=channel_id, + name=channel_data.name, + slug=channel_slug, + description=channel_data.description, + ) + channel.save() + return channel + + +def _load_scraped_video(vid: youtube.VideoScraped, channel: Channel): + if Video.objects.filter(id=vid.id).exists(): + return + + details = youtube.get_video_details(vid.id) + + # Check filter + if ( + details.is_currently_live + or (details.is_short and channel.skip_shorts) + or (details.is_livestream and channel.skip_livestreams) + ): + return + + slug = Video.get_new_slug(details.title, details.published.date(), channel.id) + + video = Video( + id=details.id, + title=details.title, + slug=slug, + channel=channel, + published=details.published, + description=details.description, + duration=details.duration, + is_livestream=details.is_livestream, + is_short=details.is_short, + ) + video.save() + + +def download_video(video: Video): + channel_folder = store.get_channel_folder(video.channel.slug) + + audio_file = channel_folder.get_audio(video.slug) + details = youtube.download_audio(video.id, audio_file) + + # Download/convert thumbnails + tn_path = youtube.download_thumbnail( + details, channel_folder.get_thumbnail(video.slug) + ) + util.resize_thumbnail(tn_path, channel_folder.get_thumbnail(video.slug, True)) + cover_file = channel_folder.get_cover(video.slug) + cover.create_cover_file( + tn_path, + channel_folder.file_avatar, + details.title, + video.channel.name, + cover.COVER_STYLE_BLUR, + cover_file, + ) + + youtube.tag_audio(audio_file, details, cover_file) + + +def fetch_channel(channel_id: str, limit: int = None): + channel = _get_or_create_channel(channel_id) + + if limit == 0: + return + + videos = youtube.get_channel_videos_from_scraper(channel_id, limit) + + for vid in videos[:limit]: + _load_scraped_video(vid, channel) + + +def update_channels(): + for channel in Channel.objects.filter(active=True): + videos = youtube.get_channel_videos_from_feed(channel.id) + + for vid in videos: + _load_scraped_video(vid, channel) + + +def download_videos(): + for video in Video.objects.filter(downloaded=None): + download_video(video) + + video.downloaded = timezone.now() + video.save() diff --git a/ucast/tests/test_cover.py b/ucast/tests/test_cover.py index ca50256..475b5f3 100644 --- a/ucast/tests/test_cover.py +++ b/ucast/tests/test_cover.py @@ -25,8 +25,7 @@ from ucast.service import cover, typ ( 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!", [ "Ha! du wärst", "Obrigkeit von", @@ -74,31 +73,31 @@ def test_get_text_color(bg_color: typ.Color, text_color: typ.Color): @pytest.mark.parametrize( "n_image,title,channel,style", [ - (1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.CoverStyle.CLASSIC), - (1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.CoverStyle.BLUR), + (1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_GRADIENT), + (1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_BLUR), ( 2, "Sintel - Open Movie by Blender Foundation", "Blender", - cover.CoverStyle.CLASSIC, + cover.COVER_STYLE_GRADIENT, ), ( 2, "Sintel - Open Movie by Blender Foundation", "Blender", - cover.CoverStyle.BLUR, + cover.COVER_STYLE_BLUR, ), ( 3, "Systemabsturz Teaser zur DiVOC bb3", "media.ccc.de", - cover.CoverStyle.CLASSIC, + cover.COVER_STYLE_GRADIENT, ), ( 3, "Systemabsturz Teaser zur DiVOC bb3", "media.ccc.de", - cover.CoverStyle.BLUR, + cover.COVER_STYLE_BLUR, ), ], ) @@ -107,9 +106,7 @@ def test_create_cover_image( ): 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}_{style.name.lower()}.png" - ) + expected_cv_file = tests.DIR_TESTFILES / "cover" / f"c{n_image}_{style}.png" tn_image = Image.open(tn_file) av_image = Image.open(av_file) @@ -136,7 +133,7 @@ def test_create_cover_image_noavatar(): None, "ThetaDev @ Embedded World 2019", "ThetaDev", - cover.CoverStyle.CLASSIC, + cover.COVER_STYLE_GRADIENT, ) assert cv_image.width == cover.COVER_WIDTH @@ -165,7 +162,7 @@ def test_create_blank_cover_image(): 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_classic.png" + expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1_gradient.png" tmpdir_o = tempfile.TemporaryDirectory() tmpdir = Path(tmpdir_o.name) @@ -176,7 +173,7 @@ def test_create_cover_file(): av_file, "ThetaDev @ Embedded World 2019", "ThetaDev", - cover.CoverStyle.CLASSIC, + "gradient", cv_file, ) diff --git a/ucast/tests/test_util.py b/ucast/tests/test_util.py index 895b78d..eb10259 100644 --- a/ucast/tests/test_util.py +++ b/ucast/tests/test_util.py @@ -6,7 +6,7 @@ from PIL import Image, ImageChops from ucast import tests from ucast.service import util -TEST_FILE_URL = "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" # noqa: E501 +TEST_FILE_URL = "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" def test_download_file(): diff --git a/ucast/tests/test_youtube.py b/ucast/tests/test_youtube.py index 151a3e6..4b3afce 100644 --- a/ucast/tests/test_youtube.py +++ b/ucast/tests/test_youtube.py @@ -1,7 +1,7 @@ +import datetime import re import subprocess import tempfile -from datetime import datetime from pathlib import Path import pytest @@ -20,8 +20,8 @@ CHANNEL_URL_BLENDER = "https://www.youtube.com/c/BlenderFoundation" @pytest.fixture(scope="module") -def video_info() -> youtube.VideoInfo: - return youtube.get_video_info(VIDEO_ID_SINTEL) +def video_info() -> youtube.VideoDetails: + return youtube.get_video_details(VIDEO_ID_SINTEL) def test_download_thumbnail(video_info): @@ -30,7 +30,8 @@ def test_download_thumbnail(video_info): tn_file = tmpdir / "thumbnail" expected_tn_file = tests.DIR_TESTFILES / "thumbnail" / "t2.webp" - youtube.download_thumbnail(video_info, tn_file) + tn_file = youtube.download_thumbnail(video_info, tn_file) + assert tn_file.suffix == ".webp" tn = Image.open(tn_file) expected_tn = Image.open(expected_tn_file) @@ -64,11 +65,13 @@ www.sintel.org""" assert not video_info.is_currently_live assert not video_info.is_livestream assert not video_info.is_short - assert video_info.published == datetime(2010, 9, 30) + assert video_info.published == datetime.datetime( + 2010, 9, 30, tzinfo=datetime.timezone.utc + ) def test_get_video_info_short(): - vinfo = youtube.get_video_info(VIDEO_ID_SHORT) + vinfo = youtube.get_video_details(VIDEO_ID_SHORT) assert vinfo.id == VIDEO_ID_SHORT assert ( vinfo.title @@ -85,7 +88,7 @@ def test_download_video(): tmpdir = Path(tmpdir_o.name) download_file = tmpdir / "download.mp3" - vinfo = youtube.download_video(VIDEO_ID_PERSUASION, download_file) + vinfo = youtube.download_audio(VIDEO_ID_PERSUASION, download_file) assert vinfo.id == VIDEO_ID_PERSUASION assert vinfo.title == "Persuasion (Instrumental) – RYYZN (No Copyright Music)" assert vinfo.duration == 100 @@ -109,13 +112,13 @@ def test_download_video(): youtube.channel_url_from_id(CHANNEL_ID_THETADEV), CHANNEL_ID_THETADEV, "ThetaDev", - "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj", # noqa: E501 + "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj", ), ( CHANNEL_URL_BLENDER, CHANNEL_ID_BLENDER, "Blender", - "https://yt3.ggpht.com/ytc/AKedOLT_31fFSD3FWEBnHZnyZeJx-GPHJwYCQKcEpaq8NQ=s900-c-k-c0x00ffffff-no-rj", # noqa: E501 + "https://yt3.ggpht.com/ytc/AKedOLT_31fFSD3FWEBnHZnyZeJx-GPHJwYCQKcEpaq8NQ=s900-c-k-c0x00ffffff-no-rj", ), ], ) diff --git a/ucast/tests/testfiles/cover/c1_classic.png b/ucast/tests/testfiles/cover/c1_gradient.png similarity index 100% rename from ucast/tests/testfiles/cover/c1_classic.png rename to ucast/tests/testfiles/cover/c1_gradient.png diff --git a/ucast/tests/testfiles/cover/c2_blur.png b/ucast/tests/testfiles/cover/c2_blur.png index 152d2b5..a36d31c 100644 Binary files a/ucast/tests/testfiles/cover/c2_blur.png and b/ucast/tests/testfiles/cover/c2_blur.png differ diff --git a/ucast/tests/testfiles/cover/c2_classic.png b/ucast/tests/testfiles/cover/c2_classic.png deleted file mode 100644 index 7c38fff..0000000 Binary files a/ucast/tests/testfiles/cover/c2_classic.png and /dev/null differ diff --git a/ucast/tests/testfiles/cover/c2_gradient.png b/ucast/tests/testfiles/cover/c2_gradient.png new file mode 100644 index 0000000..ea128e3 Binary files /dev/null and b/ucast/tests/testfiles/cover/c2_gradient.png differ diff --git a/ucast/tests/testfiles/cover/c3_classic.png b/ucast/tests/testfiles/cover/c3_gradient.png similarity index 100% rename from ucast/tests/testfiles/cover/c3_classic.png rename to ucast/tests/testfiles/cover/c3_gradient.png diff --git a/ucast_project/settings.py b/ucast_project/settings.py index 018668d..460eeb9 100644 --- a/ucast_project/settings.py +++ b/ucast_project/settings.py @@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/4.0/ref/settings/ """ import os +import time from importlib import resources from pathlib import Path @@ -175,11 +176,11 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +TIME_ZONE = get_env("TZ", time.tzname[0]) -USE_I18N = False +USE_I18N = True -USE_TZ = False +USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ @@ -194,3 +195,6 @@ STATICFILES_DIRS = [resources.path("ucast", "static")] # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Delay between YouTube API calls +YOUTUBE_SCRAPE_DELAY = 1