diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..155b785 --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/README.md b/README.md index 6195dbd..329bbb3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/notes/Coverbilder.md b/notes/Coverbilder.md index 18bba24..c5ff94a 100644 --- a/notes/Coverbilder.md +++ b/notes/Coverbilder.md @@ -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 diff --git a/notes/Speicher.md b/notes/Speicher.md index 503587c..7b3857e 100644 --- a/notes/Speicher.md +++ b/notes/Speicher.md @@ -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) diff --git a/poetry.lock b/poetry.lock index 025af55..195374a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, ] diff --git a/pyproject.toml b/pyproject.toml index ba41970..0323b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tasks.py b/tasks.py index d803b2d..832c200 100644 --- a/tasks.py +++ b/tasks.py @@ -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 + ) diff --git a/yt2podcast/admin.py b/yt2podcast/admin.py index 8c38f3f..4fd5490 100644 --- a/yt2podcast/admin.py +++ b/yt2podcast/admin.py @@ -1,3 +1,3 @@ -from django.contrib import admin +from django.contrib import admin # noqa: F401 # Register your models here. diff --git a/yt2podcast/service/cover.py b/yt2podcast/service/cover.py index 38db4da..8049b79 100644 --- a/yt2podcast/service/cover.py +++ b/yt2podcast/service/cover.py @@ -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) diff --git a/yt2podcast/service/util.py b/yt2podcast/service/util.py index a65bc97..c58a39c 100644 --- a/yt2podcast/service/util.py +++ b/yt2podcast/service/util.py @@ -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) diff --git a/yt2podcast/service/youtube.py b/yt2podcast/service/youtube.py index 8e56d79..a880ef2 100644 --- a/yt2podcast/service/youtube.py +++ b/yt2podcast/service/youtube.py @@ -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) diff --git a/yt2podcast/tests/test_cover.py b/yt2podcast/tests/test_cover.py index 518ac25..8a4d457 100644 --- a/yt2podcast/tests/test_cover.py +++ b/yt2podcast/tests/test_cover.py @@ -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) diff --git a/yt2podcast/tests/testfiles/cover/c1_blur.png b/yt2podcast/tests/testfiles/cover/c1_blur.png new file mode 100644 index 0000000..1ea1caf Binary files /dev/null and b/yt2podcast/tests/testfiles/cover/c1_blur.png differ diff --git a/yt2podcast/tests/testfiles/cover/c1.png b/yt2podcast/tests/testfiles/cover/c1_classic.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c1.png rename to yt2podcast/tests/testfiles/cover/c1_classic.png diff --git a/yt2podcast/tests/testfiles/cover/c2.png b/yt2podcast/tests/testfiles/cover/c2.png deleted file mode 100644 index ab4d221..0000000 Binary files a/yt2podcast/tests/testfiles/cover/c2.png and /dev/null differ diff --git a/yt2podcast/tests/testfiles/cover/c2_blur.png b/yt2podcast/tests/testfiles/cover/c2_blur.png new file mode 100644 index 0000000..453d455 Binary files /dev/null and b/yt2podcast/tests/testfiles/cover/c2_blur.png differ diff --git a/yt2podcast/tests/testfiles/cover/c2_classic.png b/yt2podcast/tests/testfiles/cover/c2_classic.png new file mode 100644 index 0000000..3fb1cae Binary files /dev/null and b/yt2podcast/tests/testfiles/cover/c2_classic.png differ diff --git a/yt2podcast/tests/testfiles/cover/c3_blur.png b/yt2podcast/tests/testfiles/cover/c3_blur.png new file mode 100644 index 0000000..24b24bd Binary files /dev/null and b/yt2podcast/tests/testfiles/cover/c3_blur.png differ diff --git a/yt2podcast/tests/testfiles/cover/c3.png b/yt2podcast/tests/testfiles/cover/c3_classic.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c3.png rename to yt2podcast/tests/testfiles/cover/c3_classic.png diff --git a/yt2podcast/tests/testfiles/thumbnail/t2.webp b/yt2podcast/tests/testfiles/thumbnail/t2.webp index fead8a8..b54d902 100644 Binary files a/yt2podcast/tests/testfiles/thumbnail/t2.webp and b/yt2podcast/tests/testfiles/thumbnail/t2.webp differ diff --git a/yt2podcast/views.py b/yt2podcast/views.py index 91ea44a..7a02d1f 100644 --- a/yt2podcast/views.py +++ b/yt2podcast/views.py @@ -1,3 +1,3 @@ -from django.shortcuts import render +from django.shortcuts import render # noqa: F401 # Create your views here.