Compare commits

...

5 commits

Author SHA1 Message Date
9d4781165f remove emoji from workflow
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-09 00:31:54 +02:00
2d279ba4b4 add drone ci workflow
Some checks reported errors
continuous-integration/drone Build was killed
2022-05-09 00:22:55 +02:00
e2189f84f4 crop black borders from cover images 2022-05-09 00:15:45 +02:00
219032cb61 delete old test pics 2022-05-08 23:28:28 +02:00
fdaca6b132 add new blurred thumbnail type 2022-05-08 23:27:03 +02:00
21 changed files with 386 additions and 59 deletions

11
.drone.yml Normal file
View file

@ -0,0 +1,11 @@
kind: pipeline
name: default
type: docker
steps:
- name: Test
image: d21d3q/python-poetry:3.10
commands:
- poetry install
- poetry run invoke lint
- poetry run invoke test

View file

@ -14,11 +14,9 @@ abrufen kann.
## Technik
Der Server sollte mit dem Webframework [Flask](https://flask.palletsprojects.com/)
Der Server sollte mit dem Webframework [Django](https://djangoproject.com/)
realisiert werden.
Daten sollten entweder in einer SQLite-Datenbank oder in JSON-Dateien abgelegt werden.
Die Weboberfläche wird mit Jinja-Templates gerendert, auf ein JS-Framework kann vorerst verzichtet werden.
Für ein ansehnliches Ansehen sorgt Bootstrap.

View file

@ -1,6 +1,14 @@
# Coverbilder
Podcast-Cover sind quadratisch.
Podcast-Cover sind quadratisch, während YT-Thumbnails das Seitenverhältnis
16:9 haben. Da Thumbnails häufig Textelemente beinhalten, ist es nicht
vorteilhaft, das Thumbnail einfach quadratisch zuzuschneiden.
Stattdessen sollte Ucast das Thumbnail nach oben und unten farblich
passend erweitern und den Videotitel und Kanalnamen einfügen.
![](../tests/testfiles/thumbnail/t2.webp)
![](../tests/testfiles/cover/c2.png)
- Durchschnittliche Farbe der oberen und unteren 20% des Bilds berechnen
- Farbverlauf zwischen diesen Farben als Hintergrund verwenden

View file

@ -3,13 +3,9 @@
## Verzeichnisstruktur
```txt
_ config
|_ config.toml
_ data
|_ LinusTechTips
|_ .ucast
|_ videos.json # IDs und Metadaten aller heruntergeladenen Videos
|_ options.json # Kanalspezifische Optionen (ID, LastScan)
|_ avatar.png # Profilbild des Kanals
|_ feed.xml # RSS-Feed
|_ covers # Cover-Bilder
@ -30,28 +26,26 @@ _ data
### ChannelOptions
- ID: str
- Active: bool = True
- LastScan: datetime
- SkipLivestreams: bool = True
- SkipShorts: bool = True
- KeepVideos: int = -1
- ID: `str, max_length=30`
- Active: `bool = True`
- LastScan: `datetime`
- SkipLivestreams: `bool = True`
- SkipShorts: `bool = True`
- KeepVideos: `int, nullable`
- Videos: `-> Video (1->n)`
### Videos
- Videos: dict[id: str -> Video]
### Video
- ID: str
- Title: str
- Slug: str (YYMMDD_Title, used as filename)
- Published: datetime
- Description: str
- ID: `str, max_length=30`
- Title: `str, max_length=200`
- Slug: `str, max_length=209` (YYYYMMDD_Title, used as filename)
- Published: `datetime`
- Downloaded: `datetime, nullable`
- Description: `text`
### Config
- RedisURL: str
- ScanInterval: 1h
- DefaultChannelOptions: ChannelOptions
- AppriseUrl: str (für Benachrichtigungen, https://github.com/caronc/apprise/wiki)

209
poetry.lock generated
View file

@ -31,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"
@ -69,6 +80,14 @@ python-versions = "*"
[package.dependencies]
pycparser = "*"
[[package]]
name = "cfgv"
version = "3.3.1"
description = "Validate configuration and produce human readable error messages."
category = "dev"
optional = false
python-versions = ">=3.6.1"
[[package]]
name = "charset-normalizer"
version = "2.0.12"
@ -113,6 +132,14 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "distlib"
version = "0.3.4"
description = "Distribution utilities"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "django"
version = "4.0.4"
@ -141,6 +168,18 @@ python-versions = ">=3.6"
[package.dependencies]
sgmllib3k = "*"
[[package]]
name = "filelock"
version = "3.6.0"
description = "A platform independent file lock."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"]
testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"]
[[package]]
name = "font-source-sans-pro"
version = "0.0.1"
@ -157,6 +196,17 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "identify"
version = "2.5.0"
description = "File identification library for Python"
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
license = ["ukkonen"]
[[package]]
name = "idna"
version = "3.3"
@ -189,6 +239,14 @@ category = "main"
optional = false
python-versions = ">=3.5, <4"
[[package]]
name = "nodeenv"
version = "1.6.0"
description = "Node.js virtual environment builder"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "packaging"
version = "21.3"
@ -212,6 +270,18 @@ python-versions = ">=3.7"
docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
[[package]]
name = "platformdirs"
version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
[[package]]
name = "pluggy"
version = "1.0.0"
@ -224,6 +294,22 @@ python-versions = ">=3.6"
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "2.19.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev"
optional = false
python-versions = ">=3.7"
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
toml = "*"
virtualenv = ">=20.0.8"
[[package]]
name = "py"
version = "1.11.0"
@ -310,6 +396,14 @@ pytest = ">=5.4.0"
docs = ["sphinx", "sphinx-rtd-theme"]
testing = ["django", "django-configurations (>=2.0)"]
[[package]]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "requests"
version = "2.27.1"
@ -356,6 +450,14 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sqlparse"
version = "0.4.2"
@ -364,6 +466,14 @@ category = "main"
optional = false
python-versions = ">=3.5"
[[package]]
name = "toml"
version = "0.10.2"
description = "Python Library for Tom's Obvious, Minimal Language"
category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "tomli"
version = "2.0.1"
@ -401,6 +511,24 @@ brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "virtualenv"
version = "20.14.1"
description = "Virtual Python Environment builder"
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[package.dependencies]
distlib = ">=0.3.1,<1"
filelock = ">=3.2,<4"
platformdirs = ">=2,<3"
six = ">=1.9.0,<2"
[package.extras]
docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"]
testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"]
[[package]]
name = "wcag-contrast-ratio"
version = "0.9"
@ -436,7 +564,7 @@ websockets = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "0288eb9eca14b78ee0cfac28cef0dcf17701165b6aa778255bfa73b94d5b2c4f"
content-hash = "99e2a5970962f1e936da2010b8ec997026f5afe4762af4345da287125e6b7771"
[metadata.files]
asgiref = [
@ -451,6 +579,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"},
@ -603,6 +735,10 @@ cffi = [
{file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"},
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
]
cfgv = [
{file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"},
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
]
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"},
@ -658,6 +794,10 @@ coverage = [
{file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"},
{file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"},
]
distlib = [
{file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"},
{file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
]
django = [
{file = "Django-4.0.4-py3-none-any.whl", hash = "sha256:07c8638e7a7f548dc0acaaa7825d84b7bd42b10e8d22268b3d572946f1e9b687"},
{file = "Django-4.0.4.tar.gz", hash = "sha256:4e8177858524417563cc0430f29ea249946d831eacb0068a1455686587df40b5"},
@ -666,6 +806,10 @@ feedparser = [
{file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"},
{file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},
]
filelock = [
{file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"},
{file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"},
]
font-source-sans-pro = [
{file = "font-source-sans-pro-0.0.1.tar.gz", hash = "sha256:3f81d8e52b0d7e930e2c867c0d3ee549312d03f97b71b664a8361006311f72e5"},
{file = "font_source_sans_pro-0.0.1-py2-none-any.whl", hash = "sha256:685c8813d59941e84ea326f46d638871adbc825a0aa5205a72ee9ed9c5fbb471"},
@ -676,6 +820,10 @@ fonts = [
{file = "fonts-0.0.3-py3-none-any.whl", hash = "sha256:e5f551379088ab260c2537980c3ccdff8af93408d9d4fa3319388d2ee25b7b6d"},
{file = "fonts-0.0.3.tar.gz", hash = "sha256:c626655b75a60715e118e44e270656fd22fd8f54252901ff6ebf1308ad01c405"},
]
identify = [
{file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"},
{file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"},
]
idna = [
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
@ -692,6 +840,10 @@ mutagen = [
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
]
nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
]
packaging = [
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
@ -736,10 +888,18 @@ pillow = [
{file = "Pillow-9.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458"},
{file = "Pillow-9.1.0.tar.gz", hash = "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97"},
]
platformdirs = [
{file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
{file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
]
pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
pre-commit = [
{file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"},
{file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"},
]
py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
@ -793,6 +953,41 @@ pytest-django = [
{file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"},
{file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"},
]
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
requests = [
{file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
{file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
@ -806,10 +1001,18 @@ scrapetube = [
sgmllib3k = [
{file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
sqlparse = [
{file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
{file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"},
]
toml = [
{file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
@ -826,6 +1029,10 @@ urllib3 = [
{file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"},
{file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
]
virtualenv = [
{file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"},
{file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"},
]
wcag-contrast-ratio = [
{file = "wcag-contrast-ratio-0.9.tar.gz", hash = "sha256:69192b8e5c0a7d0dc5ff1187eeb3e398141633a4bde51c69c87f58fe87ed361c"},
]

View file

@ -20,12 +20,14 @@ colorthief = "^0.2.1"
wcag-contrast-ratio = "^0.9"
font-source-sans-pro = "^0.0.1"
fonts = "^0.0.3"
bordercrop = "^1.0.0"
[tool.poetry.dev-dependencies]
pytest = "^7.1.1"
pytest-cov = "^3.0.0"
invoke = "^1.7.0"
pytest-django = "^4.5.2"
pre-commit = "^2.19.0"
[tool.poetry.scripts]
"ucast-manage" = "ucast.manage:main"

View file

@ -11,7 +11,17 @@ os.chdir(Path(__file__).absolute().parent)
@task
def test(c):
c.run("pytest tests", pty=True)
c.run("pytest", pty=True)
@task
def lint(c):
c.run("pre-commit run -a", pty=True)
@task
def format(c):
c.run("pre-commit run black -a", pty=True)
@task
@ -24,19 +34,24 @@ def get_cover(c, vid=""):
vinfo = youtube.get_video_info(vid)
title = vinfo["fulltitle"]
channel_name = vinfo["uploader"]
thumbnail_url = youtube.get_thumbnail_url(vinfo)
channel_url = vinfo["channel_url"]
channel_metadata = youtube.get_channel_metadata(channel_url)
ti = 1
while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}.png"):
while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}_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}.png"
cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_classic.png"
cv_blur_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_blur.png"
util.download_file(thumbnail_url, 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, cv_file)
cover.create_cover_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.CoverStyle.BLUR, cv_blur_file
)

View file

@ -1,3 +1,3 @@
from django.contrib import admin
from django.contrib import admin # noqa: F401
# Register your models here.

View file

@ -1,12 +1,14 @@
# coding=utf-8
import enum
import math
from pathlib import Path
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, ImageFont
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from yt2podcast.service import typ
@ -14,6 +16,11 @@ 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]:
@ -110,12 +117,57 @@ def _get_text_color(bg_color) -> typ.Color:
return 0, 0, 0
def _get_baseimage(
thumbnail: Image.Image,
top_color: typ.Color,
bottom_color: typ.Color,
style: CoverStyle,
):
if style == CoverStyle.BLUR:
ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width)
ctn_l = int((ctn_width - COVER_WIDTH) / 2)
ctn_r = ctn_width - ctn_l
cover = (
thumbnail.resize((ctn_width, COVER_WIDTH), Image.Resampling.LANCZOS)
.crop((ctn_l, 0, ctn_r, COVER_WIDTH))
.filter(ImageFilter.GaussianBlur(20))
)
else:
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
cover_draw = ImageDraw.Draw(cover)
# Draw background gradient
for i, color in enumerate(
_interpolate_color(top_color, bottom_color, cover.height)
):
cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1)
return cover
def _create_cover_image(
thumbnail: Image.Image, avatar: Optional[Image.Image], title: str, channel: str
thumbnail: Image.Image,
avatar: Optional[Image.Image],
title: str,
channel: str,
style: CoverStyle,
) -> Image.Image:
# Remove black bars from thumbnail
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_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height)
tn = thumbnail.resize((COVER_WIDTH, tn_height), Image.Resampling.LANCZOS)
tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height)
tn_16_9_height = int(COVER_WIDTH / 16 * 9)
tn_height = min(tn_resize_height, tn_16_9_height)
tn_crop_t = int((tn_resize_height - tn_height) / 2)
tn_crop_b = tn_resize_height - tn_crop_t
tn = thumbnail.resize(
(COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS
).crop((0, tn_crop_t, COVER_WIDTH, tn_crop_b))
# Get dominant colors from the top and bottom 20% of the thumbnail image
top_part = tn.crop((0, 0, COVER_WIDTH, int(tn_height * 0.2)))
@ -123,18 +175,12 @@ def _create_cover_image(
top_color = _get_dominant_color(top_part)
bottom_color = _get_dominant_color(bottom_part)
# Create new cover image
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
cover = _get_baseimage(thumbnail, top_color, bottom_color, style)
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)
# Insert thumbnail image in the middle
tn_margin = int((COVER_WIDTH - tn_height) / 2)
tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2)
cover.paste(tn, (0, tn_margin))
# Add channel avatar
@ -142,8 +188,8 @@ def _create_cover_image(
avt_size = 0
if avatar:
avt_margin = int(tn_margin * 0.05)
avt_size = tn_margin - 2 * avt_margin
avt_margin = int(tn_16_9_margin * 0.05)
avt_size = tn_16_9_margin - 2 * avt_margin
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
@ -169,7 +215,7 @@ def _create_cover_image(
text_margin_topleft,
text_vertical_offset,
COVER_WIDTH - text_margin_x,
tn_margin,
tn_16_9_margin,
),
channel,
fnt,
@ -180,7 +226,7 @@ def _create_cover_image(
cover_draw,
(
text_margin_x,
COVER_WIDTH - tn_margin + text_vertical_offset,
COVER_WIDTH - tn_16_9_margin + text_vertical_offset,
COVER_WIDTH - text_margin_x,
COVER_WIDTH,
),
@ -198,6 +244,7 @@ def create_cover_file(
avatar_path: Optional[Path],
title: str,
channel: str,
style: CoverStyle,
cover_path: Path,
):
thumbnail = Image.open(thumbnail_path)
@ -206,5 +253,5 @@ def create_cover_file(
if avatar_path:
avatar = Image.open(avatar_path)
cvr = _create_cover_image(thumbnail, avatar, title, channel)
cvr = _create_cover_image(thumbnail, avatar, title, channel, style)
cvr.save(cover_path)

View file

@ -4,4 +4,5 @@ import requests
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)

View file

@ -7,12 +7,24 @@ import requests
from scrapetube import scrapetube
from yt_dlp import YoutubeDL
from yt2podcast.service import util
def get_thumbnail_url(vinfo):
"""Get the best quality thumbnail"""
return max(vinfo["thumbnails"], key=itemgetter("preference"))["url"]
def download_thumbnail(vinfo, download_path):
best_url = get_thumbnail_url(vinfo)
try:
util.download_file(best_url, download_path)
except requests.exceptions.HTTPError:
default_url = vinfo["thumbnail"]
util.download_file(default_url, download_path)
def get_video_info(video_id):
with YoutubeDL() as ydl:
return ydl.extract_info(video_id, download=False)

View file

@ -50,7 +50,7 @@ def test_split_text(height: int, width: int, text: str, expect: List[str]):
"file_name,color",
[
("t1.webp", (63, 63, 62)),
("t2.webp", (74, 45, 37)),
("t2.webp", (17, 14, 15)),
("t3.webp", (54, 24, 28)),
],
)
@ -73,23 +73,50 @@ def test_get_text_color(bg_color: typ.Color, text_color: typ.Color):
@pytest.mark.parametrize(
"n_image,title,channel",
"n_image,title,channel,style",
[
(1, "ThetaDev @ Embedded World 2019", "ThetaDev"),
(2, "Sintel - Open Movie by Blender Foundation", "Blender"),
(3, "Systemabsturz Teaser zur DiVOC bb3", "media.ccc.de"),
(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.CoverStyle.CLASSIC,
),
(
2,
"Sintel - Open Movie by Blender Foundation",
"Blender",
cover.CoverStyle.BLUR,
),
(
3,
"Systemabsturz Teaser zur DiVOC bb3",
"media.ccc.de",
cover.CoverStyle.CLASSIC,
),
(
3,
"Systemabsturz Teaser zur DiVOC bb3",
"media.ccc.de",
cover.CoverStyle.BLUR,
),
],
)
def test_create_cover_image(n_image: int, title: str, channel: str):
def test_create_cover_image(
n_image: int, title: str, channel: str, style: cover.CoverStyle
):
tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{n_image}.webp"
av_file = tests.DIR_TESTFILES / "avatar" / f"a{n_image}.jpg"
expected_cv_file = tests.DIR_TESTFILES / "cover" / f"c{n_image}.png"
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)
expected_cv_image = Image.open(expected_cv_file)
cv_image = cover._create_cover_image(tn_image, av_image, title, channel)
cv_image = cover._create_cover_image(tn_image, av_image, title, channel, style)
diff = ImageChops.difference(cv_image, expected_cv_image)
assert diff.getbbox() is None
@ -98,14 +125,19 @@ def test_create_cover_image(n_image: int, title: str, channel: str):
def test_create_cover_file():
tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp"
av_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg"
expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1.png"
expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1_classic.png"
tmpdir_o = tempfile.TemporaryDirectory()
tmpdir = Path(tmpdir_o.name)
cv_file = tmpdir / "cover.png"
cover.create_cover_file(
tn_file, av_file, "ThetaDev @ Embedded World 2019", "ThetaDev", cv_file
tn_file,
av_file,
"ThetaDev @ Embedded World 2019",
"ThetaDev",
cover.CoverStyle.CLASSIC,
cv_file,
)
cv_image = Image.open(cv_file)

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View file

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View file

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,3 +1,3 @@
from django.shortcuts import render
from django.shortcuts import render # noqa: F401
# Create your views here.