Compare commits

..

No commits in common. "a3c7be3ae3eaaa6352566b9f705f49a6b4a3565b" and "cb1fc64a7923dff47202468a99e6bc5283708639" have entirely different histories.

24 changed files with 236 additions and 1071 deletions

2
.gitignore vendored
View file

@ -20,7 +20,7 @@ node_modules
*.mp3
# Application data
/_run*
/_run
*.sqlite3
# Generated assets

View file

@ -5,19 +5,14 @@
```txt
_ data
|_ LinusTechTips
|_ _ucast
|_ avatar.jpg # Profilbild des Kanals
|_ avatar_sm.webp
|_ .ucast
|_ avatar.png # Profilbild des Kanals
|_ feed.xml # RSS-Feed
|_ covers # Cover-Bilder
|_ 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
|_ 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
|_ Andreas Spiess
|_ ...

299
poetry.lock generated
View file

@ -9,14 +9,6 @@ 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"
@ -39,6 +31,17 @@ 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"
@ -96,22 +99,11 @@ 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 = "main"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
@ -128,7 +120,7 @@ Pillow = "*"
[[package]]
name = "coverage"
version = "6.3.3"
version = "6.3.2"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
@ -140,20 +132,6 @@ 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"
@ -258,7 +236,7 @@ python-versions = "*"
[[package]]
name = "invoke"
version = "1.7.1"
version = "1.7.0"
description = "Pythonic task execution"
category = "dev"
optional = false
@ -292,7 +270,7 @@ python-versions = "*"
name = "packaging"
version = "21.3"
description = "Core utilities for Python packages"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6"
@ -385,9 +363,9 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "pyparsing"
version = "3.0.9"
version = "3.0.8"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.6.8"
@ -464,23 +442,6 @@ 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"
@ -508,16 +469,16 @@ optional = false
python-versions = "*"
[[package]]
name = "rq"
version = "1.10.1"
description = "RQ is a simple, lightweight, library for creating background jobs, and processing them."
name = "scrapetube"
version = "2.2.2"
description = "Scrape youtube without the official youtube api and without selenium."
category = "main"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.6"
[package.dependencies]
click = ">=5.0.0"
redis = ">=3.5.0"
requests = "*"
typing-extensions = "*"
[[package]]
name = "sgmllib3k"
@ -535,14 +496,6 @@ 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"
@ -567,6 +520,14 @@ 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"
@ -622,14 +583,6 @@ 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"
@ -649,17 +602,13 @@ websockets = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "2d9aa9c628676b6c9981964a7e01a8d0b0a291025b695c5d98441d29720bced0"
content-hash = "8609785f53a44a16f3c5c1d5042ab2627bb198f3c7daa8ea18e55bf1e66c4345"
[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"},
@ -668,6 +617,10 @@ 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"},
@ -828,10 +781,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.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"},
@ -841,51 +790,47 @@ colorthief = [
{file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"},
]
coverage = [
{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"},
{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"},
]
distlib = [
{file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
@ -930,8 +875,8 @@ iniconfig = [
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
invoke = [
{file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"},
{file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"},
{file = "invoke-1.7.0-py3-none-any.whl", hash = "sha256:a5159fc63dba6ca2a87a1e33d282b99cea69711b03c64a35bb4e1c53c6c4afa0"},
{file = "invoke-1.7.0.tar.gz", hash = "sha256:e332e49de40463f2016315f51df42313855772be86435686156bc18f45b5cc6c"},
]
mutagen = [
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
@ -1055,8 +1000,8 @@ pycryptodomex = [
{file = "pycryptodomex-3.14.1.tar.gz", hash = "sha256:2ce76ed0081fd6ac8c74edc75b9d14eca2064173af79843c24fa62573263c1f2"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
{file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"},
{file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"},
]
pytest = [
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"},
@ -1109,10 +1054,6 @@ 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"},
@ -1120,9 +1061,8 @@ requests = [
rfeed = [
{file = "rfeed-1.1.1.tar.gz", hash = "sha256:aa9506f2866b74f5a322d394a14a63c19a6825c2d94755ff19d46dd1e2434819"},
]
rq = [
{file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"},
{file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"},
scrapetube = [
{file = "scrapetube-2.2.2-py3-none-any.whl", hash = "sha256:73aef77d42aa182bcd3cc7f9ebee28bc01d6b34d615d205679ebc54be1f9807f"},
]
sgmllib3k = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"},
@ -1131,9 +1071,6 @@ 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"},
@ -1146,6 +1083,10 @@ 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"},
@ -1211,72 +1152,6 @@ 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"},

View file

@ -12,7 +12,7 @@ packages = [
python = "^3.10"
Django = "^4.0.4"
yt-dlp = "^2022.3.8"
requests = "^2.27.1"
scrapetube = "^2.2.2"
rfeed = "^1.1.1"
feedparser = "^6.0.8"
Pillow = "^9.1.0"
@ -20,13 +20,11 @@ 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"
@ -43,7 +41,10 @@ requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.flake8]
extend-ignore = "E501"
max-line-length = 88
per-file-ignores = [
"settings.py:E501",
]
[tool.black]
line-length = 88

View file

@ -1,5 +1,4 @@
import os
import shutil
from pathlib import Path
from invoke import Responder, task
@ -9,11 +8,6 @@ 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):
@ -72,46 +66,35 @@ def get_cover(c, vid=""):
The images are stored in the ``ucast/tests/testfiles`` directory.
"""
vinfo = youtube.get_video_details(vid)
vinfo = youtube.get_video_info(vid)
title = vinfo.title
channel_name = vinfo.channel_name
channel_id = vinfo.channel_id
channel_metadata = youtube.get_channel_metadata(
youtube.channel_url_from_id(channel_id)
)
channel_url = vinfo.channel_url
channel_metadata = youtube.get_channel_metadata(channel_url)
ti = 1
while os.path.exists(tests.DIR_TESTFILES / "avatar" / f"a{ti}.jpg"):
while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}_classic.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}_gradient.png"
cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_classic.png"
cv_blur_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_blur.png"
tn_file = youtube.download_thumbnail(vinfo, 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.COVER_STYLE_GRADIENT, cv_file
tn_file, av_file, title, channel_name, cover.CoverStyle.CLASSIC, cv_file
)
cover.create_cover_file(
tn_file, av_file, title, channel_name, cover.COVER_STYLE_BLUR, cv_blur_file
tn_file, av_file, title, channel_name, cover.CoverStyle.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)

View file

@ -1,6 +1,5 @@
# Generated by Django 4.0.4 on 2022-05-05 00:02
import django.db.models.deletion
from django.db import migrations, models
@ -19,8 +18,6 @@ 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)),
@ -36,18 +33,9 @@ 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)),
],
),
]

View file

@ -1,63 +1,19 @@
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"
)

View file

@ -1,23 +1,26 @@
import enum
import math
from importlib import resources
from pathlib import Path
from typing import List, Literal, Optional, Tuple
from typing import List, 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]:
@ -177,23 +180,22 @@ def _get_baseimage(
"""
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
if style == COVER_STYLE_GRADIENT:
# Thumbnail with color gradient background
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:
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
@ -205,6 +207,12 @@ 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)
@ -212,9 +220,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.LANCZOS).crop(
(0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom)
)
return thumbnail.resize(
(COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS
).crop((0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom))
def _draw_text_avatar(
@ -238,7 +246,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.LANCZOS)
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
circle_mask = Image.new("L", (avt_size, avt_size))
circle_mask_draw = ImageDraw.Draw(circle_mask)

View file

@ -1,244 +0,0 @@
"""
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)

View file

@ -1,79 +0,0 @@
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

View file

@ -1,47 +1,7 @@
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: Path):
def download_file(url: str, download_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="_")

View file

@ -1,139 +1,94 @@
import datetime
import logging
import re
import shutil
import json
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 django.conf import settings
from mutagen import id3
from scrapetube import scrapetube
from yt_dlp import YoutubeDL
from ucast.service import scrapetube, util
from ucast.service import util
class ItemNotFoundError(Exception):
pass
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 ThumbnailNotFoundError(Exception):
pass
@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:
def download_thumbnail(vinfo: VideoInfo, download_path):
"""
Download the thumbnail image of a YouTube video and save it at the given filepath.
The thumbnail file ending is added to the path.
Does not add the correct file ending (jpg or webp), we are converting it with
Pillow anyway.
: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"]
logging.info(f"downloading thumbnail {url}...")
print(f"downloading thumbnail {url}...")
try:
return util.download_image_file(url, download_path)
util.download_file(url, download_path)
return
except requests.HTTPError:
logging.warning(f"downloading thumbnail {url} failed")
print(f"downloading thumbnail {url} failed")
pass
raise ThumbnailNotFoundError(f"could not find thumbnail for video {vinfo}")
def get_video_details(video_id: str) -> VideoDetails:
def get_video_info(video_id) -> VideoInfo:
with YoutubeDL() as ydl:
info = ydl.extract_info(video_id, download=False)
return VideoDetails.from_vinfo(info)
return VideoInfo(info)
def download_audio(
video_id: str, download_path: Path, sponsorblock=False
) -> VideoDetails:
tmp_dld_file = download_path.with_suffix(".dld" + download_path.suffix)
def download_video(video_id, download_path, sponsorblock=False) -> VideoInfo:
ydl_params = {
"format": "bestaudio",
"postprocessors": [
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3"},
],
"outtmpl": str(tmp_dld_file),
"outtmpl": str(download_path),
}
if sponsorblock:
@ -152,76 +107,34 @@ def download_audio(
with YoutubeDL(ydl_params) as ydl:
# extract_info downloads the video and returns its metadata
info = ydl.extract_info(video_id)
shutil.move(tmp_dld_file, download_path)
return VideoDetails.from_vinfo(info)
return VideoInfo(info)
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()
@dataclass
class ChannelMetadata:
id: str
name: str
description: str
avatar_url: str
def channel_url_from_id(channel_id: str) -> str:
return "https://www.youtube.com/channel/" + channel_id
def channel_url_from_str(channel_str: str) -> str:
"""
Get the channel URL from user input. The following types are accepted:
- 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``
: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
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"
:param channel_url: Channel-URL
:return: Channel metadata
"""
data = scrapetube.get_channel_metadata(channel_url)
url = f"{channel_url}/videos?view=0&flow=grid"
html = scrapetube.get_initial_data(session, url)
data = json.loads(
scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}"
)
metadata = data["metadata"]["channelMetadataRenderer"]
channel_id = metadata["externalId"]
@ -230,77 +143,3 @@ 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

View file

@ -1,113 +0,0 @@
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()

View file

@ -25,7 +25,8 @@ 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",
@ -73,31 +74,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.COVER_STYLE_GRADIENT),
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_BLUR),
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.CoverStyle.CLASSIC),
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.CoverStyle.BLUR),
(
2,
"Sintel - Open Movie by Blender Foundation",
"Blender",
cover.COVER_STYLE_GRADIENT,
cover.CoverStyle.CLASSIC,
),
(
2,
"Sintel - Open Movie by Blender Foundation",
"Blender",
cover.COVER_STYLE_BLUR,
cover.CoverStyle.BLUR,
),
(
3,
"Systemabsturz Teaser zur DiVOC bb3",
"media.ccc.de",
cover.COVER_STYLE_GRADIENT,
cover.CoverStyle.CLASSIC,
),
(
3,
"Systemabsturz Teaser zur DiVOC bb3",
"media.ccc.de",
cover.COVER_STYLE_BLUR,
cover.CoverStyle.BLUR,
),
],
)
@ -106,7 +107,9 @@ 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}.png"
expected_cv_file = (
tests.DIR_TESTFILES / "cover" / f"c{n_image}_{style.name.lower()}.png"
)
tn_image = Image.open(tn_file)
av_image = Image.open(av_file)
@ -133,7 +136,7 @@ def test_create_cover_image_noavatar():
None,
"ThetaDev @ Embedded World 2019",
"ThetaDev",
cover.COVER_STYLE_GRADIENT,
cover.CoverStyle.CLASSIC,
)
assert cv_image.width == cover.COVER_WIDTH
@ -162,7 +165,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_gradient.png"
expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1_classic.png"
tmpdir_o = tempfile.TemporaryDirectory()
tmpdir = Path(tmpdir_o.name)
@ -173,7 +176,7 @@ def test_create_cover_file():
av_file,
"ThetaDev @ Embedded World 2019",
"ThetaDev",
"gradient",
cover.CoverStyle.CLASSIC,
cv_file,
)

View file

@ -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"
TEST_FILE_URL = "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" # noqa: E501
def test_download_file():

View file

@ -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.VideoDetails:
return youtube.get_video_details(VIDEO_ID_SINTEL)
def video_info() -> youtube.VideoInfo:
return youtube.get_video_info(VIDEO_ID_SINTEL)
def test_download_thumbnail(video_info):
@ -30,8 +30,7 @@ def test_download_thumbnail(video_info):
tn_file = tmpdir / "thumbnail"
expected_tn_file = tests.DIR_TESTFILES / "thumbnail" / "t2.webp"
tn_file = youtube.download_thumbnail(video_info, tn_file)
assert tn_file.suffix == ".webp"
youtube.download_thumbnail(video_info, tn_file)
tn = Image.open(tn_file)
expected_tn = Image.open(expected_tn_file)
@ -65,13 +64,11 @@ 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.datetime(
2010, 9, 30, tzinfo=datetime.timezone.utc
)
assert video_info.published == datetime(2010, 9, 30)
def test_get_video_info_short():
vinfo = youtube.get_video_details(VIDEO_ID_SHORT)
vinfo = youtube.get_video_info(VIDEO_ID_SHORT)
assert vinfo.id == VIDEO_ID_SHORT
assert (
vinfo.title
@ -88,7 +85,7 @@ def test_download_video():
tmpdir = Path(tmpdir_o.name)
download_file = tmpdir / "download.mp3"
vinfo = youtube.download_audio(VIDEO_ID_PERSUASION, download_file)
vinfo = youtube.download_video(VIDEO_ID_PERSUASION, download_file)
assert vinfo.id == VIDEO_ID_PERSUASION
assert vinfo.title == "Persuasion (Instrumental) RYYZN (No Copyright Music)"
assert vinfo.duration == 100
@ -112,13 +109,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",
"https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj", # noqa: E501
),
(
CHANNEL_URL_BLENDER,
CHANNEL_ID_BLENDER,
"Blender",
"https://yt3.ggpht.com/ytc/AKedOLT_31fFSD3FWEBnHZnyZeJx-GPHJwYCQKcEpaq8NQ=s900-c-k-c0x00ffffff-no-rj",
"https://yt3.ggpht.com/ytc/AKedOLT_31fFSD3FWEBnHZnyZeJx-GPHJwYCQKcEpaq8NQ=s900-c-k-c0x00ffffff-no-rj", # noqa: E501
),
],
)

View file

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

View file

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View file

@ -11,7 +11,6 @@ https://docs.djangoproject.com/en/4.0/ref/settings/
"""
import os
import time
from importlib import resources
from pathlib import Path
@ -176,11 +175,11 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us"
TIME_ZONE = get_env("TZ", time.tzname[0])
TIME_ZONE = "UTC"
USE_I18N = True
USE_I18N = False
USE_TZ = True
USE_TZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
@ -195,6 +194,3 @@ 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