Compare commits

...

3 commits

Author SHA1 Message Date
a3c7be3ae3 add mp3 tagging
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-16 00:41:44 +02:00
85862e0e66 add download tasks 2022-05-15 03:14:07 +02:00
652795c51c finished basic yt scraping 2022-05-13 23:12:58 +02:00
24 changed files with 1070 additions and 235 deletions

2
.gitignore vendored
View file

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

View file

@ -5,14 +5,19 @@
```txt ```txt
_ data _ data
|_ LinusTechTips |_ LinusTechTips
|_ .ucast |_ _ucast
|_ avatar.png # Profilbild des Kanals |_ avatar.jpg # Profilbild des Kanals
|_ feed.xml # RSS-Feed |_ avatar_sm.webp
|_ covers # Cover-Bilder |_ covers # Cover-Bilder
|_ 220409_Building a _1_000_000 Computer.png |_ 220409_Building_a_1_000_000_Computer.png
|_ 220410_Apple makes GREAT Gaming Computers.png |_ 220410_Apple_makes_GREAT_Gaming_Computers.png
|_ 220409_Building a _1_000_000 Computer.mp3 |_ thumbnails
|_ 220410_Apple makes GREAT Gaming Computers.mp3 |_ 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 |_ Andreas Spiess
|_ ... |_ ...

299
poetry.lock generated
View file

@ -9,6 +9,14 @@ python-versions = ">=3.7"
[package.extras] [package.extras]
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] 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]] [[package]]
name = "atomicwrites" name = "atomicwrites"
version = "1.4.0" 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 = ["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"] 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]] [[package]]
name = "brotli" name = "brotli"
version = "1.0.9" version = "1.0.9"
@ -99,11 +96,22 @@ python-versions = ">=3.5.0"
[package.extras] [package.extras]
unicode_backport = ["unicodedata2"] 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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.4" version = "0.4.4"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
@ -120,7 +128,7 @@ Pillow = "*"
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "6.3.2" version = "6.3.3"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
category = "dev" category = "dev"
optional = false optional = false
@ -132,6 +140,20 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras] [package.extras]
toml = ["tomli"] 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]] [[package]]
name = "distlib" name = "distlib"
version = "0.3.4" version = "0.3.4"
@ -236,7 +258,7 @@ python-versions = "*"
[[package]] [[package]]
name = "invoke" name = "invoke"
version = "1.7.0" version = "1.7.1"
description = "Pythonic task execution" description = "Pythonic task execution"
category = "dev" category = "dev"
optional = false optional = false
@ -270,7 +292,7 @@ python-versions = "*"
name = "packaging" name = "packaging"
version = "21.3" version = "21.3"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@ -363,9 +385,9 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.0.8" version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars" description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6.8" python-versions = ">=3.6.8"
@ -442,6 +464,23 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.6" 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]] [[package]]
name = "requests" name = "requests"
version = "2.27.1" version = "2.27.1"
@ -469,16 +508,16 @@ optional = false
python-versions = "*" python-versions = "*"
[[package]] [[package]]
name = "scrapetube" name = "rq"
version = "2.2.2" version = "1.10.1"
description = "Scrape youtube without the official youtube api and without selenium." description = "RQ is a simple, lightweight, library for creating background jobs, and processing them."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.5"
[package.dependencies] [package.dependencies]
requests = "*" click = ">=5.0.0"
typing-extensions = "*" redis = ">=3.5.0"
[[package]] [[package]]
name = "sgmllib3k" name = "sgmllib3k"
@ -496,6 +535,14 @@ category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 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]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.4.2" version = "0.4.2"
@ -520,14 +567,6 @@ category = "dev"
optional = false optional = false
python-versions = ">=3.7" 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]] [[package]]
name = "tzdata" name = "tzdata"
version = "2022.1" version = "2022.1"
@ -583,6 +622,14 @@ category = "main"
optional = false optional = false
python-versions = ">=3.7" 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]] [[package]]
name = "yt-dlp" name = "yt-dlp"
version = "2022.4.8" version = "2022.4.8"
@ -602,13 +649,17 @@ websockets = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "8609785f53a44a16f3c5c1d5042ab2627bb198f3c7daa8ea18e55bf1e66c4345" content-hash = "2d9aa9c628676b6c9981964a7e01a8d0b0a291025b695c5d98441d29720bced0"
[metadata.files] [metadata.files]
asgiref = [ asgiref = [
{file = "asgiref-3.5.1-py3-none-any.whl", hash = "sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1"}, {file = "asgiref-3.5.1-py3-none-any.whl", hash = "sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1"},
{file = "asgiref-3.5.1.tar.gz", hash = "sha256:fddeea3c53fa99d0cdb613c3941cc6e52d822491fc2753fba25768fb5bf4e865"}, {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 = [ atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
{file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, {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-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, {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 = [ brotli = [
{file = "Brotli-1.0.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70"}, {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"}, {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.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
{file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, {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 = [ colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, {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"}, {file = "colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221"},
] ]
coverage = [ coverage = [
{file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, {file = "coverage-6.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df32ee0f4935a101e4b9a5f07b617d884a531ed5666671ff6ac66d2e8e8246d8"},
{file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, {file = "coverage-6.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:75b5dbffc334e0beb4f6c503fb95e6d422770fd2d1b40a64898ea26d6c02742d"},
{file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, {file = "coverage-6.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:114944e6061b68a801c5da5427b9173a0dd9d32cd5fcc18a13de90352843737d"},
{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.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ab88a01cd180b5640ccc9c47232e31924d5f9967ab7edd7e5c91c68eee47a69"},
{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.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.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4cd696aa712e6cd16898d63cf66139dc70d998f8121ab558f0e1936396dbc579"},
{file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c1a9942e282cc9d3ed522cd3e3cab081149b27ea3bda72d6f61f84eaf88c1a63"},
{file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, {file = "coverage-6.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c06455121a089252b5943ea682187a4e0a5cf0a3fb980eb8e7ce394b144430a9"},
{file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, {file = "coverage-6.3.3-cp310-cp310-win32.whl", hash = "sha256:cb5311d6ccbd22578c80028c5e292a7ab9adb91bd62c1982087fad75abe2e63d"},
{file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, {file = "coverage-6.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:6d4a6f30f611e657495cc81a07ff7aa8cd949144e7667c5d3e680d73ba7a70e4"},
{file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, {file = "coverage-6.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:79bf405432428e989cad7b8bc60581963238f7645ae8a404f5dce90236cc0293"},
{file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, {file = "coverage-6.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:338c417613f15596af9eb7a39353b60abec9d8ce1080aedba5ecee6a5d85f8d3"},
{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.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db094a6a4ae6329ed322a8973f83630b12715654c197dd392410400a5bfa1a73"},
{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.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.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:93b16b08f94c92cab88073ffd185070cdcb29f1b98df8b28e6649145b7f2c90d"},
{file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:fbc86ae8cc129c801e7baaafe3addf3c8d49c9c1597c44bdf2d78139707c3c62"},
{file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, {file = "coverage-6.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b5ba058610e8289a07db2a57bce45a1793ec0d3d11db28c047aae2aa1a832572"},
{file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, {file = "coverage-6.3.3-cp37-cp37m-win32.whl", hash = "sha256:8329635c0781927a2c6ae068461e19674c564e05b86736ab8eb29c420ee7dc20"},
{file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, {file = "coverage-6.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:e5af1feee71099ae2e3b086ec04f57f9950e1be9ecf6c420696fea7977b84738"},
{file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, {file = "coverage-6.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e814a4a5a1d95223b08cdb0f4f57029e8eab22ffdbae2f97107aeef28554517e"},
{file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, {file = "coverage-6.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61f4fbf3633cb0713437291b8848634ea97f89c7e849c2be17a665611e433f53"},
{file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, {file = "coverage-6.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3401b0d2ed9f726fadbfa35102e00d1b3547b73772a1de5508ef3bdbcb36afe7"},
{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.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8586b177b4407f988731eb7f41967415b2197f35e2a6ee1a9b9b561f6323c8e9"},
{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.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.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:afb03f981fadb5aed1ac6e3dd34f0488e1a0875623d557b6fad09b97a942b38a"},
{file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cbe91bc84be4e5ef0b1480d15c7b18e29c73bdfa33e07d3725da7d18e1b0aff2"},
{file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, {file = "coverage-6.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:91502bf27cbd5c83c95cfea291ef387469f2387508645602e1ca0fd8a4ba7548"},
{file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, {file = "coverage-6.3.3-cp38-cp38-win32.whl", hash = "sha256:c488db059848702aff30aa1d90ef87928d4e72e4f00717343800546fdbff0a94"},
{file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, {file = "coverage-6.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6534fcdfb5c503affb6b1130db7b5bfc8a0f77fa34880146f7a5c117987d0"},
{file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, {file = "coverage-6.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc692c9ee18f0dd3214843779ba6b275ee4bb9b9a5745ba64265bce911aefd1a"},
{file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, {file = "coverage-6.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:462105283de203df8de58a68c1bb4ba2a8a164097c2379f664fa81d6baf94b81"},
{file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, {file = "coverage-6.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc972d829ad5ef4d4c5fcabd2bbe2add84ce8236f64ba1c0c72185da3a273130"},
{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.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06f54765cdbce99901871d50fe9f41d58213f18e98b170a30ca34f47de7dd5e8"},
{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.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.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6f5fee77ec3384b934797f1873758f796dfb4f167e1296dc00f8b2e023ce6ee9"},
{file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:baa8be8aba3dd1e976e68677be68a960a633a6d44c325757aefaa4d66175050f"},
{file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, {file = "coverage-6.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d06380e777dd6b35ee936f333d55b53dc4a8271036ff884c909cf6e94be8b6c"},
{file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, {file = "coverage-6.3.3-cp39-cp39-win32.whl", hash = "sha256:f8cabc5fd0091976ab7b020f5708335033e422de25e20ddf9416bdce2b7e07d8"},
{file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, {file = "coverage-6.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c9441d57b0963cf8340268ad62fc83de61f1613034b79c2b1053046af0c5284"},
{file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, {file = "coverage-6.3.3-pp36.pp37.pp38-none-any.whl", hash = "sha256:d522f1dc49127eab0bfbba4e90fa068ecff0899bbf61bf4065c790ddd6c177fe"},
{file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, {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 = [ distlib = [
{file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, {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"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
] ]
invoke = [ invoke = [
{file = "invoke-1.7.0-py3-none-any.whl", hash = "sha256:a5159fc63dba6ca2a87a1e33d282b99cea69711b03c64a35bb4e1c53c6c4afa0"}, {file = "invoke-1.7.1-py3-none-any.whl", hash = "sha256:2dc975b4f92be0c0a174ad2d063010c8a1fdb5e9389d69871001118b4fcac4fb"},
{file = "invoke-1.7.0.tar.gz", hash = "sha256:e332e49de40463f2016315f51df42313855772be86435686156bc18f45b5cc6c"}, {file = "invoke-1.7.1.tar.gz", hash = "sha256:7b6deaf585eee0a848205d0b8c0014b9bf6f287a8eb798818a642dff1df14b19"},
] ]
mutagen = [ mutagen = [
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, {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"}, {file = "pycryptodomex-3.14.1.tar.gz", hash = "sha256:2ce76ed0081fd6ac8c74edc75b9d14eca2064173af79843c24fa62573263c1f2"},
] ]
pyparsing = [ pyparsing = [
{file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
] ]
pytest = [ pytest = [
{file = "pytest-7.1.2-py3-none-any.whl", hash = "sha256:13d0e3ccfc2b6e26be000cb6568c832ba67ba32e719443bfe725814d3c42433c"}, {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-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, {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 = [ requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
@ -1061,8 +1120,9 @@ requests = [
rfeed = [ rfeed = [
{file = "rfeed-1.1.1.tar.gz", hash = "sha256:aa9506f2866b74f5a322d394a14a63c19a6825c2d94755ff19d46dd1e2434819"}, {file = "rfeed-1.1.1.tar.gz", hash = "sha256:aa9506f2866b74f5a322d394a14a63c19a6825c2d94755ff19d46dd1e2434819"},
] ]
scrapetube = [ rq = [
{file = "scrapetube-2.2.2-py3-none-any.whl", hash = "sha256:73aef77d42aa182bcd3cc7f9ebee28bc01d6b34d615d205679ebc54be1f9807f"}, {file = "rq-1.10.1-py2.py3-none-any.whl", hash = "sha256:92f4cf38b2364c1697b541e77c0fe62b7e5242fa864324f262be126ee2a07e3a"},
{file = "rq-1.10.1.tar.gz", hash = "sha256:62d06b44c3acfa5d1933c5a4ec3fbc2484144a8af60e318d0b8447c5236271e2"},
] ]
sgmllib3k = [ sgmllib3k = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, {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-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
slugify = [
{file = "slugify-0.0.1.tar.gz", hash = "sha256:c5703cc11c1a6947536f3ce8bb306766b8bb5a84a53717f5a703ce0f18235e4c"},
]
sqlparse = [ sqlparse = [
{file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
{file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, {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-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, {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 = [ tzdata = [
{file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"},
{file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, {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-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55"},
{file = "websockets-10.3.tar.gz", hash = "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"}, {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 = [ yt-dlp = [
{file = "yt-dlp-2022.4.8.tar.gz", hash = "sha256:8758d016509d4574b90fbde975aa70adaef71ed5e7a195141588f6d6945205ba"}, {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"}, {file = "yt_dlp-2022.4.8-py2.py3-none-any.whl", hash = "sha256:6edefe326b1e1478fdbe627a66203e5248a6b0dd50c101e682cf700ab70cdf72"},

View file

@ -12,7 +12,7 @@ packages = [
python = "^3.10" python = "^3.10"
Django = "^4.0.4" Django = "^4.0.4"
yt-dlp = "^2022.3.8" yt-dlp = "^2022.3.8"
scrapetube = "^2.2.2" requests = "^2.27.1"
rfeed = "^1.1.1" rfeed = "^1.1.1"
feedparser = "^6.0.8" feedparser = "^6.0.8"
Pillow = "^9.1.0" Pillow = "^9.1.0"
@ -20,11 +20,13 @@ colorthief = "^0.2.1"
wcag-contrast-ratio = "^0.9" wcag-contrast-ratio = "^0.9"
font-source-sans-pro = "^0.0.1" font-source-sans-pro = "^0.0.1"
fonts = "^0.0.3" fonts = "^0.0.3"
bordercrop = "^1.0.0"
django-bulma = "^0.8.3" django-bulma = "^0.8.3"
python-dotenv = "^0.20.0" python-dotenv = "^0.20.0"
psycopg2 = "^2.9.3" psycopg2 = "^2.9.3"
mysqlclient = "^2.1.0" mysqlclient = "^2.1.0"
slugify = "^0.0.1"
rq = "^1.10.1"
mutagen = "^1.45.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.1" pytest = "^7.1.1"
@ -41,10 +43,7 @@ requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.flake8] [tool.flake8]
max-line-length = 88 extend-ignore = "E501"
per-file-ignores = [
"settings.py:E501",
]
[tool.black] [tool.black]
line-length = 88 line-length = 88

View file

@ -1,4 +1,5 @@
import os import os
import shutil
from pathlib import Path from pathlib import Path
from invoke import Responder, task from invoke import Responder, task
@ -8,6 +9,11 @@ from ucast.service import cover, util, youtube
os.chdir(Path(__file__).absolute().parent) 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 @task
def test(c): def test(c):
@ -66,35 +72,46 @@ def get_cover(c, vid=""):
The images are stored in the ``ucast/tests/testfiles`` directory. 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 title = vinfo.title
channel_name = vinfo.channel_name channel_name = vinfo.channel_name
channel_url = vinfo.channel_url channel_id = vinfo.channel_id
channel_metadata = youtube.get_channel_metadata(channel_url) channel_metadata = youtube.get_channel_metadata(
youtube.channel_url_from_id(channel_id)
)
ti = 1 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 ti += 1
tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{ti}.webp" tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{ti}.webp"
av_file = tests.DIR_TESTFILES / "avatar" / f"a{ti}.jpg" 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" 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) util.download_file(channel_metadata.avatar_url, av_file)
cover.create_cover_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( 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 @task
def build_devcontainer(c): def build_devcontainer(c):
c.run( c.run(
"docker buildx build -t thetadev256/ucast-dev --push \ "docker buildx build -t thetadev256/ucast-dev --push --platform amd64,arm64,armhf -f deploy/Devcontainer.Dockerfile deploy"
--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,5 +1,6 @@
# Generated by Django 4.0.4 on 2022-05-05 00:02 # Generated by Django 4.0.4 on 2022-05-05 00:02
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -18,6 +19,8 @@ class Migration(migrations.Migration):
models.CharField(max_length=30, primary_key=True, serialize=False), models.CharField(max_length=30, primary_key=True, serialize=False),
), ),
("name", models.CharField(max_length=100)), ("name", models.CharField(max_length=100)),
("slug", models.CharField(max_length=100)),
("description", models.TextField()),
("active", models.BooleanField(default=True)), ("active", models.BooleanField(default=True)),
("skip_livestreams", models.BooleanField(default=True)), ("skip_livestreams", models.BooleanField(default=True)),
("skip_shorts", models.BooleanField(default=True)), ("skip_shorts", models.BooleanField(default=True)),
@ -33,9 +36,18 @@ class Migration(migrations.Migration):
), ),
("title", models.CharField(max_length=200)), ("title", models.CharField(max_length=200)),
("slug", models.CharField(max_length=209)), ("slug", models.CharField(max_length=209)),
(
"channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="ucast.channel"
),
),
("published", models.DateTimeField()), ("published", models.DateTimeField()),
("downloaded", models.DateTimeField(null=True)), ("downloaded", models.DateTimeField(null=True)),
("description", models.TextField()), ("description", models.TextField()),
("duration", models.IntegerField()),
("is_livestream", models.BooleanField(default=False)),
("is_short", models.BooleanField(default=False)),
], ],
), ),
] ]

View file

@ -1,19 +1,63 @@
import datetime
from django.db import models 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): class Channel(models.Model):
id = models.CharField(max_length=30, primary_key=True) id = models.CharField(max_length=30, primary_key=True)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
slug = models.CharField(max_length=100)
description = models.TextField()
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
skip_livestreams = models.BooleanField(default=True) skip_livestreams = models.BooleanField(default=True)
skip_shorts = models.BooleanField(default=True) skip_shorts = models.BooleanField(default=True)
keep_videos = models.IntegerField(null=True, default=None) 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): class Video(models.Model):
id = models.CharField(max_length=30, primary_key=True) id = models.CharField(max_length=30, primary_key=True)
title = models.CharField(max_length=200) title = models.CharField(max_length=200)
slug = models.CharField(max_length=209) slug = models.CharField(max_length=209)
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
published = models.DateTimeField() published = models.DateTimeField()
downloaded = models.DateTimeField(null=True) downloaded = models.DateTimeField(null=True)
description = models.TextField() 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,26 +1,23 @@
import enum
import math import math
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import List, Literal, Optional, Tuple
import wcag_contrast_ratio import wcag_contrast_ratio
from bordercrop import bordercrop
from colorthief import ColorThief from colorthief import ColorThief
from fonts.ttf import SourceSansPro from fonts.ttf import SourceSansPro
from PIL import Image, ImageDraw, ImageFilter, ImageFont from PIL import Image, ImageDraw, ImageFilter, ImageFont
from ucast.service import typ from ucast.service import typ
COVER_STYLE_BLUR = "blur"
COVER_STYLE_GRADIENT = "gradient"
CoverStyle = Literal["blur", "gradient"]
CHAR_ELLIPSIS = "" CHAR_ELLIPSIS = ""
COVER_WIDTH = 500 COVER_WIDTH = 500
class CoverStyle(enum.Enum):
CLASSIC = enum.auto()
BLUR = enum.auto()
def _split_text( def _split_text(
height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0 height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0
) -> List[str]: ) -> List[str]:
@ -180,22 +177,23 @@ def _get_baseimage(
""" """
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH)) cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
if style == CoverStyle.BLUR: if style == COVER_STYLE_GRADIENT:
ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width) # Thumbnail with color gradient background
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) cover_draw = ImageDraw.Draw(cover)
# Draw background gradient
for i, color in enumerate( for i, color in enumerate(
_interpolate_color(top_color, bottom_color, cover.height) _interpolate_color(top_color, bottom_color, cover.height)
): ):
cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1) 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 return cover
@ -207,12 +205,6 @@ def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image:
:param thumbnail: Thumbnail image object :param thumbnail: Thumbnail image object
:return: Resized 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 # Scale the thumbnail image down to cover size
tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height) tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height)
tn_16_9_height = int(COVER_WIDTH / 16 * 9) 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_top = int((tn_resize_height - tn_height) / 2)
tn_crop_y_bottom = tn_resize_height - tn_crop_y_top tn_crop_y_bottom = tn_resize_height - tn_crop_y_top
return thumbnail.resize( return thumbnail.resize((COVER_WIDTH, tn_resize_height), Image.LANCZOS).crop(
(COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS (0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom)
).crop((0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom)) )
def _draw_text_avatar( def _draw_text_avatar(
@ -246,7 +238,7 @@ def _draw_text_avatar(
avt_margin = int(tn_16_9_margin * 0.05) avt_margin = int(tn_16_9_margin * 0.05)
avt_size = tn_16_9_margin - 2 * avt_margin 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 = Image.new("L", (avt_size, avt_size))
circle_mask_draw = ImageDraw.Draw(circle_mask) circle_mask_draw = ImageDraw.Draw(circle_mask)

244
ucast/service/scrapetube.py Normal file
View file

@ -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)

79
ucast/service/storage.py Normal file
View file

@ -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

View file

@ -1,7 +1,47 @@
import shutil
from pathlib import Path
import requests 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 = requests.get(url, allow_redirects=True)
r.raise_for_status() r.raise_for_status()
open(download_path, "wb").write(r.content) 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,94 +1,139 @@
import json import datetime
import logging
import re
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from operator import itemgetter from operator import itemgetter
from pathlib import Path
from typing import List, Optional
import feedparser
import requests import requests
from scrapetube import scrapetube from django.conf import settings
from mutagen import id3
from yt_dlp import YoutubeDL from yt_dlp import YoutubeDL
from ucast.service import util from ucast.service import scrapetube, util
class VideoInfo: class ItemNotFoundError(Exception):
"""Mapping of YoutubeDL's video information""" pass
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): class ThumbnailNotFoundError(Exception):
pass 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. 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 The thumbnail file ending is added to the path.
Pillow anyway.
:param vinfo: Video info (from ``get_video_info()``) :param vinfo: Video info (from ``get_video_info()``)
:param download_path: Path of the thumbnail file :param download_path: Path of the thumbnail file
:raise ThumbnailNotFoundError: if no thumbnail could be found (YT returned 404) :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): for tn in sorted(vinfo.thumbnails, key=itemgetter("preference"), reverse=True):
url = tn["url"] url = tn["url"]
print(f"downloading thumbnail {url}...") logging.info(f"downloading thumbnail {url}...")
try: try:
util.download_file(url, download_path) return util.download_image_file(url, download_path)
return
except requests.HTTPError: except requests.HTTPError:
print(f"downloading thumbnail {url} failed") logging.warning(f"downloading thumbnail {url} failed")
pass pass
raise ThumbnailNotFoundError(f"could not find thumbnail for video {vinfo}") 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: with YoutubeDL() as ydl:
info = ydl.extract_info(video_id, download=False) 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 = { ydl_params = {
"format": "bestaudio", "format": "bestaudio",
"postprocessors": [ "postprocessors": [
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3"}, {"key": "FFmpegExtractAudio", "preferredcodec": "mp3"},
], ],
"outtmpl": str(download_path), "outtmpl": str(tmp_dld_file),
} }
if sponsorblock: if sponsorblock:
@ -107,34 +152,76 @@ def download_video(video_id, download_path, sponsorblock=False) -> VideoInfo:
with YoutubeDL(ydl_params) as ydl: with YoutubeDL(ydl_params) as ydl:
# extract_info downloads the video and returns its metadata # extract_info downloads the video and returns its metadata
info = ydl.extract_info(video_id) info = ydl.extract_info(video_id)
return VideoInfo(info)
shutil.move(tmp_dld_file, download_path)
return VideoDetails.from_vinfo(info)
@dataclass def tag_audio(audio_path: Path, vinfo: VideoDetails, cover_path: Path):
class ChannelMetadata: title_text = f"{vinfo.published.date().isoformat()} {vinfo.title}"
id: str
name: str audio = id3.ID3(audio_path)
description: str audio["TPE1"] = id3.TPE1(encoding=3, text=vinfo.channel_name) # Artist
avatar_url: str 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: def channel_url_from_id(channel_id: str) -> str:
return "https://www.youtube.com/channel/" + channel_id return "https://www.youtube.com/channel/" + channel_id
def get_channel_metadata(channel_url: str) -> ChannelMetadata: def channel_url_from_str(channel_str: str) -> str:
session = requests.Session() """
session.headers[ Get the channel URL from user input. The following types are accepted:
"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"
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) :param channel_str: Channel string
data = json.loads( :return: Channel URL
scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}" """
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"] metadata = data["metadata"]["channelMetadataRenderer"]
channel_id = metadata["externalId"] channel_id = metadata["externalId"]
@ -143,3 +230,77 @@ def get_channel_metadata(channel_url: str) -> ChannelMetadata:
avatar = metadata["avatar"]["thumbnails"][0]["url"] avatar = metadata["avatar"]["thumbnails"][0]["url"]
return ChannelMetadata(channel_id, name, description, avatar) 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

0
ucast/tasks/__init__.py Normal file
View file

113
ucast/tasks/download.py Normal file
View file

@ -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()

View file

@ -25,8 +25,7 @@ from ucast.service import cover, typ
( (
1000, 1000,
300, 300,
"Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! \ "Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! Du nicht von Gott, Tyrann!",
Du nicht von Gott, Tyrann!",
[ [
"Ha! du wärst", "Ha! du wärst",
"Obrigkeit von", "Obrigkeit von",
@ -74,31 +73,31 @@ def test_get_text_color(bg_color: typ.Color, text_color: typ.Color):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"n_image,title,channel,style", "n_image,title,channel,style",
[ [
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.CoverStyle.CLASSIC), (1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_GRADIENT),
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.CoverStyle.BLUR), (1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_BLUR),
( (
2, 2,
"Sintel - Open Movie by Blender Foundation", "Sintel - Open Movie by Blender Foundation",
"Blender", "Blender",
cover.CoverStyle.CLASSIC, cover.COVER_STYLE_GRADIENT,
), ),
( (
2, 2,
"Sintel - Open Movie by Blender Foundation", "Sintel - Open Movie by Blender Foundation",
"Blender", "Blender",
cover.CoverStyle.BLUR, cover.COVER_STYLE_BLUR,
), ),
( (
3, 3,
"Systemabsturz Teaser zur DiVOC bb3", "Systemabsturz Teaser zur DiVOC bb3",
"media.ccc.de", "media.ccc.de",
cover.CoverStyle.CLASSIC, cover.COVER_STYLE_GRADIENT,
), ),
( (
3, 3,
"Systemabsturz Teaser zur DiVOC bb3", "Systemabsturz Teaser zur DiVOC bb3",
"media.ccc.de", "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" tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{n_image}.webp"
av_file = tests.DIR_TESTFILES / "avatar" / f"a{n_image}.jpg" av_file = tests.DIR_TESTFILES / "avatar" / f"a{n_image}.jpg"
expected_cv_file = ( expected_cv_file = tests.DIR_TESTFILES / "cover" / f"c{n_image}_{style}.png"
tests.DIR_TESTFILES / "cover" / f"c{n_image}_{style.name.lower()}.png"
)
tn_image = Image.open(tn_file) tn_image = Image.open(tn_file)
av_image = Image.open(av_file) av_image = Image.open(av_file)
@ -136,7 +133,7 @@ def test_create_cover_image_noavatar():
None, None,
"ThetaDev @ Embedded World 2019", "ThetaDev @ Embedded World 2019",
"ThetaDev", "ThetaDev",
cover.CoverStyle.CLASSIC, cover.COVER_STYLE_GRADIENT,
) )
assert cv_image.width == cover.COVER_WIDTH assert cv_image.width == cover.COVER_WIDTH
@ -165,7 +162,7 @@ def test_create_blank_cover_image():
def test_create_cover_file(): def test_create_cover_file():
tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp" tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp"
av_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg" 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_o = tempfile.TemporaryDirectory()
tmpdir = Path(tmpdir_o.name) tmpdir = Path(tmpdir_o.name)
@ -176,7 +173,7 @@ def test_create_cover_file():
av_file, av_file,
"ThetaDev @ Embedded World 2019", "ThetaDev @ Embedded World 2019",
"ThetaDev", "ThetaDev",
cover.CoverStyle.CLASSIC, "gradient",
cv_file, cv_file,
) )

View file

@ -6,7 +6,7 @@ from PIL import Image, ImageChops
from ucast import tests from ucast import tests
from ucast.service import util 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(): def test_download_file():

View file

@ -1,7 +1,7 @@
import datetime
import re import re
import subprocess import subprocess
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -20,8 +20,8 @@ CHANNEL_URL_BLENDER = "https://www.youtube.com/c/BlenderFoundation"
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def video_info() -> youtube.VideoInfo: def video_info() -> youtube.VideoDetails:
return youtube.get_video_info(VIDEO_ID_SINTEL) return youtube.get_video_details(VIDEO_ID_SINTEL)
def test_download_thumbnail(video_info): def test_download_thumbnail(video_info):
@ -30,7 +30,8 @@ def test_download_thumbnail(video_info):
tn_file = tmpdir / "thumbnail" tn_file = tmpdir / "thumbnail"
expected_tn_file = tests.DIR_TESTFILES / "thumbnail" / "t2.webp" 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) tn = Image.open(tn_file)
expected_tn = Image.open(expected_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_currently_live
assert not video_info.is_livestream assert not video_info.is_livestream
assert not video_info.is_short 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(): 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.id == VIDEO_ID_SHORT
assert ( assert (
vinfo.title vinfo.title
@ -85,7 +88,7 @@ def test_download_video():
tmpdir = Path(tmpdir_o.name) tmpdir = Path(tmpdir_o.name)
download_file = tmpdir / "download.mp3" 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.id == VIDEO_ID_PERSUASION
assert vinfo.title == "Persuasion (Instrumental) RYYZN (No Copyright Music)" assert vinfo.title == "Persuasion (Instrumental) RYYZN (No Copyright Music)"
assert vinfo.duration == 100 assert vinfo.duration == 100
@ -109,13 +112,13 @@ def test_download_video():
youtube.channel_url_from_id(CHANNEL_ID_THETADEV), youtube.channel_url_from_id(CHANNEL_ID_THETADEV),
CHANNEL_ID_THETADEV, CHANNEL_ID_THETADEV,
"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_URL_BLENDER,
CHANNEL_ID_BLENDER, CHANNEL_ID_BLENDER,
"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",
), ),
], ],
) )

View file

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View file

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View file

@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/4.0/ref/settings/
""" """
import os import os
import time
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
@ -175,11 +176,11 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = "en-us" 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) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/ # 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 # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Delay between YouTube API calls
YOUTUBE_SCRAPE_DELAY = 1