Compare commits
No commits in common. "a3c7be3ae3eaaa6352566b9f705f49a6b4a3565b" and "cb1fc64a7923dff47202468a99e6bc5283708639" have entirely different histories.
a3c7be3ae3
...
cb1fc64a79
2
.gitignore
vendored
|
@ -20,7 +20,7 @@ node_modules
|
|||
*.mp3
|
||||
|
||||
# Application data
|
||||
/_run*
|
||||
/_run
|
||||
*.sqlite3
|
||||
|
||||
# Generated assets
|
||||
|
|
|
@ -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
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
37
tasks.py
|
@ -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)
|
||||
|
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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="_")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 243 KiB |
BIN
ucast/tests/testfiles/cover/c2_classic.png
Normal file
After Width: | Height: | Size: 179 KiB |
Before Width: | Height: | Size: 183 KiB |
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
@ -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
|
||||
|
|