Compare commits
5 commits
2fc63c0cb1
...
89a190ad4a
Author | SHA1 | Date | |
---|---|---|---|
89a190ad4a | |||
11c679975b | |||
3eacc0ad8d | |||
27eeac66f0 | |||
ebe4ccf926 |
23 changed files with 536 additions and 168 deletions
21
.pre-commit-config.yaml
Normal file
21
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,21 @@
|
|||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.2.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.10.1
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 4.0.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [ Flake8-pyproject ]
|
||||
entry: flake8p
|
|
@ -62,6 +62,7 @@ _ data
|
|||
- Title: str, VARCHAR(200)
|
||||
- Slug: str (YYYYMMDD_Title, used as filename), VARCHAR(209)
|
||||
- Published: datetime
|
||||
- Downloaded: datetime
|
||||
- Description: str, VARCHAR(1000)
|
||||
|
||||
### Config
|
||||
|
|
175
poetry.lock
generated
175
poetry.lock
generated
|
@ -101,6 +101,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"
|
||||
|
@ -156,6 +164,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 = "feedparser"
|
||||
version = "6.0.8"
|
||||
|
@ -167,6 +183,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"
|
||||
|
@ -202,6 +230,17 @@ category = "main"
|
|||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[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"
|
||||
|
@ -280,6 +319,22 @@ category = "main"
|
|||
optional = false
|
||||
python-versions = ">=3.5, <4"
|
||||
|
||||
[[package]]
|
||||
name = "mysqlclient"
|
||||
version = "2.1.0"
|
||||
description = "Python interface to MySQL"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[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"
|
||||
|
@ -303,6 +358,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"
|
||||
|
@ -315,6 +382,30 @@ python-versions = ">=3.6"
|
|||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "2.18.1"
|
||||
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 = "psycopg2"
|
||||
version = "2.9.3"
|
||||
description = "psycopg2 - Python-PostgreSQL Database Adapter"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "py"
|
||||
version = "1.11.0"
|
||||
|
@ -595,6 +686,14 @@ category = "main"
|
|||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[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"
|
||||
|
@ -648,6 +747,24 @@ h11 = ">=0.8"
|
|||
[package.extras]
|
||||
standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
|
||||
|
||||
[[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"
|
||||
|
@ -697,7 +814,7 @@ websockets = "*"
|
|||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "3e8f9ea28feca928ee5e81f883e97e3d4390c859dc6a7850544003aff517dade"
|
||||
content-hash = "ff7d3c1941f088222a945efb832db370bad4daeee7298d08752aa4061f7c4846"
|
||||
|
||||
[metadata.files]
|
||||
alembic = [
|
||||
|
@ -872,6 +989,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"},
|
||||
|
@ -931,10 +1052,18 @@ 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"},
|
||||
]
|
||||
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"},
|
||||
|
@ -1006,6 +1135,10 @@ h11 = [
|
|||
{file = "h11-0.13.0-py3-none-any.whl", hash = "sha256:8ddd78563b633ca55346c8cd41ec0af27d3c79931828beffb46ce70a379e7442"},
|
||||
{file = "h11-0.13.0.tar.gz", hash = "sha256:70813c1135087a248a4d38cc0e1a0181ffab2188141a93eaf567940c3957ff06"},
|
||||
]
|
||||
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"},
|
||||
|
@ -1076,6 +1209,17 @@ mutagen = [
|
|||
{file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"},
|
||||
{file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"},
|
||||
]
|
||||
mysqlclient = [
|
||||
{file = "mysqlclient-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:02c8826e6add9b20f4cb12dcf016485f7b1d6e30356a1204d05431867a1b3947"},
|
||||
{file = "mysqlclient-2.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:b62d23c11c516cedb887377c8807628c1c65d57593b57853186a6ee18b0c6a5b"},
|
||||
{file = "mysqlclient-2.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:2c8410f54492a3d2488a6a53e2d85b7e016751a1e7d116e7aea9c763f59f5e8c"},
|
||||
{file = "mysqlclient-2.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:e6279263d5a9feca3e0edbc2b2a52c057375bf301d47da2089c075ff76331d14"},
|
||||
{file = "mysqlclient-2.1.0.tar.gz", hash = "sha256:973235686f1b720536d417bf0a0d39b4ab3d5086b2b6ad5e6752393428c02b12"},
|
||||
]
|
||||
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"},
|
||||
|
@ -1120,10 +1264,31 @@ 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.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"},
|
||||
{file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"},
|
||||
]
|
||||
psycopg2 = [
|
||||
{file = "psycopg2-2.9.3-cp310-cp310-win32.whl", hash = "sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362"},
|
||||
{file = "psycopg2-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca"},
|
||||
{file = "psycopg2-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56"},
|
||||
{file = "psycopg2-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305"},
|
||||
{file = "psycopg2-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2"},
|
||||
{file = "psycopg2-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461"},
|
||||
{file = "psycopg2-2.9.3-cp38-cp38-win32.whl", hash = "sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7"},
|
||||
{file = "psycopg2-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf"},
|
||||
{file = "psycopg2-2.9.3-cp39-cp39-win32.whl", hash = "sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126"},
|
||||
{file = "psycopg2-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c"},
|
||||
{file = "psycopg2-2.9.3.tar.gz", hash = "sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981"},
|
||||
]
|
||||
py = [
|
||||
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
|
||||
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
|
||||
|
@ -1294,6 +1459,10 @@ text-unidecode = [
|
|||
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
|
||||
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
|
||||
]
|
||||
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"},
|
||||
|
@ -1314,6 +1483,10 @@ uvicorn = [
|
|||
{file = "uvicorn-0.17.6-py3-none-any.whl", hash = "sha256:19e2a0e96c9ac5581c01eb1a79a7d2f72bb479691acd2b8921fce48ed5b961a6"},
|
||||
{file = "uvicorn-0.17.6.tar.gz", hash = "sha256:5180f9d059611747d841a4a4c4ab675edf54c8489e97f96d0583ee90ac3bfc23"},
|
||||
]
|
||||
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"},
|
||||
]
|
||||
|
|
|
@ -22,11 +22,15 @@ python-slugify = "^6.1.2"
|
|||
starlette-core = "^0.0.1"
|
||||
click = "^8.1.3"
|
||||
python-dotenv = "^0.20.0"
|
||||
mysqlclient = "^2.1.0"
|
||||
psycopg2 = "^2.9.3"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.2"
|
||||
pytest-cov = "^3.0.0"
|
||||
invoke = "^1.7.0"
|
||||
pre-commit = "^2.18.1"
|
||||
virtualenv = "20.14.1"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
ucast = "ucast.__main__:cli"
|
||||
|
@ -34,3 +38,10 @@ ucast = "ucast.__main__:cli"
|
|||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.flake8]
|
||||
max-line-length = 88
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py310']
|
||||
|
|
24
tasks.py
24
tasks.py
|
@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory
|
|||
|
||||
from invoke import task
|
||||
|
||||
from ucast import youtube, util, cover
|
||||
import tests
|
||||
from ucast import cover, util, youtube
|
||||
|
||||
os.chdir(Path(__file__).absolute().parent)
|
||||
db_file = Path("_run/ucast.db").absolute()
|
||||
|
@ -18,7 +18,7 @@ os.environ["DATABASE_URL"] = f"sqlite:///{db_file}"
|
|||
|
||||
@task
|
||||
def test(c):
|
||||
c.run('pytest tests', pty=True)
|
||||
c.run("pytest tests", pty=True)
|
||||
|
||||
|
||||
@task
|
||||
|
@ -29,21 +29,21 @@ def run(c):
|
|||
|
||||
|
||||
@task
|
||||
def get_cover(c, vid=''):
|
||||
def get_cover(c, vid=""):
|
||||
vinfo = youtube.get_video_info(vid)
|
||||
title = vinfo['fulltitle']
|
||||
channel_name = vinfo['uploader']
|
||||
title = vinfo["fulltitle"]
|
||||
channel_name = vinfo["uploader"]
|
||||
thumbnail_url = youtube.get_thumbnail_url(vinfo)
|
||||
channel_url = vinfo['channel_url']
|
||||
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}.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'
|
||||
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"
|
||||
|
||||
util.download_file(thumbnail_url, tn_file)
|
||||
util.download_file(channel_metadata.avatar_url, av_file)
|
||||
|
@ -52,7 +52,7 @@ def get_cover(c, vid=''):
|
|||
|
||||
|
||||
@task
|
||||
def add_migration(c, m=''):
|
||||
def add_migration(c, m=""):
|
||||
if not m:
|
||||
raise Exception("please input migration name")
|
||||
|
||||
|
@ -65,4 +65,4 @@ def add_migration(c, m=''):
|
|||
os.chdir("ucast")
|
||||
|
||||
c.run("alembic upgrade head")
|
||||
c.run(f"alembic revision --autogenerate -m {m}")
|
||||
c.run(f"alembic revision --autogenerate -m '{m}'")
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# coding=utf-8
|
||||
from importlib.resources import files
|
||||
|
||||
DIR_TESTFILES = files('tests.testfiles')
|
||||
DIR_TESTFILES = files("tests.testfiles")
|
||||
|
|
|
@ -1,60 +1,89 @@
|
|||
# coding=utf-8
|
||||
from typing import List
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageFont, ImageChops
|
||||
from fonts.ttf import SourceSansPro
|
||||
from PIL import Image, ImageChops, ImageFont
|
||||
|
||||
import tests
|
||||
from ucast import cover, typ
|
||||
|
||||
|
||||
@pytest.mark.parametrize('height,width,text,expect', [
|
||||
(40, 300, 'Hello', ['Hello']),
|
||||
(40, 300, 'Hello World, this is me', ['Hello World,…']),
|
||||
(90, 300, 'Hello World, this is me', ['Hello World, this', 'is me']),
|
||||
(90, 300, 'Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz', ['Rindfleischettik…']),
|
||||
(1000, 300, 'Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! Du nicht von Gott, Tyrann!',
|
||||
['Ha! du wärst', 'Obrigkeit von', 'Gott? Gott', 'spendet Segen', 'aus; du raubst!', 'Du nicht von Gott,',
|
||||
'Tyrann!']),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"height,width,text,expect",
|
||||
[
|
||||
(40, 300, "Hello", ["Hello"]),
|
||||
(40, 300, "Hello World, this is me", ["Hello World,…"]),
|
||||
(90, 300, "Hello World, this is me", ["Hello World, this", "is me"]),
|
||||
(
|
||||
90,
|
||||
300,
|
||||
"Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz",
|
||||
["Rindfleischettik…"],
|
||||
),
|
||||
(
|
||||
1000,
|
||||
300,
|
||||
"Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! \
|
||||
Du nicht von Gott, Tyrann!",
|
||||
[
|
||||
"Ha! du wärst",
|
||||
"Obrigkeit von",
|
||||
"Gott? Gott",
|
||||
"spendet Segen",
|
||||
"aus; du raubst!",
|
||||
"Du nicht von Gott,",
|
||||
"Tyrann!",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_split_text(height: int, width: int, text: str, expect: List[str]):
|
||||
font = ImageFont.truetype(SourceSansPro, 40)
|
||||
lines = cover._split_text(height, width, text, font, 8)
|
||||
assert lines == expect
|
||||
|
||||
|
||||
@pytest.mark.parametrize('file_name,color', [
|
||||
('t1.webp', (63, 63, 62)),
|
||||
('t2.webp', (74, 45, 37)),
|
||||
('t3.webp', (54, 24, 28)),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"file_name,color",
|
||||
[
|
||||
("t1.webp", (63, 63, 62)),
|
||||
("t2.webp", (74, 45, 37)),
|
||||
("t3.webp", (54, 24, 28)),
|
||||
],
|
||||
)
|
||||
def test_get_dominant_color(file_name: str, color: typ.Color):
|
||||
img = Image.open(tests.DIR_TESTFILES / 'thumbnail' / file_name)
|
||||
img = Image.open(tests.DIR_TESTFILES / "thumbnail" / file_name)
|
||||
c = cover._get_dominant_color(img)
|
||||
assert c == color
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bg_color,text_color', [
|
||||
((100, 0, 0), (255, 255, 255)),
|
||||
((200, 200, 0), (0, 0, 0)),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"bg_color,text_color",
|
||||
[
|
||||
((100, 0, 0), (255, 255, 255)),
|
||||
((200, 200, 0), (0, 0, 0)),
|
||||
],
|
||||
)
|
||||
def test_get_text_color(bg_color: typ.Color, text_color: typ.Color):
|
||||
c = cover._get_text_color(bg_color)
|
||||
assert c == text_color
|
||||
|
||||
|
||||
@pytest.mark.parametrize('n_image,title,channel', [
|
||||
(1, 'ThetaDev @ Embedded World 2019', 'ThetaDev'),
|
||||
(2, 'Sintel - Open Movie by Blender Foundation', 'Blender'),
|
||||
(3, 'Systemabsturz Teaser zur DiVOC bb3', 'media.ccc.de'),
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"n_image,title,channel",
|
||||
[
|
||||
(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):
|
||||
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'
|
||||
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"
|
||||
|
||||
tn_image = Image.open(tn_file)
|
||||
av_image = Image.open(av_file)
|
||||
|
@ -67,15 +96,17 @@ 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'
|
||||
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"
|
||||
|
||||
tmpdir_o = tempfile.TemporaryDirectory()
|
||||
tmpdir = Path(tmpdir_o.name)
|
||||
cv_file = tmpdir / 'cover.png'
|
||||
cv_file = tmpdir / "cover.png"
|
||||
|
||||
cover.create_cover_file(tn_file, av_file, 'ThetaDev @ Embedded World 2019', 'ThetaDev', cv_file)
|
||||
cover.create_cover_file(
|
||||
tn_file, av_file, "ThetaDev @ Embedded World 2019", "ThetaDev", cv_file
|
||||
)
|
||||
|
||||
cv_image = Image.open(cv_file)
|
||||
expected_cv_image = Image.open(expected_cv_file)
|
||||
|
|
86
tests/test_database.py
Normal file
86
tests/test_database.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
# coding=utf-8
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy import orm
|
||||
|
||||
# from ucast import models
|
||||
from ucast import db
|
||||
|
||||
|
||||
def test_insert_channel(testdb):
|
||||
c1 = db.models.Channel(id="UCE1PLliRk3urTjDG6kGByiA", name="Natalie Gold")
|
||||
session.add(c1)
|
||||
session.commit()
|
||||
|
||||
c2 = db.models.Channel(id="UCGiJh0NZ52wRhYKYnuZI08Q", name="ThetaDev")
|
||||
|
||||
session.add(c2)
|
||||
session.commit()
|
||||
|
||||
# stmt = sqlalchemy.select(models.Channel).where(models.Channel.name == "LinusTechTips")
|
||||
# stmt = sqlalchemy.select(models.Channel)
|
||||
# res = testdb.execute(stmt)
|
||||
|
||||
res = session.query(models.Channel).all()
|
||||
assert len(res) == 2
|
||||
assert res[0].name == "Natalie Gold"
|
||||
assert res[1].name == "ThetaDev"
|
||||
|
||||
|
||||
"""
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def testdb():
|
||||
try:
|
||||
os.remove("test.db")
|
||||
except:
|
||||
pass
|
||||
# url = "sqlite:///:memory:"
|
||||
url = "sqlite:///test.db"
|
||||
engine = sqlalchemy.create_engine(url)
|
||||
models.metadata.create_all(engine) # Create the tables.
|
||||
return engine
|
||||
|
||||
|
||||
def test_insert_channel(testdb):
|
||||
session_maker = orm.sessionmaker(bind=testdb)
|
||||
session = session_maker()
|
||||
c1 = models.Channel(id="UCE1PLliRk3urTjDG6kGByiA", name="Natalie Gold")
|
||||
session.add(c1)
|
||||
session.commit()
|
||||
|
||||
c2 = models.Channel(id="UCGiJh0NZ52wRhYKYnuZI08Q", name="ThetaDev")
|
||||
|
||||
session.add(c2)
|
||||
session.commit()
|
||||
|
||||
# stmt = sqlalchemy.select(models.Channel).where(models.Channel.name == "LinusTechTips")
|
||||
# stmt = sqlalchemy.select(models.Channel)
|
||||
# res = testdb.execute(stmt)
|
||||
|
||||
res = session.query(models.Channel).all()
|
||||
assert len(res) == 2
|
||||
assert res[0].name == "Natalie Gold"
|
||||
assert res[1].name == "ThetaDev"
|
||||
|
||||
|
||||
def test_insert_video(testdb):
|
||||
session_maker = orm.sessionmaker(bind=testdb)
|
||||
session = session_maker()
|
||||
|
||||
c1 = models.Channel(id="UC0QEucPrn0-Ddi3JBTcs5Kw", name="Saria Delaney")
|
||||
session.add(c1)
|
||||
session.commit()
|
||||
|
||||
v1 = models.Video(id="Bxhxzj8R_i0",
|
||||
channel=c1,
|
||||
title="Verschwiegen. Verraten. Verstummt. [Reupload: 10.10.2018]",
|
||||
published=datetime(2020, 7, 4, 12, 21, 30),
|
||||
description="")
|
||||
v1.slug = v1.get_slug()
|
||||
|
||||
session.add(v1)
|
||||
session.commit()
|
||||
"""
|
|
@ -3,5 +3,5 @@ __version__ = "0.0.1"
|
|||
|
||||
UCAST_BANNER = """\
|
||||
┬ ┬┌─┐┌─┐┌─┐┌┬┐
|
||||
│ ││ ├─┤└─┐ │
|
||||
│ ││ ├─┤└─┐ │
|
||||
└─┘└─┘┴ ┴└─┘ ┴ """
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import os
|
||||
import sys
|
||||
from alembic import config as alembic_cmd
|
||||
from pathlib import Path
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
import dotenv
|
||||
import uvicorn
|
||||
from alembic import config as alembic_cmd
|
||||
|
||||
import ucast
|
||||
|
||||
|
@ -24,29 +24,36 @@ def print_banner():
|
|||
|
||||
def print_help():
|
||||
print_banner()
|
||||
print("""
|
||||
print(
|
||||
"""
|
||||
Available commands:
|
||||
run: start the server
|
||||
migrate: apply database migrations
|
||||
alembic: run the alembic migrator
|
||||
|
||||
Configuration is read from the .env file or environment variables.
|
||||
Refer to the project page for more information: https://code.thetadev.de/HSA/Ucast""")
|
||||
Refer to the project page for more information: https://code.thetadev.de/HSA/Ucast"""
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
print_banner()
|
||||
load_dotenv()
|
||||
from ucast import config
|
||||
uvicorn.run('ucast.app:create_app',
|
||||
host="0.0.0.0", port=config.HTTP_PORT, factory=True, reload=config.DEBUG)
|
||||
|
||||
uvicorn.run(
|
||||
"ucast.app:create_app",
|
||||
host="0.0.0.0",
|
||||
port=config.HTTP_PORT,
|
||||
factory=True,
|
||||
reload=config.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def alembic(args):
|
||||
load_dotenv()
|
||||
alembic_ini_path = resources.path("ucast", "alembic.ini")
|
||||
os.environ["ALEMBIC_CONFIG"] = str(alembic_ini_path)
|
||||
os.chdir(alembic_ini_path.parent)
|
||||
|
||||
alembic_cmd.main(args, f"{sys.argv[0]} alembic")
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
script_location = ucast:migrations
|
||||
|
||||
# template used to generate migration files
|
||||
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(rev)s_%%(slug)s
|
||||
|
|
13
ucast/app.py
13
ucast/app.py
|
@ -2,14 +2,17 @@
|
|||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
from ucast import views, config
|
||||
from ucast import config, views
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Starlette(config.DEBUG, routes=[
|
||||
Route("/", views.homepage),
|
||||
Route("/err", views.error),
|
||||
])
|
||||
app = Starlette(
|
||||
config.DEBUG,
|
||||
routes=[
|
||||
Route("/", views.homepage),
|
||||
Route("/err", views.error),
|
||||
],
|
||||
)
|
||||
|
||||
if app.debug:
|
||||
print("Debug mode enabled.")
|
||||
|
|
|
@ -6,7 +6,7 @@ from starlette_core.database import DatabaseURL
|
|||
config = Config()
|
||||
|
||||
# Basic configuration
|
||||
DEBUG = config('DEBUG', cast=bool, default=False)
|
||||
DATABASE_URL = config('DATABASE_URL', cast=DatabaseURL)
|
||||
SECRET_KEY = config('SECRET_KEY', cast=Secret)
|
||||
HTTP_PORT = config('HTTP_PORT', cast=int, default=8000)
|
||||
DEBUG = config("DEBUG", cast=bool, default=False)
|
||||
DATABASE_URL = config("DATABASE_URL", cast=DatabaseURL)
|
||||
SECRET_KEY = config("SECRET_KEY", cast=Secret)
|
||||
HTTP_PORT = config("HTTP_PORT", cast=int, default=8000)
|
||||
|
|
|
@ -1,41 +1,43 @@
|
|||
# coding=utf-8
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Tuple, List, Optional
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from colorthief import ColorThief
|
||||
import wcag_contrast_ratio
|
||||
from colorthief import ColorThief
|
||||
from fonts.ttf import SourceSansPro
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from ucast import typ
|
||||
|
||||
CHAR_ELLIPSIS = '…'
|
||||
CHAR_ELLIPSIS = "…"
|
||||
COVER_WIDTH = 500
|
||||
|
||||
|
||||
def _split_text(height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0) -> List[str]:
|
||||
def _split_text(
|
||||
height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0
|
||||
) -> List[str]:
|
||||
if height < font.size:
|
||||
return []
|
||||
|
||||
max_lines = math.floor((height - font.size) / (font.size + line_spacing)) + 1
|
||||
|
||||
lines = []
|
||||
line = ''
|
||||
line = ""
|
||||
|
||||
for word in text.split(' '):
|
||||
for word in text.split(" "):
|
||||
if len(lines) >= max_lines:
|
||||
line = word
|
||||
break
|
||||
|
||||
if line == '':
|
||||
if line == "":
|
||||
nline = word
|
||||
else:
|
||||
nline = line + ' ' + word
|
||||
nline = line + " " + word
|
||||
|
||||
if font.getsize(nline)[0] <= width:
|
||||
line = nline
|
||||
elif line != '':
|
||||
elif line != "":
|
||||
lines.append(line)
|
||||
line = word
|
||||
else:
|
||||
|
@ -47,14 +49,14 @@ def _split_text(height: int, width: int, text: str, font: ImageFont.FreeTypeFont
|
|||
lines.append(nline_e)
|
||||
break
|
||||
|
||||
if line != '':
|
||||
if line != "":
|
||||
if len(lines) >= max_lines:
|
||||
# Drop the last line and add ... to the end
|
||||
lastline = lines[-1] + CHAR_ELLIPSIS
|
||||
if font.getsize(lastline)[0] <= width:
|
||||
lines[-1] = lastline
|
||||
else:
|
||||
i_last_space = lines[-1].rfind(' ')
|
||||
i_last_space = lines[-1].rfind(" ")
|
||||
lines[-1] = lines[-1][:i_last_space] + CHAR_ELLIPSIS
|
||||
else:
|
||||
lines.append(line)
|
||||
|
@ -62,8 +64,15 @@ def _split_text(height: int, width: int, text: str, font: ImageFont.FreeTypeFont
|
|||
return lines
|
||||
|
||||
|
||||
def _draw_text_box(draw: ImageDraw.ImageDraw, box: Tuple[int, int, int, int], text: str, font: ImageFont.FreeTypeFont,
|
||||
color: typ.Color = (0, 0, 0), line_spacing=0, vertical_center=True):
|
||||
def _draw_text_box(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
box: Tuple[int, int, int, int],
|
||||
text: str,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
color: typ.Color = (0, 0, 0),
|
||||
line_spacing=0,
|
||||
vertical_center=True,
|
||||
):
|
||||
x_tl, y_tl, x_br, y_br = box
|
||||
height = y_br - y_tl
|
||||
width = x_br - x_tl
|
||||
|
@ -101,7 +110,9 @@ def _get_text_color(bg_color) -> typ.Color:
|
|||
return 0, 0, 0
|
||||
|
||||
|
||||
def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], title: str, channel: str) -> Image.Image:
|
||||
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)
|
||||
|
@ -113,11 +124,13 @@ def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], t
|
|||
bottom_color = _get_dominant_color(bottom_part)
|
||||
|
||||
# Create new cover image
|
||||
cover = Image.new('RGB', (COVER_WIDTH, COVER_WIDTH))
|
||||
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)):
|
||||
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
|
||||
|
@ -134,7 +147,7 @@ def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], t
|
|||
|
||||
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
|
||||
|
||||
circle_mask = Image.new('L', (avt_size, avt_size))
|
||||
circle_mask = Image.new("L", (avt_size, avt_size))
|
||||
circle_mask_draw = ImageDraw.Draw(circle_mask)
|
||||
circle_mask_draw.ellipse((0, 0, avt_size, avt_size), 255)
|
||||
|
||||
|
@ -150,18 +163,43 @@ def _create_cover_image(thumbnail: Image.Image, avatar: Optional[Image.Image], t
|
|||
top_text_color = _get_text_color(top_color)
|
||||
bottom_text_color = _get_text_color(bottom_color)
|
||||
|
||||
_draw_text_box(cover_draw, (text_margin_topleft, text_vertical_offset, COVER_WIDTH - text_margin_x, tn_margin),
|
||||
channel,
|
||||
fnt, top_text_color, text_line_space)
|
||||
_draw_text_box(cover_draw,
|
||||
(text_margin_x, COVER_WIDTH - tn_margin + text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x, COVER_WIDTH), title, fnt, bottom_text_color, text_line_space)
|
||||
_draw_text_box(
|
||||
cover_draw,
|
||||
(
|
||||
text_margin_topleft,
|
||||
text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x,
|
||||
tn_margin,
|
||||
),
|
||||
channel,
|
||||
fnt,
|
||||
top_text_color,
|
||||
text_line_space,
|
||||
)
|
||||
_draw_text_box(
|
||||
cover_draw,
|
||||
(
|
||||
text_margin_x,
|
||||
COVER_WIDTH - tn_margin + text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x,
|
||||
COVER_WIDTH,
|
||||
),
|
||||
title,
|
||||
fnt,
|
||||
bottom_text_color,
|
||||
text_line_space,
|
||||
)
|
||||
|
||||
return cover
|
||||
|
||||
|
||||
def create_cover_file(thumbnail_path: Path, avatar_path: Optional[Path], title: str, channel: str,
|
||||
cover_path: Path):
|
||||
def create_cover_file(
|
||||
thumbnail_path: Path,
|
||||
avatar_path: Optional[Path],
|
||||
title: str,
|
||||
channel: str,
|
||||
cover_path: Path,
|
||||
):
|
||||
thumbnail = Image.open(thumbnail_path)
|
||||
|
||||
avatar = None
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
# coding=utf-8
|
||||
from starlette_core.database import Database, metadata
|
||||
from starlette_core.database import Database, metadata # noqa: F401
|
||||
|
||||
from ucast import models # noqa: F401
|
||||
from ucast.config import DATABASE_URL
|
||||
from ucast import models
|
||||
|
||||
# set db config options
|
||||
if DATABASE_URL.driver == "psycopg2":
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from ucast import db
|
||||
|
||||
|
@ -10,7 +9,7 @@ from ucast import db
|
|||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
config.set_main_option('sqlalchemy.url', str(db.DATABASE_URL))
|
||||
config.set_main_option("sqlalchemy.url", str(db.DATABASE_URL))
|
||||
target_metadata = db.metadata
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
|
@ -63,9 +62,7 @@ def run_migrations_online():
|
|||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
"""Initial revision
|
||||
|
||||
Revision ID: 0ae786127cd8
|
||||
Revises:
|
||||
Revises:
|
||||
Create Date: 2022-05-03 10:03:42.224721
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0ae786127cd8'
|
||||
revision = "0ae786127cd8"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
@ -18,30 +17,36 @@ depends_on = None
|
|||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('channels',
|
||||
sa.Column('id', sa.String(length=30), nullable=False),
|
||||
sa.Column('name', sa.Unicode(length=100), nullable=False),
|
||||
sa.Column('active', sa.Boolean(), nullable=False),
|
||||
sa.Column('skip_livestreams', sa.Boolean(), nullable=False),
|
||||
sa.Column('skip_shorts', sa.Boolean(), nullable=False),
|
||||
sa.Column('keep_videos', sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"channels",
|
||||
sa.Column("id", sa.String(length=30), nullable=False),
|
||||
sa.Column("name", sa.Unicode(length=100), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("skip_livestreams", sa.Boolean(), nullable=False),
|
||||
sa.Column("skip_shorts", sa.Boolean(), nullable=False),
|
||||
sa.Column("keep_videos", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table('videos',
|
||||
sa.Column('id', sa.String(length=30), nullable=False),
|
||||
sa.Column('channel_id', sa.String(length=30), nullable=False),
|
||||
sa.Column('title', sa.Unicode(length=200), nullable=False),
|
||||
sa.Column('slug', sa.String(length=209), nullable=False),
|
||||
sa.Column('published', sa.DateTime(), nullable=False),
|
||||
sa.Column('description', sa.UnicodeText(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['channel_id'], ['channels.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"videos",
|
||||
sa.Column("id", sa.String(length=30), nullable=False),
|
||||
sa.Column("channel_id", sa.String(length=30), nullable=False),
|
||||
sa.Column("title", sa.Unicode(length=200), nullable=False),
|
||||
sa.Column("slug", sa.String(length=209), nullable=False),
|
||||
sa.Column("published", sa.DateTime(), nullable=False),
|
||||
sa.Column("downloaded", sa.DateTime(), nullable=True),
|
||||
sa.Column("description", sa.UnicodeText(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["channel_id"],
|
||||
["channels.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('videos')
|
||||
op.drop_table('channels')
|
||||
op.drop_table("videos")
|
||||
op.drop_table("channels")
|
||||
# ### end Alembic commands ###
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
# coding=utf-8
|
||||
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
# coding=utf-8
|
||||
import slugify
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
from starlette_core.database import Base
|
||||
import slugify
|
||||
|
||||
|
||||
# metadata = sa.MetaData()
|
||||
# Base = declarative_base(metadata=metadata)
|
||||
|
@ -30,6 +29,7 @@ class Video(Base):
|
|||
title = sa.Column(sa.Unicode(200), nullable=False)
|
||||
slug = sa.Column(sa.String(209), nullable=False)
|
||||
published = sa.Column(sa.DateTime, nullable=False)
|
||||
downloaded = sa.Column(sa.DateTime, nullable=True)
|
||||
description = sa.Column(sa.UnicodeText(), nullable=False, default="")
|
||||
|
||||
def get_slug(self) -> str:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
UCAST_DIRNAME = '.ucast'
|
||||
UCAST_DIRNAME = ".ucast"
|
||||
|
||||
|
||||
class ChannelFolder:
|
||||
|
@ -10,11 +10,11 @@ class ChannelFolder:
|
|||
self.dir_root = dir_root
|
||||
dir_ucast = self.dir_root / UCAST_DIRNAME
|
||||
|
||||
self.file_videos = dir_ucast / 'videos.json'
|
||||
self.file_options = dir_ucast / 'options.json'
|
||||
self.file_avatar = dir_ucast / 'avatar.png'
|
||||
self.file_feed = dir_ucast / 'feed.xml'
|
||||
self.dir_covers = dir_ucast / 'covers'
|
||||
self.file_videos = dir_ucast / "videos.json"
|
||||
self.file_options = dir_ucast / "options.json"
|
||||
self.file_avatar = dir_ucast / "avatar.png"
|
||||
self.file_feed = dir_ucast / "feed.xml"
|
||||
self.dir_covers = dir_ucast / "covers"
|
||||
|
||||
def does_exist(self) -> bool:
|
||||
return os.path.isdir(self.dir_covers)
|
||||
|
@ -31,14 +31,14 @@ class Storage:
|
|||
def get_channel_folder(self, channel_name: str):
|
||||
cf = ChannelFolder(self.dir_data / channel_name)
|
||||
if not cf.does_exist():
|
||||
raise FileNotFoundError('channel folder does not exist')
|
||||
raise FileNotFoundError("channel folder does not exist")
|
||||
|
||||
return cf
|
||||
|
||||
def create_channel_folder(self, channel_name: str):
|
||||
cf = ChannelFolder(self.dir_data / channel_name)
|
||||
if cf.does_exist():
|
||||
raise FileExistsError('channel folder already exists')
|
||||
raise FileExistsError("channel folder already exists")
|
||||
|
||||
cf.create()
|
||||
return cf
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
# coding=utf-8
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def download_file(url: str, download_path: Path):
|
||||
r = requests.get(url, allow_redirects=True)
|
||||
open(download_path, 'wb').write(r.content)
|
||||
open(download_path, "wb").write(r.content)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from ucast import db
|
||||
# from ucast import db
|
||||
|
||||
|
||||
async def homepage(request: Request) -> Response:
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
# coding=utf-8
|
||||
from operator import itemgetter
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
|
||||
from yt_dlp import YoutubeDL
|
||||
from scrapetube import scrapetube
|
||||
import requests
|
||||
from scrapetube import scrapetube
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
|
||||
def get_thumbnail_url(vinfo):
|
||||
"""Get the best quality thumbnail"""
|
||||
return max(vinfo['thumbnails'], key=itemgetter('preference'))['url']
|
||||
return max(vinfo["thumbnails"], key=itemgetter("preference"))["url"]
|
||||
|
||||
|
||||
def get_video_info(video_id):
|
||||
|
@ -20,29 +20,25 @@ def get_video_info(video_id):
|
|||
|
||||
def download_video(video_id, download_path, sponsorblock=False):
|
||||
ydl_params = {
|
||||
'format': 'bestaudio',
|
||||
'postprocessors': [
|
||||
{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3'
|
||||
},
|
||||
"format": "bestaudio",
|
||||
"postprocessors": [
|
||||
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3"},
|
||||
],
|
||||
'outtmpl': download_path,
|
||||
"outtmpl": download_path,
|
||||
}
|
||||
|
||||
if sponsorblock:
|
||||
# noinspection PyTypeChecker
|
||||
ydl_params['postprocessors'].extend([
|
||||
{
|
||||
'key': 'SponsorBlock',
|
||||
'categories': ['sponsor'],
|
||||
'when': 'after_filter'
|
||||
},
|
||||
{
|
||||
'key': 'ModifyChapters',
|
||||
'remove_sponsor_segments': ['sponsor']
|
||||
}
|
||||
])
|
||||
ydl_params["postprocessors"].extend(
|
||||
[
|
||||
{
|
||||
"key": "SponsorBlock",
|
||||
"categories": ["sponsor"],
|
||||
"when": "after_filter",
|
||||
},
|
||||
{"key": "ModifyChapters", "remove_sponsor_segments": ["sponsor"]},
|
||||
]
|
||||
)
|
||||
|
||||
with YoutubeDL(ydl_params) as ydl:
|
||||
# extract_info downloads the video and returns its metadata
|
||||
|
@ -61,7 +57,8 @@ def get_channel_metadata(channel_url):
|
|||
session = requests.Session()
|
||||
session.headers[
|
||||
"User-Agent"
|
||||
] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36"
|
||||
] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
|
||||
(KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36"
|
||||
|
||||
url = f"{channel_url}/videos?view=0&flow=grid"
|
||||
|
||||
|
@ -69,11 +66,11 @@ def get_channel_metadata(channel_url):
|
|||
data = json.loads(
|
||||
scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}"
|
||||
)
|
||||
metadata = data['metadata']['channelMetadataRenderer']
|
||||
metadata = data["metadata"]["channelMetadataRenderer"]
|
||||
|
||||
channel_id = metadata['externalId']
|
||||
name = metadata['title']
|
||||
description = metadata['description']
|
||||
avatar = metadata['avatar']['thumbnails'][0]['url']
|
||||
channel_id = metadata["externalId"]
|
||||
name = metadata["title"]
|
||||
description = metadata["description"]
|
||||
avatar = metadata["avatar"]["thumbnails"][0]["url"]
|
||||
|
||||
return ChannelMetadata(channel_id, name, description, avatar)
|
||||
|
|
Loading…
Reference in a new issue