Compare commits

..

No commits in common. "9d4781165f8b7e70dd7526411708a86fb25786c3" and "5def0f3c61958d1c67ad898547e62d5ce35342a9" have entirely different histories.

21 changed files with 59 additions and 386 deletions

View file

@ -1,11 +0,0 @@
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,9 +14,11 @@ abrufen kann.
## Technik
Der Server sollte mit dem Webframework [Django](https://djangoproject.com/)
Der Server sollte mit dem Webframework [Flask](https://flask.palletsprojects.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,14 +1,6 @@
# Coverbilder
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)
Podcast-Cover sind quadratisch.
- Durchschnittliche Farbe der oberen und unteren 20% des Bilds berechnen
- Farbverlauf zwischen diesen Farben als Hintergrund verwenden

View file

@ -3,9 +3,13 @@
## 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
@ -26,26 +30,28 @@ _ data
### ChannelOptions
- ID: `str, max_length=30`
- Active: `bool = True`
- LastScan: `datetime`
- SkipLivestreams: `bool = True`
- SkipShorts: `bool = True`
- KeepVideos: `int, nullable`
- Videos: `-> Video (1->n)`
- ID: str
- Active: bool = True
- LastScan: datetime
- SkipLivestreams: bool = True
- SkipShorts: bool = True
- KeepVideos: int = -1
### Videos
- Videos: dict[id: str -> Video]
### Video
- 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`
- ID: str
- Title: str
- Slug: str (YYMMDD_Title, used as filename)
- Published: datetime
- Description: str
### 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,17 +31,6 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
[[package]]
name = "bordercrop"
version = "1.0.0"
description = "A black borders cropping module"
category = "main"
optional = false
python-versions = ">=3.2, <4"
[package.dependencies]
Pillow = "*"
[[package]]
name = "brotli"
version = "1.0.9"
@ -80,14 +69,6 @@ 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"
@ -132,14 +113,6 @@ 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"
@ -168,18 +141,6 @@ 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"
@ -196,17 +157,6 @@ 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"
@ -239,14 +189,6 @@ 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"
@ -270,18 +212,6 @@ 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"
@ -294,22 +224,6 @@ 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"
@ -396,14 +310,6 @@ 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"
@ -450,14 +356,6 @@ 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"
@ -466,14 +364,6 @@ 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"
@ -511,24 +401,6 @@ 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"
@ -564,7 +436,7 @@ websockets = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "99e2a5970962f1e936da2010b8ec997026f5afe4762af4345da287125e6b7771"
content-hash = "0288eb9eca14b78ee0cfac28cef0dcf17701165b6aa778255bfa73b94d5b2c4f"
[metadata.files]
asgiref = [
@ -579,10 +451,6 @@ attrs = [
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
]
bordercrop = [
{file = "bordercrop-1.0.0-py3-none-any.whl", hash = "sha256:50342a4a7d3b37bd1188faf3bedcb4d4b264c3d7cc51a59d082d3afeaab86c0f"},
{file = "bordercrop-1.0.0.tar.gz", hash = "sha256:2cfd078f8214fcecc304ee9bc8e96b38c9decc3db96ee5301e31e60678322990"},
]
brotli = [
{file = "Brotli-1.0.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70"},
{file = "Brotli-1.0.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b"},
@ -735,10 +603,6 @@ 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"},
@ -794,10 +658,6 @@ 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"},
@ -806,10 +666,6 @@ 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"},
@ -820,10 +676,6 @@ 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"},
@ -840,10 +692,6 @@ 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"},
@ -888,18 +736,10 @@ 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"},
@ -953,41 +793,6 @@ 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"},
@ -1001,18 +806,10 @@ 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"},
@ -1029,10 +826,6 @@ 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,14 +20,12 @@ 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,17 +11,7 @@ os.chdir(Path(__file__).absolute().parent)
@task
def test(c):
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)
c.run("pytest tests", pty=True)
@task
@ -34,24 +24,19 @@ 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}_classic.png"):
while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}.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}_classic.png"
cv_blur_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_blur.png"
cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}.png"
youtube.download_thumbnail(vinfo, tn_file)
util.download_file(thumbnail_url, tn_file)
util.download_file(channel_metadata.avatar_url, av_file)
cover.create_cover_file(
tn_file, av_file, title, channel_name, cover.CoverStyle.CLASSIC, cv_file
)
cover.create_cover_file(
tn_file, av_file, title, channel_name, cover.CoverStyle.BLUR, cv_blur_file
)
cover.create_cover_file(tn_file, av_file, title, channel_name, cv_file)

View file

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

View file

@ -1,14 +1,12 @@
# 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, ImageFilter, ImageFont
from PIL import Image, ImageDraw, ImageFont
from yt2podcast.service import typ
@ -16,11 +14,6 @@ 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]:
@ -117,22 +110,20 @@ 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:
def _create_cover_image(
thumbnail: Image.Image, avatar: Optional[Image.Image], title: str, channel: str
) -> Image.Image:
# 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)
# 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)))
bottom_part = tn.crop((0, int(tn_height * 0.8), COVER_WIDTH, tn_height))
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_draw = ImageDraw.Draw(cover)
@ -142,45 +133,8 @@ def _get_baseimage(
):
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,
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_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)))
bottom_part = tn.crop((0, int(tn_height * 0.8), COVER_WIDTH, tn_height))
top_color = _get_dominant_color(top_part)
bottom_color = _get_dominant_color(bottom_part)
cover = _get_baseimage(thumbnail, top_color, bottom_color, style)
cover_draw = ImageDraw.Draw(cover)
# 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
@ -188,8 +142,8 @@ def _create_cover_image(
avt_size = 0
if avatar:
avt_margin = int(tn_16_9_margin * 0.05)
avt_size = tn_16_9_margin - 2 * avt_margin
avt_margin = int(tn_margin * 0.05)
avt_size = tn_margin - 2 * avt_margin
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
@ -215,7 +169,7 @@ def _create_cover_image(
text_margin_topleft,
text_vertical_offset,
COVER_WIDTH - text_margin_x,
tn_16_9_margin,
tn_margin,
),
channel,
fnt,
@ -226,7 +180,7 @@ def _create_cover_image(
cover_draw,
(
text_margin_x,
COVER_WIDTH - tn_16_9_margin + text_vertical_offset,
COVER_WIDTH - tn_margin + text_vertical_offset,
COVER_WIDTH - text_margin_x,
COVER_WIDTH,
),
@ -244,7 +198,6 @@ def create_cover_file(
avatar_path: Optional[Path],
title: str,
channel: str,
style: CoverStyle,
cover_path: Path,
):
thumbnail = Image.open(thumbnail_path)
@ -253,5 +206,5 @@ def create_cover_file(
if avatar_path:
avatar = Image.open(avatar_path)
cvr = _create_cover_image(thumbnail, avatar, title, channel, style)
cvr = _create_cover_image(thumbnail, avatar, title, channel)
cvr.save(cover_path)

View file

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

View file

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

View file

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View file

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