diff --git a/.drone.yml b/.drone.yml index 155b785..86ea57d 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,7 +4,7 @@ type: docker steps: - name: Test - image: d21d3q/python-poetry:3.10 + image: thetadev256/ucast-dev commands: - poetry install - poetry run invoke lint diff --git a/.env b/.env new file mode 100644 index 0000000..f600be1 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +UCAST_DEBUG=True +UCAST_WORKDIR=_run diff --git a/.gitignore b/.gitignore index 1ddb610..366f892 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ __pycache__ *.egg-info .pytest_cache +# JS +node_modules + # Jupyter .ipynb_checkpoints @@ -18,5 +21,7 @@ __pycache__ # Application data /_run -.env *.sqlite3 + +# Generated assets +/ucast/static/bulma/css diff --git a/README.md b/README.md index 329bbb3..22db521 100644 --- a/README.md +++ b/README.md @@ -24,5 +24,30 @@ Für ein ansehnliches Ansehen sorgt Bootstrap. ### Project aufsetzen -1. Python3 + [Poetry](https://python-poetry.org/) dependency manager installieren -2. Dependencies mit ``poetry install`` installieren +1. Python3 + Node.js + [Poetry](https://python-poetry.org/) dependency manager + + [pnpm](https://pnpm.io/) installieren +2. Python-Dependencies mit ``poetry install`` installieren +3. Node-Dependencies mit ``pnpm i`` installerien + +### Tasks (Python) + +Ausführen: `invoke ` + +`test` Unittests ausführen + +`lint` Codequalität/Formatierung überprüfen + +`format` Code mit black formatieren + +`makemigrations` Datenbankmigration erstellen + +`get-cover --vid ` YouTube-Thumbnail herunterladen +und Coverbilder zum Testen erzeugen (werden unter `ucast/tests/testfiles` abgelegt) + +### Tasks (Node.js) + +Ausführen: `npm run ` + +`start` Sass-Stylesheets automatisch bei Änderungen kompilieren + +`build` Sass-Stylesheets kompilieren und optimieren diff --git a/assets/icons/logo.svg b/assets/icons/logo.svg new file mode 100644 index 0000000..ad05aa8 --- /dev/null +++ b/assets/icons/logo.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/icons/logo_border.svg b/assets/icons/logo_border.svg new file mode 100644 index 0000000..dfcf38f --- /dev/null +++ b/assets/icons/logo_border.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/logo_dark.svg b/assets/icons/logo_dark.svg new file mode 100644 index 0000000..4e4d75e --- /dev/null +++ b/assets/icons/logo_dark.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/icons/yt_icon.svg b/assets/icons/yt_icon.svg new file mode 100644 index 0000000..2b82d1a --- /dev/null +++ b/assets/icons/yt_icon.svg @@ -0,0 +1,43 @@ + + diff --git a/assets/sass/style.sass b/assets/sass/style.sass new file mode 100644 index 0000000..a74478f --- /dev/null +++ b/assets/sass/style.sass @@ -0,0 +1,9 @@ +// 1. Import the initial variables +@import "../../node_modules/bulma/sass/utilities/initial-variables" + +// 2. Set your own initial variables + +// 3. Import the rest of Bulma +@import "../../node_modules/bulma/bulma" + +// 4. Import your stuff here \ No newline at end of file diff --git a/deploy/Devcontainer.Dockerfile b/deploy/Devcontainer.Dockerfile new file mode 100644 index 0000000..571102f --- /dev/null +++ b/deploy/Devcontainer.Dockerfile @@ -0,0 +1,32 @@ +# This has to be built with docker buildx to set the TARGETPLATFORM argument +FROM python:3.10 + +ARG TARGETPLATFORM + +# ffmpeg static source (https://johnvansickle.com/ffmpeg/) +RUN set -e; \ + mkdir /build_ffmpeg; \ + cd /build_ffmpeg; \ + case "$TARGETPLATFORM" in \ + "linux/amd64") ffmpeg_arch="amd64";; \ + "linux/arm64") ffmpeg_arch="arm64";; \ + "linux/arm/v7") ffmpeg_arch="armhf";; \ + *) echo "TARGETPLATFORM $TARGETPLATFORM not found"; exit 1 ;;\ + esac; \ + wget "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ffmpeg_arch}-static.tar.xz"; \ + wget "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${ffmpeg_arch}-static.tar.xz.md5"; \ + md5sum -c "ffmpeg-release-${ffmpeg_arch}-static.tar.xz.md5"; \ + tar Jxf "ffmpeg-release-${ffmpeg_arch}-static.tar.xz"; \ + mv "ffmpeg-5.0.1-${ffmpeg_arch}-static/ffmpeg" /usr/bin; \ + cd /; \ + rm -rf /build_ffmpeg; + +# The cryptography package is written in Rust and not available as a built wheel for armv7 +# Thats why we need Rust to compile it from source +RUN set -e; \ + if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y; \ + . $HOME/.cargo/env; \ + fi; \ + pip install --upgrade pip setuptools poetry; \ + rm -rf $HOME/.cargo $HOME/.rustup; diff --git a/manage.py b/manage.py index 9986394..b71322b 120000 --- a/manage.py +++ b/manage.py @@ -1 +1 @@ -ucast/manage.py \ No newline at end of file +ucast_project/manage.py \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..0520d67 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "ucast", + "license": "MIT", + "author": { + "name": "ThetaDev", + "email": "t.testboy@gmail.com" + }, + "description": "YouTube to Podcast converter", + "private": true, + "dependencies": { + "bulma": "^0.9.4" + }, + "devDependencies": { + "autoprefixer": "^10.4.7", + "clean-css-cli": "^5.6.0", + "postcss": "^8.4.13", + "postcss-cli": "^9.1.0", + "rimraf": "^3.0.2", + "sass": "^1.51.0" + }, + "scripts": { + "build": "npm run build-clean && npm run build-sass && npm run build-autoprefix && npm run build-cleancss", + "build-autoprefix": "postcss --use autoprefixer --map false --output ucast/static/bulma/css/style.css ucast/static/bulma/css/style.css", + "build-cleancss": "cleancss -o ucast/static/bulma/css/style.min.css ucast/static/bulma/css/style.css", + "build-clean": "rimraf ucast/static/bulma/css", + "build-sass": "sass --style expanded --source-map assets/sass/style.sass ucast/static/bulma/css/style.css", + "start": "npm run build-sass -- --watch" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2ef8bfb --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,646 @@ +lockfileVersion: 5.3 + +specifiers: + autoprefixer: ^10.4.7 + bulma: ^0.9.4 + clean-css-cli: ^5.6.0 + postcss: ^8.4.13 + postcss-cli: ^9.1.0 + rimraf: ^3.0.2 + sass: ^1.51.0 + +dependencies: + bulma: 0.9.4 + +devDependencies: + autoprefixer: 10.4.7_postcss@8.4.13 + clean-css-cli: 5.6.0 + postcss: 8.4.13 + postcss-cli: 9.1.0_postcss@8.4.13 + rimraf: 3.0.2 + sass: 1.51.0 + +packages: + + /@nodelib/fs.scandir/2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat/2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk/1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.13.0 + dev: true + + /ansi-regex/5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-styles/4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /array-union/3.0.1: + resolution: {integrity: sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw==} + engines: {node: '>=12'} + dev: true + + /autoprefixer/10.4.7_postcss@8.4.13: + resolution: {integrity: sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.20.3 + caniuse-lite: 1.0.30001339 + fraction.js: 4.2.0 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.13 + postcss-value-parser: 4.2.0 + dev: true + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /browserslist/4.20.3: + resolution: {integrity: sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001339 + electron-to-chromium: 1.4.137 + escalade: 3.1.1 + node-releases: 2.0.4 + picocolors: 1.0.0 + dev: true + + /bulma/0.9.4: + resolution: {integrity: sha512-86FlT5+1GrsgKbPLRRY7cGDg8fsJiP/jzTqXXVqiUZZ2aZT8uemEOHlU1CDU+TxklPEZ11HZNNWclRBBecP4CQ==} + dev: false + + /caniuse-lite/1.0.30001339: + resolution: {integrity: sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==} + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /clean-css-cli/5.6.0: + resolution: {integrity: sha512-68vorNEG808D1QzeerO9AlwQVTuaR8YSK4aqwIsjJq0wDSyPH11ApHY0O+EQrdEGUZcN+d72v+Nn/gpxjAFewQ==} + engines: {node: '>= 10.12.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + clean-css: 5.3.0 + commander: 7.2.0 + glob: 7.2.0 + dev: true + + /clean-css/5.3.0: + resolution: {integrity: sha512-YYuuxv4H/iNb1Z/5IbMRoxgrzjWGhOEFfd+groZ5dMCVkpENiMZmwspdrzBo9286JjM1gZJPAyL7ZIdzuvu2AQ==} + engines: {node: '>= 10.0'} + dependencies: + source-map: 0.6.1 + dev: true + + /cliui/7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /color-convert/2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name/1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /commander/7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: true + + /dependency-graph/0.11.0: + resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} + engines: {node: '>= 0.6.0'} + dev: true + + /dir-glob/3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /electron-to-chromium/1.4.137: + resolution: {integrity: sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==} + dev: true + + /emoji-regex/8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /escalade/3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + dev: true + + /fast-glob/3.2.11: + resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + + /fastq/1.13.0: + resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==} + dependencies: + reusify: 1.0.4 + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /fraction.js/4.2.0: + resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} + dev: true + + /fs-extra/10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + dependencies: + graceful-fs: 4.2.10 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /get-caller-file/2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + + /get-stdin/9.0.0: + resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==} + engines: {node: '>=12'} + dev: true + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globby/12.2.0: + resolution: {integrity: sha512-wiSuFQLZ+urS9x2gGPl1H5drc5twabmm4m2gTR27XDFyjUHJUNsS8o/2aKyIF6IoBaR630atdher0XJ5g6OMmA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + array-union: 3.0.1 + dir-glob: 3.0.1 + fast-glob: 3.2.11 + ignore: 5.2.0 + merge2: 1.4.1 + slash: 4.0.0 + dev: true + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /ignore/5.2.0: + resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} + engines: {node: '>= 4'} + dev: true + + /immutable/4.0.0: + resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} + engines: {node: '>=0.10.0'} + dev: true + + /is-fullwidth-code-point/3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /jsonfile/6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.10 + dev: true + + /lilconfig/2.0.5: + resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==} + engines: {node: '>=10'} + dev: true + + /merge2/1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /micromatch/4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /node-releases/2.0.4: + resolution: {integrity: sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==} + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /normalize-range/0.1.2: + resolution: {integrity: sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=} + engines: {node: '>=0.10.0'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: true + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: true + + /path-type/4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /pify/2.3.0: + resolution: {integrity: sha1-7RQaasBDqEnqWISY59yosVMw6Qw=} + engines: {node: '>=0.10.0'} + dev: true + + /postcss-cli/9.1.0_postcss@8.4.13: + resolution: {integrity: sha512-zvDN2ADbWfza42sAnj+O2uUWyL0eRL1V+6giM2vi4SqTR3gTYy8XzcpfwccayF2szcUif0HMmXiEaDv9iEhcpw==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + postcss: ^8.0.0 + dependencies: + chokidar: 3.5.3 + dependency-graph: 0.11.0 + fs-extra: 10.1.0 + get-stdin: 9.0.0 + globby: 12.2.0 + picocolors: 1.0.0 + postcss: 8.4.13 + postcss-load-config: 3.1.4_postcss@8.4.13 + postcss-reporter: 7.0.5_postcss@8.4.13 + pretty-hrtime: 1.0.3 + read-cache: 1.0.0 + slash: 4.0.0 + yargs: 17.4.1 + transitivePeerDependencies: + - ts-node + dev: true + + /postcss-load-config/3.1.4_postcss@8.4.13: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.0.5 + postcss: 8.4.13 + yaml: 1.10.2 + dev: true + + /postcss-reporter/7.0.5_postcss@8.4.13: + resolution: {integrity: sha512-glWg7VZBilooZGOFPhN9msJ3FQs19Hie7l5a/eE6WglzYqVeH3ong3ShFcp9kDWJT1g2Y/wd59cocf9XxBtkWA==} + engines: {node: '>=10'} + peerDependencies: + postcss: ^8.1.0 + dependencies: + picocolors: 1.0.0 + postcss: 8.4.13 + thenby: 1.3.4 + dev: true + + /postcss-value-parser/4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true + + /postcss/8.4.13: + resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /pretty-hrtime/1.0.3: + resolution: {integrity: sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=} + engines: {node: '>= 0.8'} + dev: true + + /queue-microtask/1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /read-cache/1.0.0: + resolution: {integrity: sha1-5mTvMRYRZsl1HNvo28+GtftY93Q=} + dependencies: + pify: 2.3.0 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /require-directory/2.1.1: + resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=} + engines: {node: '>=0.10.0'} + dev: true + + /reusify/1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rimraf/3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.0 + dev: true + + /run-parallel/1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /sass/1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.0.0 + source-map-js: 1.0.2 + dev: true + + /slash/4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map/0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true + + /string-width/4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /strip-ansi/6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /thenby/1.3.4: + resolution: {integrity: sha512-89Gi5raiWA3QZ4b2ePcEwswC3me9JIg+ToSgtE0JWeCynLnLxNr/f9G+xfo9K+Oj4AFdom8YNJjibIARTJmapQ==} + dev: true + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /universalify/2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + dev: true + + /wrap-ansi/7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: true + + /y18n/5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yaml/1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: true + + /yargs-parser/21.0.1: + resolution: {integrity: sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==} + engines: {node: '>=12'} + dev: true + + /yargs/17.4.1: + resolution: {integrity: sha512-WSZD9jgobAg3ZKuCQZSa3g9QOJeCCqLoLAykiWgmXnDo9EPnn4RPf5qVTtzgOx66o6/oqhcA5tHtJXpG8pMt3g==} + engines: {node: '>=12'} + dependencies: + cliui: 7.0.4 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.0.1 + dev: true diff --git a/poetry.lock b/poetry.lock index 195374a..eeea053 100644 --- a/poetry.lock +++ b/poetry.lock @@ -157,6 +157,17 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-bulma" +version = "0.8.3" +description = "Bulma CSS Framework for Django projects" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +django = ">=2.2" + [[package]] name = "feedparser" version = "6.0.8" @@ -239,6 +250,14 @@ 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" @@ -310,6 +329,14 @@ 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" @@ -396,6 +423,17 @@ pytest = ">=5.4.0" docs = ["sphinx", "sphinx-rtd-theme"] testing = ["django", "django-configurations (>=2.0)"] +[[package]] +name = "python-dotenv" +version = "0.20.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + [[package]] name = "pyyaml" version = "6.0" @@ -564,7 +602,7 @@ websockets = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "99e2a5970962f1e936da2010b8ec997026f5afe4762af4345da287125e6b7771" +content-hash = "8609785f53a44a16f3c5c1d5042ab2627bb198f3c7daa8ea18e55bf1e66c4345" [metadata.files] asgiref = [ @@ -802,6 +840,10 @@ django = [ {file = "Django-4.0.4-py3-none-any.whl", hash = "sha256:07c8638e7a7f548dc0acaaa7825d84b7bd42b10e8d22268b3d572946f1e9b687"}, {file = "Django-4.0.4.tar.gz", hash = "sha256:4e8177858524417563cc0430f29ea249946d831eacb0068a1455686587df40b5"}, ] +django-bulma = [ + {file = "django-bulma-0.8.3.tar.gz", hash = "sha256:b794b4e64f482de77f376451f7cd8b3c8448eb68e5a24c51b9190625a08b0b30"}, + {file = "django_bulma-0.8.3-py3-none-any.whl", hash = "sha256:0ef6e5c171c2a32010e724a8be61ba6cd0e55ebbd242cf6780560518483c4d00"}, +] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"}, @@ -840,6 +882,13 @@ 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"}, @@ -900,6 +949,19 @@ pre-commit = [ {file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"}, {file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"}, ] +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"}, @@ -953,6 +1015,10 @@ 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"}, ] +python-dotenv = [ + {file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"}, + {file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"}, +] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, diff --git a/pyproject.toml b/pyproject.toml index 0323b68..d783024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "YouTube to Podcast converter" authors = ["Theta-Dev "] packages = [ { include = "ucast" }, - { include = "yt2podcast" }, + { include = "ucast_project" }, ] [tool.poetry.dependencies] @@ -21,6 +21,10 @@ wcag-contrast-ratio = "^0.9" font-source-sans-pro = "^0.0.1" fonts = "^0.0.3" bordercrop = "^1.0.0" +django-bulma = "^0.8.3" +python-dotenv = "^0.20.0" +psycopg2 = "^2.9.3" +mysqlclient = "^2.1.0" [tool.poetry.dev-dependencies] pytest = "^7.1.1" @@ -30,7 +34,7 @@ pytest-django = "^4.5.2" pre-commit = "^2.19.0" [tool.poetry.scripts] -"ucast-manage" = "ucast.manage:main" +"ucast-manage" = "ucast_project.manage:main" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tasks.py b/tasks.py index 832c200..3c9a2df 100644 --- a/tasks.py +++ b/tasks.py @@ -1,40 +1,75 @@ import os from pathlib import Path -from invoke import task +from invoke import Responder, task -from yt2podcast import tests -from yt2podcast.service import cover, util, youtube +from ucast import tests +from ucast.service import cover, util, youtube os.chdir(Path(__file__).absolute().parent) @task def test(c): + """Run unit tests""" c.run("pytest", pty=True) @task def lint(c): + """Check for code quality and formatting""" c.run("pre-commit run -a", pty=True) @task def format(c): + """Format the code with black""" c.run("pre-commit run black -a", pty=True) @task def makemigrations(c): - c.run("python manage.py makemigrations yt2podcast") + """Create a new migration that applies the changes made to the data model""" + c.run("python manage.py makemigrations ucast") + + +@task +def collectstatic(c): + """Copy static files into a common folder""" + c.run("python manage.py collectstatic --noinput") + + +@task +def migrate(c): + """Migrate the database""" + c.run("python manage.py migrate") + + +@task +def create_testuser(c): + """Create a test user with the credentials admin:pass""" + responder_pwd = Responder(pattern=r"Password.*: ", response="pass\n") + responder_yes = Responder(pattern=r"Bypass password validation", response="y\n") + + c.run( + "python manage.py createsuperuser --username admin --email admin@example.com", + pty=True, + watchers=[responder_pwd, responder_yes], + ) @task def get_cover(c, vid=""): + """ + Download thumbnail image of the YouTube video with the id + from the ``--vid`` parameter and create cover images from it. + + The images are stored in the ``ucast/tests/testfiles`` directory. + """ vinfo = youtube.get_video_info(vid) - title = vinfo["fulltitle"] - channel_name = vinfo["uploader"] - channel_url = vinfo["channel_url"] + title = vinfo.title + channel_name = vinfo.channel_name + channel_url = vinfo.channel_url channel_metadata = youtube.get_channel_metadata(channel_url) ti = 1 @@ -55,3 +90,11 @@ def get_cover(c, vid=""): cover.create_cover_file( tn_file, av_file, title, channel_name, cover.CoverStyle.BLUR, cv_blur_file ) + + +@task +def build_devcontainer(c): + c.run( + "docker buildx build -t thetadev256/ucast-dev --push \ +--platform amd64,arm64,armhf -f deploy/Devcontainer.Dockerfile deploy" + ) diff --git a/ucast/__init__.py b/ucast/__init__.py index 9bad579..e69de29 100644 --- a/ucast/__init__.py +++ b/ucast/__init__.py @@ -1 +0,0 @@ -# coding=utf-8 diff --git a/yt2podcast/admin.py b/ucast/admin.py similarity index 100% rename from yt2podcast/admin.py rename to ucast/admin.py diff --git a/yt2podcast/apps.py b/ucast/apps.py similarity index 61% rename from yt2podcast/apps.py rename to ucast/apps.py index 563d9a1..ad4b110 100644 --- a/yt2podcast/apps.py +++ b/ucast/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class Yt2PodcastConfig(AppConfig): +class UcastConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "yt2podcast" + name = "ucast" diff --git a/yt2podcast/migrations/0001_initial.py b/ucast/migrations/0001_initial.py similarity index 95% rename from yt2podcast/migrations/0001_initial.py rename to ucast/migrations/0001_initial.py index fed1734..d9d9bd5 100644 --- a/yt2podcast/migrations/0001_initial.py +++ b/ucast/migrations/0001_initial.py @@ -34,7 +34,7 @@ class Migration(migrations.Migration): ("title", models.CharField(max_length=200)), ("slug", models.CharField(max_length=209)), ("published", models.DateTimeField()), - ("downloaded", models.DateTimeField()), + ("downloaded", models.DateTimeField(null=True)), ("description", models.TextField()), ], ), diff --git a/yt2podcast/__init__.py b/ucast/migrations/__init__.py similarity index 100% rename from yt2podcast/__init__.py rename to ucast/migrations/__init__.py diff --git a/yt2podcast/models.py b/ucast/models.py similarity index 92% rename from yt2podcast/models.py rename to ucast/models.py index 486abad..7f6711e 100644 --- a/yt2podcast/models.py +++ b/ucast/models.py @@ -15,5 +15,5 @@ class Video(models.Model): title = models.CharField(max_length=200) slug = models.CharField(max_length=209) published = models.DateTimeField() - downloaded = models.DateTimeField() + downloaded = models.DateTimeField(null=True) description = models.TextField() diff --git a/ucast/resources/yt_icon.png b/ucast/resources/yt_icon.png new file mode 100644 index 0000000..5d2fac6 Binary files /dev/null and b/ucast/resources/yt_icon.png differ diff --git a/yt2podcast/migrations/__init__.py b/ucast/service/__init__.py similarity index 100% rename from yt2podcast/migrations/__init__.py rename to ucast/service/__init__.py diff --git a/yt2podcast/service/cover.py b/ucast/service/cover.py similarity index 56% rename from yt2podcast/service/cover.py rename to ucast/service/cover.py index 8049b79..5e6444d 100644 --- a/yt2podcast/service/cover.py +++ b/ucast/service/cover.py @@ -1,6 +1,6 @@ -# coding=utf-8 import enum import math +from importlib import resources from pathlib import Path from typing import List, Optional, Tuple @@ -10,7 +10,7 @@ from colorthief import ColorThief from fonts.ttf import SourceSansPro from PIL import Image, ImageDraw, ImageFilter, ImageFont -from yt2podcast.service import typ +from ucast.service import typ CHAR_ELLIPSIS = "…" COVER_WIDTH = 500 @@ -24,6 +24,17 @@ class CoverStyle(enum.Enum): def _split_text( height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0 ) -> List[str]: + """ + Split and trim the input text so it can be printed to a certain + area of an image. + + :param height: Image area height [px] + :param width: Image area width [px] + :param text: Input text + :param font: Pillow ImageFont + :param line_spacing: Line spacing [px] + :return: + """ if height < font.size: return [] @@ -80,6 +91,19 @@ def _draw_text_box( line_spacing=0, vertical_center=True, ): + """ + Draw a text box to an image. The text gets automatically + wrapped and trimmed to fit. + + :param draw: Pillow ImageDraw object + :param box: Coordinates of the text box ``(x_tl, y_tl, x_br, y_br)`` + :param text: Text to be printed + :param font: Pillow ImageFont + :param color: Text color + :param line_spacing: Line spacing [px] + :param vertical_center: Center text vertically in the box + :return: + """ x_tl, y_tl, x_br, y_br = box height = y_br - y_tl width = x_br - x_tl @@ -96,19 +120,41 @@ def _draw_text_box( draw.text((x_tl, y_pos), line, color, font) -def _get_dominant_color(img: Image.Image): +def _get_dominant_color(img: Image.Image) -> typ.Color: + """ + Return the dominant color of an image using the ColorThief library. + + :param img: Pillow Image object + :return: dominant color + """ thief = ColorThief.__new__(ColorThief) thief.image = img return thief.get_color() -def _interpolate_color(color_from: typ.Color, color_to: typ.Color, interval: int): - det_co = [(t - f) / interval for f, t in zip(color_from, color_to)] - for i in range(interval): +def _interpolate_color(color_from: typ.Color, color_to: typ.Color, steps: int): + """ + Return a generator providing colors within the given range. Useful to create + gradients. + + :param color_from: Starting color + :param color_to: Ending color + :param steps: Number of steps + :return: Generator providing the colors + """ + det_co = [(t - f) / steps for f, t in zip(color_from, color_to)] + for i in range(steps): yield [round(f + det * i) for f, det in zip(color_from, det_co)] def _get_text_color(bg_color) -> typ.Color: + """ + Return the text color (black or white) with the largest contrast + to a given background color. + + :param bg_color: Background color + :return: Text 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) @@ -123,17 +169,26 @@ def _get_baseimage( bottom_color: typ.Color, style: CoverStyle, ): + """ + Return the background image for the cover. + + :param thumbnail: Thumbnail image object + :param top_color: Top color of the thumbnail image + :param bottom_color: Bottom color of the thumbnail image + :param style: Style of the cover image + :return: Base image + """ + cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH)) + if style == CoverStyle.BLUR: ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width) - ctn_l = int((ctn_width - COVER_WIDTH) / 2) - ctn_r = ctn_width - ctn_l - cover = ( - thumbnail.resize((ctn_width, COVER_WIDTH), Image.Resampling.LANCZOS) - .crop((ctn_l, 0, ctn_r, COVER_WIDTH)) - .filter(ImageFilter.GaussianBlur(20)) - ) + ctn_x_left = int((ctn_width - COVER_WIDTH) / 2) + + ctn = thumbnail.resize( + (ctn_width, COVER_WIDTH), Image.Resampling.LANCZOS + ).filter(ImageFilter.GaussianBlur(20)) + cover.paste(ctn, (-ctn_x_left, 0)) else: - cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH)) cover_draw = ImageDraw.Draw(cover) # Draw background gradient @@ -145,14 +200,13 @@ def _get_baseimage( return cover -def _create_cover_image( - thumbnail: Image.Image, - avatar: Optional[Image.Image], - title: str, - channel: str, - style: CoverStyle, -) -> Image.Image: - # Remove black bars from thumbnail +def _resize_thumbnail(thumbnail: Image.Image) -> Image.Image: + """ + Scale thumbnail image down to cover size and remove black bars + + :param thumbnail: Thumbnail image object + :return: Resized thumbnail image object + """ thumbnail = bordercrop.crop( thumbnail, MINIMUM_ROWS=int(thumbnail.height * 0.1), @@ -163,30 +217,31 @@ def _create_cover_image( tn_resize_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height) tn_16_9_height = int(COVER_WIDTH / 16 * 9) tn_height = min(tn_resize_height, tn_16_9_height) - tn_crop_t = int((tn_resize_height - tn_height) / 2) - tn_crop_b = tn_resize_height - tn_crop_t - tn = thumbnail.resize( + tn_crop_y_top = int((tn_resize_height - tn_height) / 2) + tn_crop_y_bottom = tn_resize_height - tn_crop_y_top + + return thumbnail.resize( (COVER_WIDTH, tn_resize_height), Image.Resampling.LANCZOS - ).crop((0, tn_crop_t, COVER_WIDTH, tn_crop_b)) + ).crop((0, tn_crop_y_top, COVER_WIDTH, tn_crop_y_bottom)) - # Get dominant colors from the top and bottom 20% of the thumbnail image - top_part = tn.crop((0, 0, COVER_WIDTH, int(tn_height * 0.2))) - bottom_part = tn.crop((0, int(tn_height * 0.8), COVER_WIDTH, tn_height)) - top_color = _get_dominant_color(top_part) - bottom_color = _get_dominant_color(bottom_part) - cover = _get_baseimage(thumbnail, top_color, bottom_color, style) +def _draw_text_avatar( + cover: Image.Image, + avatar: Optional[Image.Image], + title: str, + channel: str, + top_color: typ.Color, + bottom_color: typ.Color, +): cover_draw = ImageDraw.Draw(cover) - # Insert thumbnail image in the middle - tn_margin = int((COVER_WIDTH - tn_height) / 2) - tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2) - cover.paste(tn, (0, tn_margin)) - # Add channel avatar avt_margin = 0 avt_size = 0 + tn_16_9_height = int(COVER_WIDTH / 16 * 9) + tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2) + if avatar: avt_margin = int(tn_16_9_margin * 0.05) avt_size = tn_16_9_margin - 2 * avt_margin @@ -236,22 +291,90 @@ def _create_cover_image( text_line_space, ) + +def _create_cover_image( + thumbnail: Image.Image, + avatar: Optional[Image.Image], + title: str, + channel: str, + style: CoverStyle, +) -> Image.Image: + """ + Create a cover image from video metadata and thumbnail + + :param thumbnail: Thumbnail image object + :param avatar: Creator avatar image object + :param title: Video title + :param channel: Channel name + :param style: Style of cover image + :return: Cover image + """ + tn = _resize_thumbnail(thumbnail) + + # Get dominant colors from the top and bottom 20% of the thumbnail image + top_part = tn.crop((0, 0, COVER_WIDTH, int(tn.height * 0.2))) + bottom_part = tn.crop((0, int(tn.height * 0.8), COVER_WIDTH, tn.height)) + top_color = _get_dominant_color(top_part) + bottom_color = _get_dominant_color(bottom_part) + + cover = _get_baseimage(tn, top_color, bottom_color, style) + + # Insert thumbnail image in the middle + tn_margin = int((COVER_WIDTH - tn.height) / 2) + cover.paste(tn, (0, tn_margin)) + + _draw_text_avatar(cover, avatar, title, channel, top_color, bottom_color) + + return cover + + +def _create_blank_cover_image( + avatar: Optional[Image.Image], title: str, channel: str +) -> Image.Image: + bg_color = (16, 16, 16) + cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH), bg_color) + + yt_icon_path = resources.path("ucast.resources", "yt_icon.png") + yt_icon = Image.open(yt_icon_path) + yt_icon_x_left = int((COVER_WIDTH - yt_icon.width) / 2) + yt_icon_y_top = int((COVER_WIDTH - yt_icon.height) / 2) + cover.paste(yt_icon, (yt_icon_x_left, yt_icon_y_top)) + + _draw_text_avatar(cover, avatar, title, channel, bg_color, bg_color) + return cover def create_cover_file( - thumbnail_path: Path, + thumbnail_path: Optional[Path], avatar_path: Optional[Path], title: str, channel: str, style: CoverStyle, cover_path: Path, ): - thumbnail = Image.open(thumbnail_path) + """ + Create a cover image from video metadata and thumbnail + and save it to disk. + + :param thumbnail_path: Path of thumbnail image + :param avatar_path: Path of avatar image + :param title: Video title + :param channel: Channel name + :param style: Style of cover image + :param cover_path: Save path of cover image + """ + thumbnail = None + if thumbnail_path: + thumbnail = Image.open(thumbnail_path) avatar = None if avatar_path: avatar = Image.open(avatar_path) - cvr = _create_cover_image(thumbnail, avatar, title, channel, style) + if thumbnail: + cvr = _create_cover_image(thumbnail, avatar, title, channel, style) + else: + cvr = _create_blank_cover_image(avatar, title, channel) + cvr.save(cover_path) diff --git a/yt2podcast/service/typ.py b/ucast/service/typ.py similarity index 78% rename from yt2podcast/service/typ.py rename to ucast/service/typ.py index dbf0d5a..75efbf1 100644 --- a/yt2podcast/service/typ.py +++ b/ucast/service/typ.py @@ -1,4 +1,3 @@ -# coding=utf-8 from typing import Tuple Color = Tuple[int, int, int] diff --git a/yt2podcast/service/util.py b/ucast/service/util.py similarity index 92% rename from yt2podcast/service/util.py rename to ucast/service/util.py index c58a39c..011bedd 100644 --- a/yt2podcast/service/util.py +++ b/ucast/service/util.py @@ -1,4 +1,3 @@ -# coding=utf-8 import requests diff --git a/ucast/service/youtube.py b/ucast/service/youtube.py new file mode 100644 index 0000000..b02a3cd --- /dev/null +++ b/ucast/service/youtube.py @@ -0,0 +1,145 @@ +import json +from dataclasses import dataclass +from datetime import datetime +from operator import itemgetter + +import requests +from scrapetube import scrapetube +from yt_dlp import YoutubeDL + +from ucast.service import util + + +class VideoInfo: + """Mapping of YoutubeDL's video information""" + + def __init__(self, info: dict): + self._info = info + + self.id = info["id"] + self.title = info["title"] + self.description = info["description"] + self.channel_id = info["channel_id"] + self.channel_name = info["uploader"] + self.duration = info["duration"] + self.published = self.__approx_published_time( + datetime.strptime(info["upload_date"], "%Y%m%d") + ) + self.thumbnails = info["thumbnails"] + self.is_currently_live = bool(info.get("is_live")) + self.is_livestream = info.get("is_live") or info.get("was_live") + self.is_short = self.duration <= 60 and info["width"] < info["height"] + + @staticmethod + def __approx_published_time(time_in: datetime) -> datetime: + """ + Assume that a video published on the current day is published now. + Eventually add an option to get the exact upload time from Google's API. + + :param time_in: + :return: + """ + now = datetime.now() + if time_in.date() == now.date(): + return now + return time_in + + def __str__(self): + return f"{self.title} ({self.id})" + + +class ThumbnailNotFoundError(Exception): + pass + + +def download_thumbnail(vinfo: VideoInfo, download_path): + """ + Download the thumbnail image of a YouTube video and save it at the given filepath. + Does not add the correct file ending (jpg or webp), we are converting it with + Pillow anyway. + + :param vinfo: Video info (from ``get_video_info()``) + :param download_path: Path of the thumbnail file + :raise ThumbnailNotFoundError: if no thumbnail could be found (YT returned 404) + """ + for tn in sorted(vinfo.thumbnails, key=itemgetter("preference"), reverse=True): + url = tn["url"] + print(f"downloading thumbnail {url}...") + + try: + util.download_file(url, download_path) + return + except requests.HTTPError: + print(f"downloading thumbnail {url} failed") + pass + + raise ThumbnailNotFoundError(f"could not find thumbnail for video {vinfo}") + + +def get_video_info(video_id) -> VideoInfo: + with YoutubeDL() as ydl: + info = ydl.extract_info(video_id, download=False) + return VideoInfo(info) + + +def download_video(video_id, download_path, sponsorblock=False) -> VideoInfo: + ydl_params = { + "format": "bestaudio", + "postprocessors": [ + {"key": "FFmpegExtractAudio", "preferredcodec": "mp3"}, + ], + "outtmpl": str(download_path), + } + + if sponsorblock: + # noinspection PyTypeChecker + 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 + info = ydl.extract_info(video_id) + return VideoInfo(info) + + +@dataclass +class ChannelMetadata: + id: str + name: str + description: str + avatar_url: str + + +def channel_url_from_id(channel_id: str) -> str: + return "https://www.youtube.com/channel/" + channel_id + + +def get_channel_metadata(channel_url: str) -> ChannelMetadata: + 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" + + url = f"{channel_url}/videos?view=0&flow=grid" + + html = scrapetube.get_initial_data(session, url) + data = json.loads( + scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}" + ) + metadata = data["metadata"]["channelMetadataRenderer"] + + 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/ucast/tests/__init__.py b/ucast/tests/__init__.py new file mode 100644 index 0000000..293ff6c --- /dev/null +++ b/ucast/tests/__init__.py @@ -0,0 +1,3 @@ +from importlib.resources import files + +DIR_TESTFILES = files("ucast.tests.testfiles") diff --git a/yt2podcast/tests/test_cover.py b/ucast/tests/test_cover.py similarity index 74% rename from yt2podcast/tests/test_cover.py rename to ucast/tests/test_cover.py index 8a4d457..ca50256 100644 --- a/yt2podcast/tests/test_cover.py +++ b/ucast/tests/test_cover.py @@ -1,4 +1,3 @@ -# coding=utf-8 import tempfile from pathlib import Path from typing import List @@ -7,8 +6,8 @@ import pytest from fonts.ttf import SourceSansPro from PIL import Image, ImageChops, ImageFont -from yt2podcast import tests -from yt2podcast.service import cover, typ +from ucast import tests +from ucast.service import cover, typ @pytest.mark.parametrize( @@ -50,7 +49,7 @@ def test_split_text(height: int, width: int, text: str, expect: List[str]): "file_name,color", [ ("t1.webp", (63, 63, 62)), - ("t2.webp", (17, 14, 15)), + ("t2.webp", (22, 20, 20)), ("t3.webp", (54, 24, 28)), ], ) @@ -118,6 +117,47 @@ def test_create_cover_image( cv_image = cover._create_cover_image(tn_image, av_image, title, channel, style) + assert cv_image.width == cover.COVER_WIDTH + assert cv_image.height == cover.COVER_WIDTH + + diff = ImageChops.difference(cv_image, expected_cv_image) + assert diff.getbbox() is None + + +def test_create_cover_image_noavatar(): + tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp" + expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1_noavatar.png" + + tn_image = Image.open(tn_file) + expected_cv_image = Image.open(expected_cv_file) + + cv_image = cover._create_cover_image( + tn_image, + None, + "ThetaDev @ Embedded World 2019", + "ThetaDev", + cover.CoverStyle.CLASSIC, + ) + + assert cv_image.width == cover.COVER_WIDTH + assert cv_image.height == cover.COVER_WIDTH + + diff = ImageChops.difference(cv_image, expected_cv_image) + assert diff.getbbox() is None + + +def test_create_blank_cover_image(): + av_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg" + expected_cv_file = tests.DIR_TESTFILES / "cover" / "blank.png" + + av_image = Image.open(av_file) + expected_cv_image = Image.open(expected_cv_file) + + cv_image = cover._create_blank_cover_image(av_image, "missingno", "ThetaDev") + + assert cv_image.width == cover.COVER_WIDTH + assert cv_image.height == cover.COVER_WIDTH + diff = ImageChops.difference(cv_image, expected_cv_image) assert diff.getbbox() is None diff --git a/ucast/tests/test_util.py b/ucast/tests/test_util.py new file mode 100644 index 0000000..895b78d --- /dev/null +++ b/ucast/tests/test_util.py @@ -0,0 +1,24 @@ +import tempfile +from pathlib import Path + +from PIL import Image, ImageChops + +from ucast import tests +from ucast.service import util + +TEST_FILE_URL = "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj" # noqa: E501 + + +def test_download_file(): + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + download_file = tmpdir / "download.jpg" + expected_tn_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg" + + util.download_file(TEST_FILE_URL, download_file) + + downloaded_avatar = Image.open(download_file) + expected_avatar = Image.open(expected_tn_file) + + diff = ImageChops.difference(downloaded_avatar, expected_avatar) + assert diff.getbbox() is None diff --git a/ucast/tests/test_youtube.py b/ucast/tests/test_youtube.py new file mode 100644 index 0000000..151a3e6 --- /dev/null +++ b/ucast/tests/test_youtube.py @@ -0,0 +1,129 @@ +import re +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path + +import pytest +from PIL import Image, ImageChops + +from ucast import tests +from ucast.service import youtube + +VIDEO_ID_SINTEL = "eRsGyueVLvQ" +VIDEO_ID_SHORT = "lcQZ6YwQHiw" +VIDEO_ID_PERSUASION = "DWjFW7Yq1fA" + +CHANNEL_ID_THETADEV = "UCGiJh0NZ52wRhYKYnuZI08Q" +CHANNEL_ID_BLENDER = "UCSMOQeBJ2RAnuFungnQOxLg" +CHANNEL_URL_BLENDER = "https://www.youtube.com/c/BlenderFoundation" + + +@pytest.fixture(scope="module") +def video_info() -> youtube.VideoInfo: + return youtube.get_video_info(VIDEO_ID_SINTEL) + + +def test_download_thumbnail(video_info): + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + tn_file = tmpdir / "thumbnail" + expected_tn_file = tests.DIR_TESTFILES / "thumbnail" / "t2.webp" + + youtube.download_thumbnail(video_info, tn_file) + + tn = Image.open(tn_file) + expected_tn = Image.open(expected_tn_file) + + diff = ImageChops.difference(tn, expected_tn) + assert diff.getbbox() is None + + +def test_get_video_info(video_info): + assert video_info.id == VIDEO_ID_SINTEL + assert video_info.title == "Sintel - Open Movie by Blender Foundation" + assert video_info.channel_id == "UCSMOQeBJ2RAnuFungnQOxLg" + assert ( + video_info.description + == """Help us making Free/Open Movies: https://cloud.blender.org/join + +"Sintel" is an independently produced short film, initiated by the Blender Foundation \ +as a means to further improve and validate the free/open source 3D creation suite \ +Blender. With initial funding provided by 1000s of donations via the internet \ +community, it has \ +again proven to be a viable development model for both open 3D technology as for \ +independent animation film. +This 15 minute film has been realized in the studio of the Amsterdam Blender \ +Institute, by an international team of artists and developers. In addition to \ +that, several crucial technical and creative targets have been realized online, \ +by developers and artists and teams all over the world. + +www.sintel.org""" + ) + assert video_info.duration == 888 + assert not video_info.is_currently_live + assert not video_info.is_livestream + assert not video_info.is_short + assert video_info.published == datetime(2010, 9, 30) + + +def test_get_video_info_short(): + vinfo = youtube.get_video_info(VIDEO_ID_SHORT) + assert vinfo.id == VIDEO_ID_SHORT + assert ( + vinfo.title + == "Small pink flowers | #shorts | Free Stock Video | \ +creative commons short videos | creative #short" + ) + assert not vinfo.is_currently_live + assert not vinfo.is_livestream + assert vinfo.is_short + + +def test_download_video(): + tmpdir_o = tempfile.TemporaryDirectory() + tmpdir = Path(tmpdir_o.name) + download_file = tmpdir / "download.mp3" + + vinfo = youtube.download_video(VIDEO_ID_PERSUASION, download_file) + assert vinfo.id == VIDEO_ID_PERSUASION + assert vinfo.title == "Persuasion (Instrumental) – RYYZN (No Copyright Music)" + assert vinfo.duration == 100 + + # Check with ffmpeg if the audio file is valid + res = subprocess.run( + ["ffmpeg", "-i", str(download_file)], + capture_output=True, + universal_newlines=True, + ) + assert "Stream #0:0: Audio: mp3" in res.stderr + + match = re.search(r"Duration: (\d{2}:\d{2}:\d{2})", res.stderr) + assert match[1] == "00:01:40" + + +@pytest.mark.parametrize( + "channel_url,channel_id,name,avatar_url", + [ + ( + youtube.channel_url_from_id(CHANNEL_ID_THETADEV), + CHANNEL_ID_THETADEV, + "ThetaDev", + "https://yt3.ggpht.com/ytc/AKedOLSnFfmpibLLoqyaYdsF6bJ-zaLPzomII__FrJve1w=s900-c-k-c0x00ffffff-no-rj", # noqa: E501 + ), + ( + CHANNEL_URL_BLENDER, + CHANNEL_ID_BLENDER, + "Blender", + "https://yt3.ggpht.com/ytc/AKedOLT_31fFSD3FWEBnHZnyZeJx-GPHJwYCQKcEpaq8NQ=s900-c-k-c0x00ffffff-no-rj", # noqa: E501 + ), + ], +) +def test_channel_metadata( + channel_url: str, channel_id: str, name: str, avatar_url: str +): + metadata = youtube.get_channel_metadata(channel_url) + assert metadata.id == channel_id + assert metadata.name == name + assert metadata.avatar_url == avatar_url + assert metadata.description diff --git a/yt2podcast/tests/testfiles/avatar/a1.jpg b/ucast/tests/testfiles/avatar/a1.jpg similarity index 100% rename from yt2podcast/tests/testfiles/avatar/a1.jpg rename to ucast/tests/testfiles/avatar/a1.jpg diff --git a/yt2podcast/tests/testfiles/avatar/a2.jpg b/ucast/tests/testfiles/avatar/a2.jpg similarity index 100% rename from yt2podcast/tests/testfiles/avatar/a2.jpg rename to ucast/tests/testfiles/avatar/a2.jpg diff --git a/yt2podcast/tests/testfiles/avatar/a3.jpg b/ucast/tests/testfiles/avatar/a3.jpg similarity index 100% rename from yt2podcast/tests/testfiles/avatar/a3.jpg rename to ucast/tests/testfiles/avatar/a3.jpg diff --git a/ucast/tests/testfiles/cover/blank.png b/ucast/tests/testfiles/cover/blank.png new file mode 100644 index 0000000..6e79a4d Binary files /dev/null and b/ucast/tests/testfiles/cover/blank.png differ diff --git a/ucast/tests/testfiles/cover/c1_blur.png b/ucast/tests/testfiles/cover/c1_blur.png new file mode 100644 index 0000000..8b0bd15 Binary files /dev/null and b/ucast/tests/testfiles/cover/c1_blur.png differ diff --git a/yt2podcast/tests/testfiles/cover/c1_classic.png b/ucast/tests/testfiles/cover/c1_classic.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c1_classic.png rename to ucast/tests/testfiles/cover/c1_classic.png diff --git a/ucast/tests/testfiles/cover/c1_noavatar.png b/ucast/tests/testfiles/cover/c1_noavatar.png new file mode 100644 index 0000000..d708050 Binary files /dev/null and b/ucast/tests/testfiles/cover/c1_noavatar.png differ diff --git a/ucast/tests/testfiles/cover/c2_blur.png b/ucast/tests/testfiles/cover/c2_blur.png new file mode 100644 index 0000000..152d2b5 Binary files /dev/null and b/ucast/tests/testfiles/cover/c2_blur.png differ diff --git a/ucast/tests/testfiles/cover/c2_classic.png b/ucast/tests/testfiles/cover/c2_classic.png new file mode 100644 index 0000000..7c38fff Binary files /dev/null and b/ucast/tests/testfiles/cover/c2_classic.png differ diff --git a/ucast/tests/testfiles/cover/c3_blur.png b/ucast/tests/testfiles/cover/c3_blur.png new file mode 100644 index 0000000..3b919de Binary files /dev/null and b/ucast/tests/testfiles/cover/c3_blur.png differ diff --git a/yt2podcast/tests/testfiles/cover/c3_classic.png b/ucast/tests/testfiles/cover/c3_classic.png similarity index 100% rename from yt2podcast/tests/testfiles/cover/c3_classic.png rename to ucast/tests/testfiles/cover/c3_classic.png diff --git a/ucast/tests/testfiles/sources.md b/ucast/tests/testfiles/sources.md new file mode 100644 index 0000000..92f6d05 --- /dev/null +++ b/ucast/tests/testfiles/sources.md @@ -0,0 +1,10 @@ +### Quellen der Thumbnails/Avatarbilder zum Testen + +- a1/t1: [ThetaDev @ Embedded World 2019](https://www.youtube.com/watch?v=ZPxEr4YdWt8), by [ThetaDev](https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q) (CC-BY) +- a2/t2: [Sintel - Open Movie by Blender Foundation](https://www.youtube.com/watch?v=eRsGyueVLvQ), by [Blender](https://www.youtube.com/c/BlenderFoundation) (CC-BY) +- a3/t3: [Systemabsturz Teaser zur DiVOC bb3](https://www.youtube.com/watch?v=uFqgQ35wyYY), by [media.ccc.de](https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg) (CC-BY) + +### Weitere Testvideos + +- [Persuasion (Instrumental) – RYYZN (No Copyright Music)](https://www.youtube.com/watch?v=DWjFW7Yq1fA), by [RYYZN](https://soundcloud.com/ryyzn) (CC-BY) +- [Small pink flowers | #shorts | Free Stock Video](https://www.youtube.com/watch?v=lcQZ6YwQHiw), by [Shahzaib Hassan](https://www.youtube.com/channel/UCmLTTbctUZobNQrr8RtX8uQ), (CC-BY) \ No newline at end of file diff --git a/yt2podcast/tests/testfiles/thumbnail/t1.webp b/ucast/tests/testfiles/thumbnail/t1.webp similarity index 100% rename from yt2podcast/tests/testfiles/thumbnail/t1.webp rename to ucast/tests/testfiles/thumbnail/t1.webp diff --git a/ucast/tests/testfiles/thumbnail/t2.webp b/ucast/tests/testfiles/thumbnail/t2.webp new file mode 100644 index 0000000..0ed7f2b Binary files /dev/null and b/ucast/tests/testfiles/thumbnail/t2.webp differ diff --git a/yt2podcast/tests/testfiles/thumbnail/t3.webp b/ucast/tests/testfiles/thumbnail/t3.webp similarity index 100% rename from yt2podcast/tests/testfiles/thumbnail/t3.webp rename to ucast/tests/testfiles/thumbnail/t3.webp diff --git a/yt2podcast/views.py b/ucast/views.py similarity index 100% rename from yt2podcast/views.py rename to ucast/views.py diff --git a/ucast_project/__init__.py b/ucast_project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ucast/asgi.py b/ucast_project/asgi.py similarity index 81% rename from ucast/asgi.py rename to ucast_project/asgi.py index be266bd..23dea34 100644 --- a/ucast/asgi.py +++ b/ucast_project/asgi.py @@ -11,6 +11,6 @@ import os from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast_project.settings") application = get_asgi_application() diff --git a/ucast/manage.py b/ucast_project/manage.py similarity index 88% rename from ucast/manage.py rename to ucast_project/manage.py index 4c6dfe3..f4ede1d 100755 --- a/ucast/manage.py +++ b/ucast_project/manage.py @@ -6,7 +6,7 @@ import sys def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast_project.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/ucast/settings.py b/ucast_project/settings.py similarity index 52% rename from ucast/settings.py rename to ucast_project/settings.py index ffcdcd0..018668d 100644 --- a/ucast/settings.py +++ b/ucast_project/settings.py @@ -10,32 +10,82 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.0/ref/settings/ """ +import os +from importlib import resources from pathlib import Path -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent / "_run" +import dotenv -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ +VAR_PREFIX = "UCAST_" + + +def get_env(name, default=None): + return os.environ.get(VAR_PREFIX + name, default) + + +def get_env_path(name, default=None): + raw_env = get_env(name) + if not raw_env: + return default + return Path(raw_env).absolute() + + +def _load_dotenv() -> Path: + """ + Look for a .env file in the current working directory or + its parent directories. + + The directory containing the .env file becomes the application + working directory, if no ``UCAST_WORKDIR`` environment variable + is present. + + :return: Application working directory + """ + dotenv_path = dotenv.find_dotenv() + default_workdir = Path().resolve() + + if dotenv_path: + dotenv.load_dotenv(dotenv_path) + print(f"Loaded config from envfile at {dotenv_path}") + default_workdir = Path(dotenv_path).resolve().parent + + os.chdir(default_workdir) + + env_workdir = get_env("WORKDIR") + if env_workdir: + env_workdir_path = Path(env_workdir).resolve() + os.makedirs(env_workdir_path, exist_ok=True) + os.chdir(env_workdir_path) + return env_workdir_path + + return default_workdir + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = _load_dotenv() # 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" +# generate with openssl rand -base64 64 +SECRET_KEY = get_env( + "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 +DEBUG = get_env("DEBUG", False) ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - "yt2podcast.apps.Yt2PodcastConfig", + "ucast.apps.UcastConfig", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "bulma", ] MIDDLEWARE = [ @@ -48,7 +98,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "ucast.urls" +ROOT_URLCONF = "ucast_project.urls" TEMPLATES = [ { @@ -66,16 +116,40 @@ TEMPLATES = [ }, ] -WSGI_APPLICATION = "ucast.wsgi.application" +WSGI_APPLICATION = "ucast_project.wsgi.application" + + +def _get_db_config() -> dict: + db_name = get_env("DB_NAME", "db") + db_engine = get_env("DB_ENGINE", "sqlite") + + if db_engine == "sqlite": + return { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / f"{db_name}.sqlite", + } + + db_port = get_env("DB_PORT") + if not db_port: + if db_engine == "postgresql": + db_port = "5432" + elif db_engine == "mysql": + db_port = "3306" + + return { + "ENGINE": f"django.db.backends.{db_engine}", + "NAME": db_name, + "USER": get_env("DB_USER"), + "PASSWORD": get_env("DB_PASSWORD"), + "HOST": get_env("DB_HOST", "127.0.0.1"), + "PORT": db_port, + } + # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases - DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", - } + "default": _get_db_config(), } # Password validation @@ -103,17 +177,18 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" -USE_I18N = True +USE_I18N = False -USE_TZ = True +USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ STATIC_URL = "static/" -STATIC_ROOT = BASE_DIR / "static" +STATIC_ROOT = get_env_path("STATIC_ROOT", BASE_DIR / "static") +DOWNLOAD_ROOT = get_env_path("DOWNLOAD_ROOT", BASE_DIR / "data") -DATA_ROOT = BASE_DIR / "data" +STATICFILES_DIRS = [resources.path("ucast", "static")] # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field diff --git a/ucast/urls.py b/ucast_project/urls.py similarity index 100% rename from ucast/urls.py rename to ucast_project/urls.py diff --git a/ucast/wsgi.py b/ucast_project/wsgi.py similarity index 81% rename from ucast/wsgi.py rename to ucast_project/wsgi.py index 06375f4..b8762e6 100644 --- a/ucast/wsgi.py +++ b/ucast_project/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ucast_project.settings") application = get_wsgi_application() diff --git a/yt2podcast/service/__init__.py b/yt2podcast/service/__init__.py deleted file mode 100644 index 9bad579..0000000 --- a/yt2podcast/service/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# coding=utf-8 diff --git a/yt2podcast/service/youtube.py b/yt2podcast/service/youtube.py deleted file mode 100644 index a880ef2..0000000 --- a/yt2podcast/service/youtube.py +++ /dev/null @@ -1,88 +0,0 @@ -# coding=utf-8 -import json -from dataclasses import dataclass -from operator import itemgetter - -import requests -from scrapetube import scrapetube -from yt_dlp import YoutubeDL - -from yt2podcast.service import util - - -def get_thumbnail_url(vinfo): - """Get the best quality thumbnail""" - return max(vinfo["thumbnails"], key=itemgetter("preference"))["url"] - - -def download_thumbnail(vinfo, download_path): - best_url = get_thumbnail_url(vinfo) - - try: - util.download_file(best_url, download_path) - except requests.exceptions.HTTPError: - default_url = vinfo["thumbnail"] - util.download_file(default_url, download_path) - - -def get_video_info(video_id): - with YoutubeDL() as ydl: - return ydl.extract_info(video_id, download=False) - - -def download_video(video_id, download_path, sponsorblock=False): - ydl_params = { - "format": "bestaudio", - "postprocessors": [ - {"key": "FFmpegExtractAudio", "preferredcodec": "mp3"}, - ], - "outtmpl": download_path, - } - - if sponsorblock: - # noinspection PyTypeChecker - 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 - return ydl.extract_info(video_id) - - -@dataclass -class ChannelMetadata: - id: str - name: str - description: str - avatar_url: str - - -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" - - url = f"{channel_url}/videos?view=0&flow=grid" - - html = scrapetube.get_initial_data(session, url) - data = json.loads( - scrapetube.get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}" - ) - metadata = data["metadata"]["channelMetadataRenderer"] - - 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/tests/__init__.py b/yt2podcast/tests/__init__.py deleted file mode 100644 index b383d20..0000000 --- a/yt2podcast/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# coding=utf-8 -from importlib.resources import files - -DIR_TESTFILES = files("yt2podcast.tests.testfiles") diff --git a/yt2podcast/tests/testfiles/cover/c1_blur.png b/yt2podcast/tests/testfiles/cover/c1_blur.png deleted file mode 100644 index 1ea1caf..0000000 Binary files a/yt2podcast/tests/testfiles/cover/c1_blur.png and /dev/null differ diff --git a/yt2podcast/tests/testfiles/cover/c2_blur.png b/yt2podcast/tests/testfiles/cover/c2_blur.png deleted file mode 100644 index 453d455..0000000 Binary files a/yt2podcast/tests/testfiles/cover/c2_blur.png and /dev/null differ diff --git a/yt2podcast/tests/testfiles/cover/c2_classic.png b/yt2podcast/tests/testfiles/cover/c2_classic.png deleted file mode 100644 index 3fb1cae..0000000 Binary files a/yt2podcast/tests/testfiles/cover/c2_classic.png and /dev/null differ diff --git a/yt2podcast/tests/testfiles/cover/c3_blur.png b/yt2podcast/tests/testfiles/cover/c3_blur.png deleted file mode 100644 index 24b24bd..0000000 Binary files a/yt2podcast/tests/testfiles/cover/c3_blur.png and /dev/null differ diff --git a/yt2podcast/tests/testfiles/sources.md b/yt2podcast/tests/testfiles/sources.md deleted file mode 100644 index 2456116..0000000 --- a/yt2podcast/tests/testfiles/sources.md +++ /dev/null @@ -1,5 +0,0 @@ -### Quellen der Thumbnails/Avatarbilder zum Testen - -- a1/t1: [ThetaDev](https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q) (CC-BY) -- a2/t2: [Blender](https://www.youtube.com/c/BlenderFoundation) (CC-BY) -- a3/t3: [media.ccc.de](https://www.youtube.com/channel/UC2TXq_t06Hjdr2g_KdKpHQg) (CC-BY) diff --git a/yt2podcast/tests/testfiles/thumbnail/t2.webp b/yt2podcast/tests/testfiles/thumbnail/t2.webp deleted file mode 100644 index b54d902..0000000 Binary files a/yt2podcast/tests/testfiles/thumbnail/t2.webp and /dev/null differ