diff --git a/.gitignore b/.gitignore index 1ddb610..d82c827 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ # Python venv -dist .tox __pycache__ *.egg-info @@ -18,5 +17,3 @@ __pycache__ # Application data /_run -.env -*.sqlite3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 1b99b3e..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# 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 diff --git a/manage.py b/manage.py deleted file mode 120000 index 9986394..0000000 --- a/manage.py +++ /dev/null @@ -1 +0,0 @@ -ucast/manage.py \ No newline at end of file diff --git a/notes/Speicher.md b/notes/Speicher.md index 503587c..7c80cb1 100644 --- a/notes/Speicher.md +++ b/notes/Speicher.md @@ -4,50 +4,64 @@ ```txt _ config - |_ config.toml + |_ config.py _ 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 - |_ 220409_Building a _1_000_000 Computer.png - |_ 220410_Apple makes GREAT Gaming Computers.png - |_ 220409_Building a _1_000_000 Computer.mp3 - |_ 220410_Apple makes GREAT Gaming Computers.mp3 + | |_ .ucast + | | |_ videos.json # IDs und Metadaten aller heruntergeladenen Videos + | | |_ options.json # Kanalspezifische Optionen (ID, enabled) + | | |_ avatar.png # Profilbild des Kanals + | | |_ feed.xml # RSS-Feed + | | |_ covers # Cover-Bilder + | | |_ 220409_Building a _1_000_000 Computer.png + | | |_ 220410_Apple makes GREAT Gaming Computers.png + | |_ 220409_Building a _1_000_000 Computer.mp3 + | |_ 220410_Apple makes GREAT Gaming Computers.mp3 + | + |_ Andreas Spiess + |_ ... +``` +## Verzeichnisstruktur (mit Datenbank) + +```txt +_ config + |_ config.py +_ data + |_ ucast.db + | + |_ LinusTechTips + | |_ .ucast + | | |_ avatar.png # Profilbild des Kanals + | | |_ feed.xml # RSS-Feed + | | |_ covers # Cover-Bilder + | | |_ 220409_Building a _1_000_000 Computer.png + | | |_ 220410_Apple makes GREAT Gaming Computers.png + | |_ 220409_Building a _1_000_000 Computer.mp3 + | |_ 220410_Apple makes GREAT Gaming Computers.mp3 + | |_ Andreas Spiess |_ ... ``` ## Datenmodelle -### LastScan +### Channel -- LastScan: datetime - -### ChannelOptions - -- ID: str +- ID: str, VARCHAR(30), PKEY +- Name: str, VARCHAR(100) - Active: bool = True -- LastScan: datetime - SkipLivestreams: bool = True - SkipShorts: bool = True - KeepVideos: int = -1 -### Videos - -- Videos: dict[id: str -> Video] - ### Video -- ID: str -- Title: str -- Slug: str (YYMMDD_Title, used as filename) +- ID: str, VARCHAR(30), PKEY +- Title: str, VARCHAR(200) +- Slug: str (YYMMDD_Title, used as filename), VARCHAR(207) - Published: datetime -- Description: str +- Description: str, VARCHAR(1000) ### Config diff --git a/poetry.lock b/poetry.lock index 025af55..17079c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,13 +1,17 @@ [[package]] -name = "asgiref" -version = "3.5.1" -description = "ASGI specs, helper code, and adapters" +name = "alembic" +version = "1.7.7" +description = "A database migration tool for SQLAlchemy." category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" [package.extras] -tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] +tz = ["python-dateutil"] [[package]] name = "atomicwrites" @@ -80,11 +84,22 @@ python-versions = ">=3.5.0" [package.extras] unicode_backport = ["unicodedata2"] +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.4" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -113,23 +128,6 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""} [package.extras] toml = ["tomli"] -[[package]] -name = "django" -version = "4.0.4" -description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -category = "main" -optional = false -python-versions = ">=3.8" - -[package.dependencies] -asgiref = ">=3.4.1,<4" -sqlparse = ">=0.2.2" -tzdata = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -argon2 = ["argon2-cffi (>=19.1.0)"] -bcrypt = ["bcrypt"] - [[package]] name = "feedparser" version = "6.0.8" @@ -141,6 +139,50 @@ python-versions = ">=3.6" [package.dependencies] sgmllib3k = "*" +[[package]] +name = "flask" +version = "2.1.2" +description = "A simple framework for building complex web applications." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0" +itsdangerous = ">=2.0" +Jinja2 = ">=3.0" +Werkzeug = ">=2.0" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-alembic" +version = "2.0.1" +description = "Flask extension to integrate Alembic migrations" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +alembic = ">=0.8" +Flask = ">=0.10" +Flask-SQLAlchemy = "*" +SQLAlchemy = "*" + +[[package]] +name = "flask-sqlalchemy" +version = "2.5.1" +description = "Adds SQLAlchemy support to your Flask application." +category = "main" +optional = false +python-versions = ">= 2.7, != 3.0.*, != 3.1.*, != 3.2.*, != 3.3.*" + +[package.dependencies] +Flask = ">=0.10" +SQLAlchemy = ">=0.8.0" + [[package]] name = "font-source-sans-pro" version = "0.0.1" @@ -157,6 +199,17 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "greenlet" +version = "1.1.2" +description = "Lightweight in-process concurrent programming" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.extras] +docs = ["sphinx"] + [[package]] name = "idna" version = "3.3" @@ -181,6 +234,52 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "mako" +version = "1.2.0" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "mutagen" version = "1.45.1" @@ -295,21 +394,6 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] -[[package]] -name = "pytest-django" -version = "4.5.2" -description = "A Django plugin for pytest." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -pytest = ">=5.4.0" - -[package.extras] -docs = ["sphinx", "sphinx-rtd-theme"] -testing = ["django", "django-configurations (>=2.0)"] - [[package]] name = "requests" version = "2.27.1" @@ -357,12 +441,36 @@ optional = false python-versions = "*" [[package]] -name = "sqlparse" -version = "0.4.2" -description = "A non-validating SQL parser." +name = "sqlalchemy" +version = "1.4.36" +description = "Database Abstraction Library" category = "main" optional = false -python-versions = ">=3.5" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} + +[package.extras] +aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] +aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] +mariadb_connector = ["mariadb (>=1.0.1)"] +mssql = ["pyodbc"] +mssql_pymssql = ["pymssql"] +mssql_pyodbc = ["pyodbc"] +mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"] +mysql_connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"] +postgresql_pg8000 = ["pg8000 (>=1.16.6)"] +postgresql_psycopg2binary = ["psycopg2-binary"] +postgresql_psycopg2cffi = ["psycopg2cffi"] +pymysql = ["pymysql (<1)", "pymysql"] +sqlcipher = ["sqlcipher3-binary"] [[package]] name = "tomli" @@ -380,14 +488,6 @@ category = "main" optional = false python-versions = ">=3.7" -[[package]] -name = "tzdata" -version = "2022.1" -description = "Provider of IANA time zone data" -category = "main" -optional = false -python-versions = ">=2" - [[package]] name = "urllib3" version = "1.26.9" @@ -401,6 +501,14 @@ 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 = "uwsgi" +version = "2.0.20" +description = "The uWSGI server" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "wcag-contrast-ratio" version = "0.9" @@ -417,6 +525,17 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "werkzeug" +version = "2.1.2" +description = "The comprehensive WSGI web application library." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +watchdog = ["watchdog"] + [[package]] name = "yt-dlp" version = "2022.4.8" @@ -436,12 +555,12 @@ websockets = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "0288eb9eca14b78ee0cfac28cef0dcf17701165b6aa778255bfa73b94d5b2c4f" +content-hash = "a87f6de89028dd491dd5c09ef629084997f799bd9f80d773d80ff2102f1dc0ea" [metadata.files] -asgiref = [ - {file = "asgiref-3.5.1-py3-none-any.whl", hash = "sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1"}, - {file = "asgiref-3.5.1.tar.gz", hash = "sha256:fddeea3c53fa99d0cdb613c3941cc6e52d822491fc2753fba25768fb5bf4e865"}, +alembic = [ + {file = "alembic-1.7.7-py3-none-any.whl", hash = "sha256:29be0856ec7591c39f4e1cb10f198045d890e6e2274cf8da80cb5e721a09642b"}, + {file = "alembic-1.7.7.tar.gz", hash = "sha256:4961248173ead7ce8a21efb3de378f13b8398e6630fab0eb258dc74a8af24c58"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, @@ -607,6 +726,10 @@ charset-normalizer = [ {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -658,14 +781,22 @@ 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"}, ] -django = [ - {file = "Django-4.0.4-py3-none-any.whl", hash = "sha256:07c8638e7a7f548dc0acaaa7825d84b7bd42b10e8d22268b3d572946f1e9b687"}, - {file = "Django-4.0.4.tar.gz", hash = "sha256:4e8177858524417563cc0430f29ea249946d831eacb0068a1455686587df40b5"}, -] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"}, ] +flask = [ + {file = "Flask-2.1.2-py3-none-any.whl", hash = "sha256:fad5b446feb0d6db6aec0c3184d16a8c1f6c3e464b511649c8918a9be100b4fe"}, + {file = "Flask-2.1.2.tar.gz", hash = "sha256:315ded2ddf8a6281567edb27393010fe3406188bafbfe65a3339d5787d89e477"}, +] +flask-alembic = [ + {file = "Flask-Alembic-2.0.1.tar.gz", hash = "sha256:05a1e6f4148dbfcc9280a393373bfbd250af6f9f4f0ca9f744ef8f7376a3deec"}, + {file = "Flask_Alembic-2.0.1-py2.py3-none-any.whl", hash = "sha256:7e67740b0b08d58dcae0c701d56b56e60f5fa4af907bb82b4cb0469229ba94ff"}, +] +flask-sqlalchemy = [ + {file = "Flask-SQLAlchemy-2.5.1.tar.gz", hash = "sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912"}, + {file = "Flask_SQLAlchemy-2.5.1-py2.py3-none-any.whl", hash = "sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390"}, +] 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 +807,63 @@ fonts = [ {file = "fonts-0.0.3-py3-none-any.whl", hash = "sha256:e5f551379088ab260c2537980c3ccdff8af93408d9d4fa3319388d2ee25b7b6d"}, {file = "fonts-0.0.3.tar.gz", hash = "sha256:c626655b75a60715e118e44e270656fd22fd8f54252901ff6ebf1308ad01c405"}, ] +greenlet = [ + {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"}, + {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"}, + {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"}, + {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"}, + {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"}, + {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"}, + {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"}, + {file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"}, + {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"}, + {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"}, + {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"}, + {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"}, + {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"}, + {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"}, + {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"}, + {file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"}, + {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"}, + {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"}, + {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"}, + {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"}, + {file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"}, + {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"}, + {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"}, + {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"}, + {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"}, + {file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"}, + {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"}, + {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"}, + {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"}, + {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"}, + {file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"}, + {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"}, + {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"}, + {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"}, +] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, @@ -688,6 +876,60 @@ invoke = [ {file = "invoke-1.7.0-py3-none-any.whl", hash = "sha256:a5159fc63dba6ca2a87a1e33d282b99cea69711b03c64a35bb4e1c53c6c4afa0"}, {file = "invoke-1.7.0.tar.gz", hash = "sha256:e332e49de40463f2016315f51df42313855772be86435686156bc18f45b5cc6c"}, ] +itsdangerous = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] +jinja2 = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] +mako = [ + {file = "Mako-1.2.0-py3-none-any.whl", hash = "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba"}, + {file = "Mako-1.2.0.tar.gz", hash = "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"}, +] +markupsafe = [ + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, +] mutagen = [ {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, {file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"}, @@ -789,10 +1031,6 @@ pytest-cov = [ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] -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"}, -] 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,9 +1044,43 @@ scrapetube = [ sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, ] -sqlparse = [ - {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, - {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, +sqlalchemy = [ + {file = "SQLAlchemy-1.4.36-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:81e53bd383c2c33de9d578bfcc243f559bd3801a0e57f2bcc9a943c790662e0c"}, + {file = "SQLAlchemy-1.4.36-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6e1fe00ee85c768807f2a139b83469c1e52a9ffd58a6eb51aa7aeb524325ab18"}, + {file = "SQLAlchemy-1.4.36-cp27-cp27m-win32.whl", hash = "sha256:d57ac32f8dc731fddeb6f5d1358b4ca5456e72594e664769f0a9163f13df2a31"}, + {file = "SQLAlchemy-1.4.36-cp27-cp27m-win_amd64.whl", hash = "sha256:fca8322e04b2dde722fcb0558682740eebd3bd239bea7a0d0febbc190e99dc15"}, + {file = "SQLAlchemy-1.4.36-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:53d2d9ee93970c969bc4e3c78b1277d7129554642f6ffea039c282c7dc4577bc"}, + {file = "SQLAlchemy-1.4.36-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:f0394a3acfb8925db178f7728adb38c027ed7e303665b225906bfa8099dc1ce8"}, + {file = "SQLAlchemy-1.4.36-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c606d8238feae2f360b8742ffbe67741937eb0a05b57f536948d198a3def96"}, + {file = "SQLAlchemy-1.4.36-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8d07fe2de0325d06e7e73281e9a9b5e259fbd7cbfbe398a0433cbb0082ad8fa7"}, + {file = "SQLAlchemy-1.4.36-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5041474dcab7973baa91ec1f3112049a9dd4652898d6a95a6a895ff5c58beb6b"}, + {file = "SQLAlchemy-1.4.36-cp310-cp310-win32.whl", hash = "sha256:be094460930087e50fd08297db9d7aadaed8408ad896baf758e9190c335632da"}, + {file = "SQLAlchemy-1.4.36-cp310-cp310-win_amd64.whl", hash = "sha256:64d796e9af522162f7f2bf7a3c5531a0a550764c426782797bbeed809d0646c5"}, + {file = "SQLAlchemy-1.4.36-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:a0ae3aa2e86a4613f2d4c49eb7da23da536e6ce80b2bfd60bbb2f55fc02b0b32"}, + {file = "SQLAlchemy-1.4.36-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d50cb71c1dbed70646d521a0975fb0f92b7c3f84c61fa59e07be23a1aaeecfc"}, + {file = "SQLAlchemy-1.4.36-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:16abf35af37a3d5af92725fc9ec507dd9e9183d261c2069b6606d60981ed1c6e"}, + {file = "SQLAlchemy-1.4.36-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5864a83bd345871ad9699ce466388f836db7572003d67d9392a71998092210e3"}, + {file = "SQLAlchemy-1.4.36-cp36-cp36m-win32.whl", hash = "sha256:fbf8c09fe9728168f8cc1b40c239eab10baf9c422c18be7f53213d70434dea43"}, + {file = "SQLAlchemy-1.4.36-cp36-cp36m-win_amd64.whl", hash = "sha256:6e859fa96605027bd50d8e966db1c4e1b03e7b3267abbc4b89ae658c99393c58"}, + {file = "SQLAlchemy-1.4.36-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:166a3887ec355f7d2f12738f7fa25dc8ac541867147a255f790f2f41f614cb44"}, + {file = "SQLAlchemy-1.4.36-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e885548da361aa3f8a9433db4cfb335b2107e533bf314359ae3952821d84b3e"}, + {file = "SQLAlchemy-1.4.36-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5c90ef955d429966d84326d772eb34333178737ebb669845f1d529eb00c75e72"}, + {file = "SQLAlchemy-1.4.36-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a052bd9f53004f8993c624c452dfad8ec600f572dd0ed0445fbe64b22f5570e"}, + {file = "SQLAlchemy-1.4.36-cp37-cp37m-win32.whl", hash = "sha256:dce3468bf1fc12374a1a732c9efd146ce034f91bb0482b602a9311cb6166a920"}, + {file = "SQLAlchemy-1.4.36-cp37-cp37m-win_amd64.whl", hash = "sha256:6cb4c4f57a20710cea277edf720d249d514e587f796b75785ad2c25e1c0fed26"}, + {file = "SQLAlchemy-1.4.36-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e74ce103b81c375c3853b436297952ef8d7863d801dcffb6728d01544e5191b5"}, + {file = "SQLAlchemy-1.4.36-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b20c4178ead9bc398be479428568ff31b6c296eb22e75776273781a6551973f"}, + {file = "SQLAlchemy-1.4.36-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:af2587ae11400157753115612d6c6ad255143efba791406ad8a0cbcccf2edcb3"}, + {file = "SQLAlchemy-1.4.36-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83cf3077712be9f65c9aaa0b5bc47bc1a44789fd45053e2e3ecd59ff17c63fe9"}, + {file = "SQLAlchemy-1.4.36-cp38-cp38-win32.whl", hash = "sha256:ce20f5da141f8af26c123ebaa1b7771835ca6c161225ce728962a79054f528c3"}, + {file = "SQLAlchemy-1.4.36-cp38-cp38-win_amd64.whl", hash = "sha256:316c7e5304dda3e3ad711569ac5d02698bbc71299b168ac56a7076b86259f7ea"}, + {file = "SQLAlchemy-1.4.36-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:f522214f6749bc073262529c056f7dfd660f3b5ec4180c5354d985eb7219801e"}, + {file = "SQLAlchemy-1.4.36-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ecac4db8c1aa4a269f5829df7e706639a24b780d2ac46b3e485cbbd27ec0028"}, + {file = "SQLAlchemy-1.4.36-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b3db741beaa983d4cbf9087558620e7787106319f7e63a066990a70657dd6b35"}, + {file = "SQLAlchemy-1.4.36-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ec89bf98cc6a0f5d1e28e3ad28e9be6f3b4bdbd521a4053c7ae8d5e1289a8a1"}, + {file = "SQLAlchemy-1.4.36-cp39-cp39-win32.whl", hash = "sha256:e12532c4d3f614678623da5d852f038ace1f01869b89f003ed6fe8c793f0c6a3"}, + {file = "SQLAlchemy-1.4.36-cp39-cp39-win_amd64.whl", hash = "sha256:cb441ca461bf97d00877b607f132772644b623518b39ced54da433215adce691"}, + {file = "SQLAlchemy-1.4.36.tar.gz", hash = "sha256:64678ac321d64a45901ef2e24725ec5e783f1f4a588305e196431447e7ace243"}, ] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, @@ -818,14 +1090,13 @@ typing-extensions = [ {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, ] -tzdata = [ - {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, - {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, -] urllib3 = [ {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] +uwsgi = [ + {file = "uwsgi-2.0.20.tar.gz", hash = "sha256:88ab9867d8973d8ae84719cf233b7dafc54326fcaec89683c3f9f77c002cdff9"}, +] wcag-contrast-ratio = [ {file = "wcag-contrast-ratio-0.9.tar.gz", hash = "sha256:69192b8e5c0a7d0dc5ff1187eeb3e398141633a4bde51c69c87f58fe87ed361c"}, ] @@ -879,6 +1150,10 @@ websockets = [ {file = "websockets-10.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3eda1cb7e9da1b22588cefff09f0951771d6ee9fa8dbe66f5ae04cc5f26b2b55"}, {file = "websockets-10.3.tar.gz", hash = "sha256:fc06cc8073c8e87072138ba1e431300e2d408f054b27047d047b549455066ff4"}, ] +werkzeug = [ + {file = "Werkzeug-2.1.2-py3-none-any.whl", hash = "sha256:72a4b735692dd3135217911cbeaa1be5fa3f62bffb8745c5215420a03dc55255"}, + {file = "Werkzeug-2.1.2.tar.gz", hash = "sha256:1ce08e8093ed67d638d63879fd1ba3735817f7a80de3674d293f5984f25fb6e6"}, +] yt-dlp = [ {file = "yt-dlp-2022.4.8.tar.gz", hash = "sha256:8758d016509d4574b90fbde975aa70adaef71ed5e7a195141588f6d6945205ba"}, {file = "yt_dlp-2022.4.8-py2.py3-none-any.whl", hash = "sha256:6edefe326b1e1478fdbe627a66203e5248a6b0dd50c101e682cf700ab70cdf72"}, diff --git a/pyproject.toml b/pyproject.toml index ba41970..c0e0b5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,12 @@ [tool.poetry] name = "ucast" -version = "0.0.1" +version = "0.1.0" description = "YouTube to Podcast converter" authors = ["Theta-Dev "] -packages = [ - { include = "ucast" }, - { include = "yt2podcast" }, -] [tool.poetry.dependencies] python = "^3.10" -Django = "^4.0.4" +Flask = "^2.1.1" yt-dlp = "^2022.3.8" scrapetube = "^2.2.2" rfeed = "^1.1.1" @@ -20,26 +16,15 @@ colorthief = "^0.2.1" wcag-contrast-ratio = "^0.9" font-source-sans-pro = "^0.0.1" fonts = "^0.0.3" +Flask-SQLAlchemy = "^2.5.1" +Flask-Alembic = "^2.0.1" +uWSGI = "^2.0.20" [tool.poetry.dev-dependencies] -pytest = "^7.1.1" +pytest = "^7.1.2" pytest-cov = "^3.0.0" invoke = "^1.7.0" -pytest-django = "^4.5.2" - -[tool.poetry.scripts] -"ucast-manage" = "ucast.manage:main" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" - -[tool.flake8] -max-line-length = 88 -per-file-ignores = [ - "settings.py:E501", -] - -[tool.black] -line-length = 88 -target-version = ['py310'] diff --git a/tasks.py b/tasks.py index d803b2d..cafe839 100644 --- a/tasks.py +++ b/tasks.py @@ -1,40 +1,32 @@ import os -from pathlib import Path from invoke import task -from yt2podcast import tests -from yt2podcast.service import cover, util, youtube - -os.chdir(Path(__file__).absolute().parent) +from ucast import youtube, util, cover +import tests @task def test(c): - c.run("pytest tests", pty=True) + c.run('pytest tests', pty=True) @task -def makemigrations(c): - c.run("python manage.py makemigrations yt2podcast") - - -@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) diff --git a/yt2podcast/tests/__init__.py b/tests/__init__.py similarity index 50% rename from yt2podcast/tests/__init__.py rename to tests/__init__.py index b383d20..3a4d86b 100644 --- a/yt2podcast/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ # coding=utf-8 from importlib.resources import files -DIR_TESTFILES = files("yt2podcast.tests.testfiles") +DIR_TESTFILES = files('tests.testfiles') diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 0000000..745c2e6 --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,84 @@ +# coding=utf-8 +from typing import List +import tempfile +from pathlib import Path + +import pytest +from PIL import Image, ImageFont, ImageChops +from fonts.ttf import SourceSansPro + +import tests +from ucast import cover, types + + +@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)), +]) +def test_get_dominant_color(file_name: str, color: types.Color): + 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)), +]) +def test_get_text_color(bg_color: types.Color, text_color: types.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'), +]) +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_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) + + diff = ImageChops.difference(cv_image, expected_cv_image) + assert diff.getbbox() is None + + +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' + + 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) + + cv_image = Image.open(cv_file) + expected_cv_image = Image.open(expected_cv_file) + + diff = ImageChops.difference(cv_image, expected_cv_image) + assert diff.getbbox() is None diff --git a/yt2podcast/tests/testfiles/avatar/a1.jpg b/tests/testfiles/avatar/a1.jpg similarity index 100% rename from yt2podcast/tests/testfiles/avatar/a1.jpg rename to tests/testfiles/avatar/a1.jpg diff --git a/yt2podcast/tests/testfiles/avatar/a2.jpg b/tests/testfiles/avatar/a2.jpg similarity index 100% rename from yt2podcast/tests/testfiles/avatar/a2.jpg rename to tests/testfiles/avatar/a2.jpg diff --git a/yt2podcast/tests/testfiles/avatar/a3.jpg b/tests/testfiles/avatar/a3.jpg similarity index 100% rename from yt2podcast/tests/testfiles/avatar/a3.jpg rename to tests/testfiles/avatar/a3.jpg diff --git a/yt2podcast/tests/testfiles/cover/c1.png b/tests/testfiles/cover/c1.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c1.png rename to tests/testfiles/cover/c1.png diff --git a/yt2podcast/tests/testfiles/cover/c2.png b/tests/testfiles/cover/c2.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c2.png rename to tests/testfiles/cover/c2.png diff --git a/yt2podcast/tests/testfiles/cover/c3.png b/tests/testfiles/cover/c3.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c3.png rename to tests/testfiles/cover/c3.png diff --git a/yt2podcast/tests/testfiles/sources.md b/tests/testfiles/sources.md similarity index 100% rename from yt2podcast/tests/testfiles/sources.md rename to tests/testfiles/sources.md diff --git a/yt2podcast/tests/testfiles/thumbnail/t1.webp b/tests/testfiles/thumbnail/t1.webp similarity index 100% rename from yt2podcast/tests/testfiles/thumbnail/t1.webp rename to tests/testfiles/thumbnail/t1.webp diff --git a/yt2podcast/tests/testfiles/thumbnail/t2.webp b/tests/testfiles/thumbnail/t2.webp similarity index 100% rename from yt2podcast/tests/testfiles/thumbnail/t2.webp rename to tests/testfiles/thumbnail/t2.webp diff --git a/yt2podcast/tests/testfiles/thumbnail/t3.webp b/tests/testfiles/thumbnail/t3.webp similarity index 100% rename from yt2podcast/tests/testfiles/thumbnail/t3.webp rename to tests/testfiles/thumbnail/t3.webp diff --git a/ucast/app.py b/ucast/app.py new file mode 100644 index 0000000..e324c0c --- /dev/null +++ b/ucast/app.py @@ -0,0 +1,27 @@ +# coding=utf-8 +from flask import Flask + +from ucast.views import view + + +def create_app(test_config=None): + # create and configure the app + app = Flask(__name__, instance_relative_config=True) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile('config.py', silent=True) + else: + # load the test config if passed in + app.config.from_mapping(test_config) + + print(app.instance_path) + + app.register_blueprint(view) + + return app + + +if __name__ == '__main__': + application = create_app() + application.run() diff --git a/ucast/asgi.py b/ucast/asgi.py deleted file mode 100644 index be266bd..0000000 --- a/ucast/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for ucast project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") - -application = get_asgi_application() diff --git a/yt2podcast/service/cover.py b/ucast/cover.py similarity index 69% rename from yt2podcast/service/cover.py rename to ucast/cover.py index 38db4da..7dd523b 100644 --- a/yt2podcast/service/cover.py +++ b/ucast/cover.py @@ -1,43 +1,41 @@ # coding=utf-8 import math from pathlib import Path -from typing import List, Optional, Tuple +from typing import Tuple, List, Optional -import wcag_contrast_ratio -from colorthief import ColorThief -from fonts.ttf import SourceSansPro from PIL import Image, ImageDraw, ImageFont +from colorthief import ColorThief +import wcag_contrast_ratio +from fonts.ttf import SourceSansPro -from yt2podcast.service import typ +from ucast import types -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: @@ -49,14 +47,14 @@ def _split_text( 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) @@ -64,15 +62,8 @@ def _split_text( 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: types.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 @@ -95,13 +86,13 @@ def _get_dominant_color(img: Image.Image): return thief.get_color() -def _interpolate_color(color_from: typ.Color, color_to: typ.Color, interval: int): +def _interpolate_color(color_from: types.Color, color_to: types.Color, interval: int): det_co = [(t - f) / interval for f, t in zip(color_from, color_to)] for i in range(interval): yield [round(f + det * i) for f, det in zip(color_from, det_co)] -def _get_text_color(bg_color) -> typ.Color: +def _get_text_color(bg_color) -> types.Color: color_decimal = tuple([c / 255 for c in bg_color]) c_blk = wcag_contrast_ratio.rgb((0, 0, 0), color_decimal) c_wht = wcag_contrast_ratio.rgb((1, 1, 1), color_decimal) @@ -110,9 +101,7 @@ 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) @@ -124,13 +113,11 @@ def _create_cover_image( 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 @@ -147,7 +134,7 @@ def _create_cover_image( 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) @@ -163,43 +150,18 @@ def _create_cover_image( 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 diff --git a/ucast/manage.py b/ucast/manage.py deleted file mode 100755 index 4c6dfe3..0000000 --- a/ucast/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/yt2podcast/service/__init__.py b/ucast/model.py similarity index 88% rename from yt2podcast/service/__init__.py rename to ucast/model.py index 9bad579..87062c3 100644 --- a/yt2podcast/service/__init__.py +++ b/ucast/model.py @@ -1 +1,3 @@ # coding=utf-8 + + diff --git a/ucast/settings.py b/ucast/settings.py deleted file mode 100644 index ffcdcd0..0000000 --- a/ucast/settings.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Django settings for ucast project. - -Generated by 'django-admin startproject' using Django 4.0.4. - -For more information on this file, see -https://docs.djangoproject.com/en/4.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.0/ref/settings/ -""" - -from pathlib import Path - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent / "_run" - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-$b25pq-s+(_zx2!2$+i+^0$kft0&y3kwmj7j5a#d_jop)$d061" - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - -# Application definition - -INSTALLED_APPS = [ - "yt2podcast.apps.Yt2PodcastConfig", - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", -] - -ROOT_URLCONF = "ucast.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "ucast.wsgi.application" - -# Database -# https://docs.djangoproject.com/en/4.0/ref/settings/#databases - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", - } -} - -# Password validation -# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - -# Internationalization -# https://docs.djangoproject.com/en/4.0/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.0/howto/static-files/ - -STATIC_URL = "static/" -STATIC_ROOT = BASE_DIR / "static" - -DATA_ROOT = BASE_DIR / "data" - -# Default primary key field type -# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/ucast/storage.py b/ucast/storage.py new file mode 100644 index 0000000..266aca2 --- /dev/null +++ b/ucast/storage.py @@ -0,0 +1,44 @@ +# coding=utf-8 +import os +from pathlib import Path + +UCAST_DIRNAME = '.ucast' + + +class ChannelFolder: + def __init__(self, dir_root: Path): + 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' + + def does_exist(self) -> bool: + return os.path.isdir(self.dir_covers) + + def create(self): + os.makedirs(self.dir_covers, exist_ok=True) + + +class Storage: + def __init__(self, config_dir: Path, data_dir: Path): + self.dir_config = config_dir + self.dir_data = data_dir + + 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') + + 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') + + cf.create() + return cf diff --git a/ucast/types.py b/ucast/types.py new file mode 100644 index 0000000..99876b6 --- /dev/null +++ b/ucast/types.py @@ -0,0 +1,5 @@ +# coding=utf-8 +from os import PathLike +from typing import Tuple, Union + +Color = Tuple[int, int, int] diff --git a/ucast/urls.py b/ucast/urls.py deleted file mode 100644 index 7d590bd..0000000 --- a/ucast/urls.py +++ /dev/null @@ -1,21 +0,0 @@ -"""ucast URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import path - -urlpatterns = [ - path("admin/", admin.site.urls), -] diff --git a/ucast/util.py b/ucast/util.py new file mode 100644 index 0000000..15cb146 --- /dev/null +++ b/ucast/util.py @@ -0,0 +1,9 @@ +# coding=utf-8 +import requests + +from ucast import types + + +def download_file(url: str, download_path: types.Path): + r = requests.get(url, allow_redirects=True) + open(download_path, 'wb').write(r.content) diff --git a/ucast/views.py b/ucast/views.py new file mode 100644 index 0000000..8382766 --- /dev/null +++ b/ucast/views.py @@ -0,0 +1,14 @@ +# coding=utf-8 +from flask import Blueprint + +view = Blueprint("view", __name__) + + +@view.route('/') +def hello(): + return 'Hello, World!' + + +@view.route('/err') +def errtest(): + raise Exception('This is an error') diff --git a/ucast/wsgi.py b/ucast/wsgi.py index 06375f4..47e1839 100644 --- a/ucast/wsgi.py +++ b/ucast/wsgi.py @@ -1,16 +1,4 @@ -""" -WSGI config for ucast project. +# coding=utf-8 +from ucast import app -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") - -application = get_wsgi_application() +application = app.create_app() diff --git a/yt2podcast/service/youtube.py b/ucast/youtube.py similarity index 58% rename from yt2podcast/service/youtube.py rename to ucast/youtube.py index 8e56d79..ac68a6b 100644 --- a/yt2podcast/service/youtube.py +++ b/ucast/youtube.py @@ -1,16 +1,16 @@ # coding=utf-8 +from operator import itemgetter import json from dataclasses import dataclass -from operator import itemgetter -import requests -from scrapetube import scrapetube from yt_dlp import YoutubeDL +from scrapetube import scrapetube +import requests 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,25 +20,29 @@ 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 @@ -57,8 +61,7 @@ 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" @@ -66,11 +69,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) diff --git a/yt2podcast/__init__.py b/yt2podcast/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/yt2podcast/admin.py b/yt2podcast/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/yt2podcast/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/yt2podcast/apps.py b/yt2podcast/apps.py deleted file mode 100644 index 563d9a1..0000000 --- a/yt2podcast/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class Yt2PodcastConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "yt2podcast" diff --git a/yt2podcast/migrations/0001_initial.py b/yt2podcast/migrations/0001_initial.py deleted file mode 100644 index fed1734..0000000 --- a/yt2podcast/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-05 00:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Channel", - fields=[ - ( - "id", - models.CharField(max_length=30, primary_key=True, serialize=False), - ), - ("name", models.CharField(max_length=100)), - ("active", models.BooleanField(default=True)), - ("skip_livestreams", models.BooleanField(default=True)), - ("skip_shorts", models.BooleanField(default=True)), - ("keep_videos", models.IntegerField(default=None, null=True)), - ], - ), - migrations.CreateModel( - name="Video", - fields=[ - ( - "id", - models.CharField(max_length=30, primary_key=True, serialize=False), - ), - ("title", models.CharField(max_length=200)), - ("slug", models.CharField(max_length=209)), - ("published", models.DateTimeField()), - ("downloaded", models.DateTimeField()), - ("description", models.TextField()), - ], - ), - ] diff --git a/yt2podcast/migrations/__init__.py b/yt2podcast/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/yt2podcast/models.py b/yt2podcast/models.py deleted file mode 100644 index 486abad..0000000 --- a/yt2podcast/models.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db import models - - -class Channel(models.Model): - id = models.CharField(max_length=30, primary_key=True) - name = models.CharField(max_length=100) - active = models.BooleanField(default=True) - skip_livestreams = models.BooleanField(default=True) - skip_shorts = models.BooleanField(default=True) - keep_videos = models.IntegerField(null=True, default=None) - - -class Video(models.Model): - id = models.CharField(max_length=30, primary_key=True) - title = models.CharField(max_length=200) - slug = models.CharField(max_length=209) - published = models.DateTimeField() - downloaded = models.DateTimeField() - description = models.TextField() diff --git a/yt2podcast/service/typ.py b/yt2podcast/service/typ.py deleted file mode 100644 index dbf0d5a..0000000 --- a/yt2podcast/service/typ.py +++ /dev/null @@ -1,4 +0,0 @@ -# coding=utf-8 -from typing import Tuple - -Color = Tuple[int, int, int] diff --git a/yt2podcast/service/util.py b/yt2podcast/service/util.py deleted file mode 100644 index a65bc97..0000000 --- a/yt2podcast/service/util.py +++ /dev/null @@ -1,7 +0,0 @@ -# coding=utf-8 -import requests - - -def download_file(url: str, download_path): - r = requests.get(url, allow_redirects=True) - open(download_path, "wb").write(r.content) diff --git a/yt2podcast/tests/test_cover.py b/yt2podcast/tests/test_cover.py deleted file mode 100644 index 518ac25..0000000 --- a/yt2podcast/tests/test_cover.py +++ /dev/null @@ -1,115 +0,0 @@ -# coding=utf-8 -import tempfile -from pathlib import Path -from typing import List - -import pytest -from fonts.ttf import SourceSansPro -from PIL import Image, ImageChops, ImageFont - -from yt2podcast import tests -from yt2podcast.service 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!", - ], - ), - ], -) -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)), - ], -) -def test_get_dominant_color(file_name: str, color: typ.Color): - 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)), - ], -) -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"), - ], -) -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_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) - - diff = ImageChops.difference(cv_image, expected_cv_image) - assert diff.getbbox() is None - - -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" - - 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 - ) - - cv_image = Image.open(cv_file) - expected_cv_image = Image.open(expected_cv_file) - - diff = ImageChops.difference(cv_image, expected_cv_image) - assert diff.getbbox() is None diff --git a/yt2podcast/views.py b/yt2podcast/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/yt2podcast/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here.