Compare commits

...

7 commits

Author SHA1 Message Date
e7b3149962 add tests for youtube, util modules
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-11 02:24:17 +02:00
52aa14d134 add task documentation 2022-05-10 19:01:14 +02:00
c18bf3837e load settings from environment, add bulma 2022-05-10 18:23:48 +02:00
fb0e2ef158 renamed django project 2022-05-10 13:21:48 +02:00
bf84fc7c78 add VideoInfo object 2022-05-10 13:01:54 +02:00
5dcbfa638b add handling for empty thumbnails 2022-05-10 02:34:47 +02:00
d3ad3d012e model: make downloaded date nullable 2022-05-09 22:30:28 +02:00
63 changed files with 1800 additions and 185 deletions

View file

@ -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

2
.env Normal file
View file

@ -0,0 +1,2 @@
UCAST_DEBUG=True
UCAST_WORKDIR=_run

7
.gitignore vendored
View file

@ -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

View file

@ -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 <taskname>`
`test` Unittests ausführen
`lint` Codequalität/Formatierung überprüfen
`format` Code mit black formatieren
`makemigrations` Datenbankmigration erstellen
`get-cover --vid <YouTube-Video-ID>` YouTube-Thumbnail herunterladen
und Coverbilder zum Testen erzeugen (werden unter `ucast/tests/testfiles` abgelegt)
### Tasks (Node.js)
Ausführen: `npm run <taskname>`
`start` Sass-Stylesheets automatisch bei Änderungen kompilieren
`build` Sass-Stylesheets kompilieren und optimieren

78
assets/icons/logo.svg Normal file
View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="68.5mm"
height="15.79044mm"
viewBox="0 0 68.5 15.79044"
version="1.1"
id="svg5"
sodipodi:docname="logo.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
lock-margins="true"
fit-margin-top="2"
fit-margin-left="2"
fit-margin-right="2"
fit-margin-bottom="2"
inkscape:zoom="9.7594058"
inkscape:cx="189.30456"
inkscape:cy="45.853201"
inkscape:window-width="2516"
inkscape:window-height="1051"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid1338"
originx="-1.4687504"
originy="-18.459564" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-1.4687503,-18.45956)">
<path
style="fill:none;stroke:#282828;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 67.468749,21.166667 H 56.885416"
id="path3041" />
<path
style="fill:none;stroke:#282828;stroke-width:0.98677;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 62.177083,21.444868 V 31.75"
id="path3043" />
<path
style="fill:none;stroke:#ee0000;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 3.9687503,21.166667 V 27.78125 L 7.9375002,31.75 h 2.6458328 l 3.96875,-3.96875 v -6.614583"
id="path3572" />
<path
style="fill:none;stroke:#282828;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 27.781251,21.166667 h -6.614584 l -3.96875,3.96875 v 2.645833 l 3.96875,3.96875 h 6.614584"
id="path3687" />
<path
style="fill:none;stroke:#282828;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 30.427084,31.75 v -5.291667 l 5.291667,-5.291666 5.291666,5.291666 V 31.75 v 0"
id="path3802" />
<path
style="fill:none;stroke:#282828;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 54.239583,21.166667 h -7.9375 l -2.645834,2.645833 2.645834,2.645833 h 5.291666 L 54.239583,29.104166 51.593749,31.75 h -7.9375"
id="path3954" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="68.5mm"
height="15.79044mm"
viewBox="0 0 68.5 15.79044"
version="1.1"
id="svg5"
sodipodi:docname="logo_border.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
lock-margins="true"
fit-margin-top="2"
fit-margin-left="2"
fit-margin-right="2"
fit-margin-bottom="2"
inkscape:zoom="0.89824763"
inkscape:cx="-394.65732"
inkscape:cy="200.94681"
inkscape:window-width="2516"
inkscape:window-height="1051"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer2">
<inkscape:grid
type="xygrid"
id="grid1338"
originx="-1.4687504"
originy="-18.459564" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Border">
<path
style="color:#000000;fill:#282828;stroke:#ffffff;stroke-linecap:square;stroke-opacity:1;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 54.916016,2.206456 v 1 h 0.5 10.583981 0.5 v -1 h -0.5 -10.583981 z"
id="path13414" />
<path
style="color:#000000;fill:#282828;stroke:#ffffff;stroke-width:0.494467;stroke-linecap:square;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 60.208984,2.461412 V 2.9504073 13.301445 13.79044 h 1 V 13.301445 2.9504073 2.461412 Z"
id="path13417" />
<path
style="color:#000000;fill:#ee0000;stroke:#ffffff;stroke-linecap:square;stroke-opacity:1;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 1.9999997,2.206456 v 0.5 6.822265 l 4.261719,4.261719 h 3.060547 L 13.583984,9.528721 v -6.822265 -0.5 h -1 v 0.5 6.408203 L 8.9082027,12.79044 H 6.6757807 L 2.9999997,9.114659 v -6.408203 -0.5 z"
id="path13420" />
<path
style="color:#000000;fill:#282828;stroke:#ffffff;stroke-linecap:square;stroke-opacity:1;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 19.490234,2.206456 -4.261718,4.261718 v 3.060547 l 4.261718,4.261719 h 6.822266 0.5 v -1 h -0.5 -6.408203 L 16.228516,9.114659 V 6.882237 l 3.675781,-3.675781 h 6.408203 0.5 v -1 h -0.5 z"
id="path13423" />
<path
style="color:#000000;fill:#282828;stroke:#ffffff;stroke-linecap:square;stroke-opacity:1;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 34.25,1.999424 -5.791016,5.792969 v 5.498047 0.5 h 1 v -0.5 -5.083984 L 34.25,3.413487 39.041016,8.206456 v 5.083984 0.5 h 1 v -0.5 -5.498047 z"
id="path13426" />
<path
style="color:#000000;fill:#282828;stroke:#ffffff;stroke-linecap:square;stroke-opacity:1;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 44.626953,2.206456 -3.146484,3.146484 3.146484,3.146484 h 5.291016 l 2.146484,2.144532 -2.146484,2.146484 h -7.730469 -0.5 v 1 h 0.5 8.144531 L 53.478516,10.643956 50.332031,7.499424 H 45.041016 L 42.894531,5.35294 45.041016,3.206456 h 7.730468 0.5 v -1 h -0.5 z"
id="path13429" />
</g>
<g
inkscape:label="Main"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-1.4687503,-18.45956)">
<path
style="color:#000000;fill:#282828;stroke-linecap:square;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 56.384766,20.666016 v 1 h 0.5 10.583984 0.5 v -1 h -0.5 -10.583984 z"
id="path3041" />
<path
style="color:#000000;fill:#282828;stroke-width:0.489566;stroke-linecap:square;stroke-miterlimit:4;stroke-dasharray:none"
d="M 61.677734,21.144438 V 21.623788 31.77065 32.25 h 1 v -0.47935 -10.146862 -0.47935 z"
id="path3043" />
<path
style="color:#000000;fill:#ee0000;stroke-linecap:square;stroke:none;stroke-opacity:1;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 3.46875,20.666016 v 0.5 6.822265 L 7.7304687,32.25 h 3.0605473 l 4.261718,-4.261719 v -6.822265 -0.5 h -1 v 0.5 6.408203 L 10.376953,31.25 H 8.1445313 L 4.46875,27.574219 v -6.408203 -0.5 z"
id="path3572" />
<path
style="color:#000000;fill:#282828;stroke-linecap:square;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 20.958984,20.666016 -4.261718,4.261718 v 3.060547 L 20.958984,32.25 h 6.822266 0.5 v -1 h -0.5 -6.408203 l -3.675781,-3.675781 v -2.232422 l 3.675781,-3.675781 h 6.408203 0.5 v -1 h -0.5 z"
id="path3687" />
<path
style="color:#000000;fill:#282828;stroke-linecap:square;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 35.71875,20.458984 -5.791016,5.792969 V 31.75 v 0.5 h 1 v -0.5 -5.083984 l 4.791016,-4.792969 4.791016,4.792969 V 31.75 v 0.5 h 1 v -0.5 -5.498047 z"
id="path3802" />
<path
style="color:#000000;fill:#282828;stroke-linecap:square;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
d="m 46.095703,20.666016 -3.146484,3.146484 3.146484,3.146484 h 5.291016 L 53.533203,29.103516 51.386719,31.25 h -7.730469 -0.5 v 1 h 0.5 8.144531 l 3.146485,-3.146484 -3.146485,-3.144532 H 46.509766 L 44.363281,23.8125 46.509766,21.666016 h 7.730468 0.5 v -1 h -0.5 z"
id="path3954" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="68.5mm"
height="15.79044mm"
viewBox="0 0 68.5 15.79044"
version="1.1"
id="svg5"
sodipodi:docname="logo_dark.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
lock-margins="true"
fit-margin-top="2"
fit-margin-left="2"
fit-margin-right="2"
fit-margin-bottom="2"
inkscape:zoom="0.34602189"
inkscape:cx="-85.254723"
inkscape:cy="-293.33405"
inkscape:window-width="2516"
inkscape:window-height="1051"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid1338"
originx="-1.4687504"
originy="-18.459564" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-1.4687503,-18.45956)">
<path
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 67.468749,21.166667 H 56.885416"
id="path3041" />
<path
style="fill:none;stroke:#ffffff;stroke-width:0.98677;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 62.177083,21.444868 V 31.75"
id="path3043" />
<path
style="fill:none;stroke:#ee0000;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 3.9687503,21.166667 V 27.78125 L 7.9375002,31.75 h 2.6458328 l 3.96875,-3.96875 v -6.614583"
id="path3572" />
<path
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 27.781251,21.166667 h -6.614584 l -3.96875,3.96875 v 2.645833 l 3.96875,3.96875 h 6.614584"
id="path3687" />
<path
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 30.427084,31.75 v -5.291667 l 5.291667,-5.291666 5.291666,5.291666 V 31.75 v 0"
id="path3802" />
<path
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 54.239583,21.166667 h -7.9375 l -2.645834,2.645833 2.645834,2.645833 h 5.291666 L 54.239583,29.104166 51.593749,31.75 h -7.9375"
id="path3954" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

43
assets/icons/yt_icon.svg Normal file
View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
enable-background="new 0 0 512 512"
id="Layer_1"
version="1.1"
viewBox="0 0 508.15335 357.10535"
xml:space="preserve"
sodipodi:docname="YOUTUBE_icon-icons.com_65487.svg"
width="508.15335"
height="357.10535"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs9" /><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="1.2304688"
inkscape:cx="252.34286"
inkscape:cy="163.75873"
inkscape:window-width="2516"
inkscape:window-height="1051"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" /><g
id="g4"
style="fill:#ffffff"
transform="translate(-3.0903642,-91.894664)"><path
d="M 260.4,449 C 203.3,447.2 149,445.8 94.7,443.7 83,443.2 71.1,441.4 59.7,438.7 38.3,433.7 23.5,420.8 15.9,399.7 9.8,382.7 7.6,365.2 6,347.4 2.5,305.6 2.5,263.8 4.2,222 c 1,-23.6 1.6,-47.4 7.9,-70.3 3.8,-13.7 8.4,-27.1 19.5,-37 11.7,-10.5 25.4,-16.8 41,-17.5 42.8,-2.1 85.5,-4.7 128.3,-5.1 57.6,-0.6 115.3,0.2 172.9,1.3 24.9,0.5 50,1.8 74.7,5 22.6,3 39.5,15.6 48.5,37.6 6.9,16.9 9.5,34.6 11,52.6 3.9,45.1 4,90.2 1.8,135.3 -1.1,22.9 -2.2,45.9 -8.7,68.2 -7.4,25.6 -23.1,42.5 -49.3,48.3 -10.2,2.2 -20.8,3 -31.2,3.4 -54.4,1.9 -108.7,3.6 -160.2,5.2 z M 205.1,335.3 c 45.6,-23.6 90.7,-47 136.7,-70.9 -45.9,-24 -91,-47.5 -136.7,-71.4 0,47.7 0,94.6 0,142.3 z"
id="path2"
style="fill:#ffffff" /></g></svg>

After

Width:  |  Height:  |  Size: 2 KiB

9
assets/sass/style.sass Normal file
View file

@ -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

View file

@ -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;

View file

@ -1 +1 @@
ucast/manage.py
ucast_project/manage.py

29
package.json Normal file
View file

@ -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"
}
}

646
pnpm-lock.yaml Normal file
View file

@ -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

68
poetry.lock generated
View file

@ -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"},

View file

@ -5,7 +5,7 @@ description = "YouTube to Podcast converter"
authors = ["Theta-Dev <t.testboy@gmail.com>"]
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"]

View file

@ -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"
)

View file

@ -1 +0,0 @@
# coding=utf-8

View file

@ -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"

View file

@ -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()),
],
),

View file

@ -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()

BIN
ucast/resources/yt_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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,
):
"""
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)
if thumbnail:
cvr = _create_cover_image(thumbnail, avatar, title, channel, style)
else:
cvr = _create_blank_cover_image(avatar, title, channel)
cvr.save(cover_path)

View file

@ -1,4 +1,3 @@
# coding=utf-8
from typing import Tuple
Color = Tuple[int, int, int]

View file

@ -1,4 +1,3 @@
# coding=utf-8
import requests

145
ucast/service/youtube.py Normal file
View file

@ -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)

3
ucast/tests/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from importlib.resources import files
DIR_TESTFILES = files("ucast.tests.testfiles")

View file

@ -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

24
ucast/tests/test_util.py Normal file
View file

@ -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

129
ucast/tests/test_youtube.py Normal file
View file

@ -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

View file

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 186 KiB

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View file

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View file

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View file

@ -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)

View file

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -1 +0,0 @@
# coding=utf-8

View file

@ -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)

View file

@ -1,4 +0,0 @@
# coding=utf-8
from importlib.resources import files
DIR_TESTFILES = files("yt2podcast.tests.testfiles")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

View file

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB