Compare commits
9 commits
Author | SHA1 | Date | |
---|---|---|---|
89a190ad4a | |||
11c679975b | |||
3eacc0ad8d | |||
27eeac66f0 | |||
ebe4ccf926 | |||
2fc63c0cb1 | |||
824ba9e101 | |||
a4cb344091 | |||
e131574393 |
|
@ -1,12 +0,0 @@
|
|||
[bumpversion]
|
||||
current_version = 0.4.6
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:ucast/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
|
@ -1,23 +0,0 @@
|
|||
.idea
|
||||
|
||||
# Python
|
||||
venv
|
||||
dist
|
||||
.tox
|
||||
__pycache__
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
|
||||
# JS
|
||||
node_modules
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Application data
|
||||
/.env
|
||||
/_run*
|
||||
*.sqlite3
|
||||
|
||||
assets
|
||||
notes
|
62
.drone.yml
|
@ -1,62 +0,0 @@
|
|||
kind: pipeline
|
||||
name: default
|
||||
type: docker
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: ''
|
||||
|
||||
steps:
|
||||
- name: install dependencies
|
||||
image: thetadev256/ucast-dev
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /root/.cache
|
||||
commands:
|
||||
- poetry install
|
||||
- poetry run invoke reset
|
||||
|
||||
- name: lint
|
||||
image: thetadev256/ucast-dev
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /root/.cache
|
||||
commands:
|
||||
- poetry run invoke lint
|
||||
depends_on:
|
||||
- install dependencies
|
||||
|
||||
- name: test
|
||||
image: thetadev256/ucast-dev
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /root/.cache
|
||||
commands:
|
||||
- poetry run invoke test
|
||||
depends_on:
|
||||
- install dependencies
|
||||
|
||||
# - name: build container
|
||||
# image: quay.io/buildah/stable
|
||||
# when:
|
||||
# event:
|
||||
# - tag
|
||||
# commands:
|
||||
# - buildah login -u $DOCKER_USER -p $DOCKER_PASS -- $DOCKER_REGISTRY
|
||||
# - buildah manifest create ucast
|
||||
# - buildah bud --tag code.thetadev.de/hsa/ucast:latest --manifest ucast --arch amd64 --build-arg TARGETPLATFORM=linux/amd64 -f deploy/Dockerfile .
|
||||
# - buildah bud --tag code.thetadev.de/hsa/ucast:latest --manifest ucast --arch arm64 --build-arg TARGETPLATFORM=linux/arm64 -f deploy/Dockerfile .
|
||||
# - buildah manifest push --all ucast docker://code.thetadev.de/hsa/ucast:latest
|
||||
# environment:
|
||||
# DOCKER_REGISTRY:
|
||||
# from_secret: docker_registry
|
||||
# DOCKER_USER:
|
||||
# from_secret: docker_username
|
||||
# DOCKER_PASS:
|
||||
# from_secret: docker_password
|
||||
# depends_on:
|
||||
# - test
|
||||
|
||||
volumes:
|
||||
- name: cache
|
||||
temp: { }
|
|
@ -1,14 +0,0 @@
|
|||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 88
|
||||
|
||||
[{Makefile,*.go}]
|
||||
indent_style = tab
|
||||
|
||||
[*.{json,md,rst,ini,yml,yaml,html,js,jsx,ts,tsx,vue}]
|
||||
indent_size = 2
|
|
@ -1,3 +0,0 @@
|
|||
UCAST_DEBUG=True
|
||||
UCAST_WORKDIR=_run
|
||||
UCAST_ALLOWED_HOSTS=localhost,127.0.0.1
|
14
.gitignore
vendored
|
@ -2,19 +2,19 @@
|
|||
|
||||
# Python
|
||||
venv
|
||||
dist
|
||||
.tox
|
||||
__pycache__
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
|
||||
# JS
|
||||
node_modules
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Media files
|
||||
*.webm
|
||||
*.mp4
|
||||
*.mp3
|
||||
|
||||
# Application data
|
||||
/.env
|
||||
/_run*
|
||||
*.sqlite3
|
||||
/_run
|
||||
.env
|
||||
|
|
33
README.md
|
@ -14,9 +14,11 @@ abrufen kann.
|
|||
|
||||
## Technik
|
||||
|
||||
Der Server sollte mit dem Webframework [Django](https://djangoproject.com/)
|
||||
Der Server sollte mit dem Webframework [Flask](https://flask.palletsprojects.com/)
|
||||
realisiert werden.
|
||||
|
||||
Daten sollten entweder in einer SQLite-Datenbank oder in JSON-Dateien abgelegt werden.
|
||||
|
||||
Die Weboberfläche wird mit Jinja-Templates gerendert, auf ein JS-Framework kann vorerst verzichtet werden.
|
||||
Für ein ansehnliches Ansehen sorgt Bootstrap.
|
||||
|
||||
|
@ -24,30 +26,5 @@ Für ein ansehnliches Ansehen sorgt Bootstrap.
|
|||
|
||||
### Project aufsetzen
|
||||
|
||||
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
|
||||
1. Python3 + [Poetry](https://python-poetry.org/) dependency manager installieren
|
||||
2. Dependencies mit ``poetry install`` installieren
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="12.084mm" height="12.084mm" version="1.1" viewBox="0 0 12.084 12.084" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-1.75 -1.9565)"><path d="m2 2.2065v7.3223l4.2617 4.2617h3.0605l4.2617-4.2617v-7.3223h-1v6.9082l-3.6758 3.6758h-2.2324l-3.6758-3.6758v-6.9082z" color="#000000" fill="#e00" stroke="#fff" stroke-linecap="square" stroke-width=".5"/></g><g transform="translate(-3.2188 -20.416)"><path d="m3.4688 20.666v7.3223l4.2617 4.2617h3.0605l4.2617-4.2617v-7.3223h-1v6.9082l-3.6758 3.6758h-2.2324l-3.6758-3.6758v-6.9082z" color="#000000" fill="#e00"/></g></svg>
|
Before Width: | Height: | Size: 626 B |
|
@ -1,78 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 3.1 KiB |
|
@ -1,107 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 5.7 KiB |
|
@ -1,78 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 3.2 KiB |
|
@ -1,43 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 2 KiB |
|
@ -1,48 +0,0 @@
|
|||
@import "../../node_modules/bulma/sass/utilities/initial-variables"
|
||||
@import "../../node_modules/bulma/bulma"
|
||||
|
||||
.channel-icon
|
||||
max-height: 64px
|
||||
|
||||
.video-thumbnail
|
||||
width: 100%
|
||||
|
||||
.video-grid
|
||||
$spacing: 0.5vw
|
||||
|
||||
display: grid
|
||||
grid-row-gap: $spacing
|
||||
row-gap: $spacing
|
||||
grid-column-gap: $spacing
|
||||
column-gap: $spacing
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr))
|
||||
grid-column: auto
|
||||
|
||||
@include tablet
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr))
|
||||
|
||||
@include desktop
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr))
|
||||
|
||||
@include widescreen
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr))
|
||||
|
||||
@include fullhd
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr))
|
||||
|
||||
.video-card
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.video-card-content
|
||||
padding: 0 0.5vw
|
||||
|
||||
&:last-child
|
||||
padding-bottom: 0.5vw
|
||||
|
||||
// Fix almost invisible navbar items on mobile
|
||||
.navbar-item
|
||||
color: #fff
|
||||
|
||||
.overflow-x
|
||||
overflow-x: auto
|
|
@ -1,32 +0,0 @@
|
|||
# This has to be built with docker buildx to set the TARGETPLATFORM argument
|
||||
FROM registry.hub.docker.com/library/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;
|
|
@ -1,48 +0,0 @@
|
|||
FROM registry.hub.docker.com/thetadev256/ucast-dev
|
||||
|
||||
COPY . /build
|
||||
WORKDIR /build
|
||||
|
||||
RUN poetry build -f wheel
|
||||
|
||||
FROM registry.hub.docker.com/library/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;
|
||||
|
||||
# nginx
|
||||
RUN apt-get update && \
|
||||
apt-get install -y nginx && \
|
||||
apt-get clean && \
|
||||
mkdir /ucast && \
|
||||
chown 1000:1000 /ucast && \
|
||||
chown -R 1000:1000 /var/lib/nginx /var/log/nginx
|
||||
|
||||
COPY ./deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY ./deploy/nginx /etc/nginx/conf.d
|
||||
COPY ./deploy/entrypoint.py /entrypoint.py
|
||||
|
||||
COPY --from=0 /build/dist /install
|
||||
RUN pip install -- /install/*.whl gunicorn honcho && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
ENV UCAST_WORKDIR=/ucast
|
||||
|
||||
EXPOSE 8001
|
||||
ENTRYPOINT /entrypoint.py
|
|
@ -1,44 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
# Source: https://danmanners.com/posts/2022-01-buildah-multi-arch/
|
||||
|
||||
# Set your manifest name
|
||||
export MANIFEST_NAME="ucast"
|
||||
|
||||
# Set the required variables
|
||||
export BUILD_PATH="."
|
||||
export DOCKERFILE="deploy/Dockerfile"
|
||||
export REGISTRY="registry.hub.docker.com"
|
||||
export USER="thetadev256"
|
||||
export IMAGE_NAME="ucast"
|
||||
export IMAGE_TAG="v0.3.2"
|
||||
|
||||
# Create a multi-architecture manifest
|
||||
buildah manifest create ${MANIFEST_NAME}
|
||||
|
||||
# Build your amd64 architecture container
|
||||
buildah bud \
|
||||
--tag "${REGISTRY}/${USER}/${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
--manifest ${MANIFEST_NAME} \
|
||||
--arch amd64 \
|
||||
--build-arg TARGETPLATFORM=linux/amd64 \
|
||||
-f ${DOCKERFILE} \
|
||||
${BUILD_PATH}
|
||||
|
||||
# Build your arm64 architecture container
|
||||
buildah bud \
|
||||
--tag "${REGISTRY}/${USER}/${IMAGE_NAME}:${IMAGE_TAG}" \
|
||||
--manifest ${MANIFEST_NAME} \
|
||||
--arch arm64 \
|
||||
--build-arg TARGETPLATFORM=linux/arm64 \
|
||||
-f ${DOCKERFILE} \
|
||||
${BUILD_PATH}
|
||||
|
||||
# Push the full manifest, with both CPU Architectures
|
||||
buildah manifest push --all \
|
||||
${MANIFEST_NAME} \
|
||||
"docker://${REGISTRY}/${USER}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
buildah manifest push --all \
|
||||
${MANIFEST_NAME} \
|
||||
"docker://${REGISTRY}/${USER}/${IMAGE_NAME}"
|
|
@ -1,21 +1,7 @@
|
|||
version: "3"
|
||||
services:
|
||||
ucast:
|
||||
image: thetadev256/ucast
|
||||
user: 1000:1000
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
- "../_run:/ucast"
|
||||
environment:
|
||||
UCAST_REDIS_URL: "redis://redis:6379"
|
||||
UCAST_SECRET_KEY: "django-insecure-Es/+plApGxNBy8+ewB+74zMlmfV2H3whw6gu7i0ESwGrEWAUYRP3HM2EX0PLr3UJ"
|
||||
UCAST_ALLOWED_HOSTS: ".localhost,127.0.0.1"
|
||||
UCAST_N_WORKERS: 2
|
||||
UCAST_TZ: "Europe/Berlin"
|
||||
|
||||
redis:
|
||||
container_name: redis
|
||||
container_name: ucast-redis
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
version: "3"
|
||||
services:
|
||||
redis:
|
||||
container_name: redis
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
|
||||
nginx:
|
||||
image: nginx:1
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- "./nginx:/etc/nginx/conf.d:ro"
|
||||
- "../_run:/ucast:ro"
|
|
@ -1,30 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from honcho import manager
|
||||
|
||||
|
||||
def run_cmd(cmd):
|
||||
returncode = subprocess.call(cmd)
|
||||
if returncode != 0:
|
||||
sys.exit(returncode)
|
||||
|
||||
|
||||
n_workers = int(os.environ.get("UCAST_N_WORKERS", "1"))
|
||||
|
||||
run_cmd(["ucast-manage", "collectstatic", "--noinput"])
|
||||
run_cmd(["ucast-manage", "migrate"])
|
||||
|
||||
m = manager.Manager()
|
||||
m.add_process("ucast", "gunicorn ucast_project.wsgi")
|
||||
m.add_process("nginx", "nginx")
|
||||
|
||||
for i in range(n_workers):
|
||||
m.add_process(f"worker_{i}", "ucast-manage rqworker")
|
||||
|
||||
m.add_process("scheduler", "ucast-manage rqscheduler")
|
||||
|
||||
m.loop()
|
||||
sys.exit(m.returncode)
|
|
@ -1,61 +0,0 @@
|
|||
worker_processes auto;
|
||||
daemon off;
|
||||
pid /tmp/nginx.pid;
|
||||
include /etc/nginx/modules-enabled/*.conf;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
# multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
|
||||
##
|
||||
# Basic Settings
|
||||
##
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
types_hash_max_size 2048;
|
||||
# server_tokens off;
|
||||
|
||||
# server_names_hash_bucket_size 64;
|
||||
# server_name_in_redirect off;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
##
|
||||
# SSL Settings
|
||||
##
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
##
|
||||
# Logging Settings
|
||||
##
|
||||
|
||||
access_log off;
|
||||
error_log stderr;
|
||||
|
||||
##
|
||||
# Gzip Settings
|
||||
##
|
||||
|
||||
gzip on;
|
||||
|
||||
# gzip_vary on;
|
||||
# gzip_proxied any;
|
||||
# gzip_comp_level 6;
|
||||
# gzip_buffers 16 8k;
|
||||
# gzip_http_version 1.1;
|
||||
# gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
##
|
||||
# Virtual Host Configs
|
||||
##
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
server {
|
||||
listen 8001;
|
||||
server_name localhost;
|
||||
|
||||
client_max_body_size 1M;
|
||||
|
||||
# serve media files
|
||||
location /static/ {
|
||||
alias /ucast/static/;
|
||||
}
|
||||
|
||||
location /internal_files/ {
|
||||
internal;
|
||||
alias /ucast/data/;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
}
|
||||
|
||||
# location /errors/ {
|
||||
# alias /etc/nginx/conf.d/errorpages/;
|
||||
# internal;
|
||||
# }
|
||||
}
|
3
docs/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
|||
/.tox
|
||||
/build
|
||||
/venv
|
|
@ -1,20 +0,0 @@
|
|||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
@ -1,108 +0,0 @@
|
|||
\usepackage[absolute]{textpos}
|
||||
\usepackage{setspace}
|
||||
|
||||
\newcommand{\hsamaketitle}{%
|
||||
\let\sphinxrestorepageanchorsetting\relax
|
||||
\ifHy@pageanchor\def\sphinxrestorepageanchorsetting{\Hy@pageanchortrue}\fi
|
||||
\hypersetup{pdfauthor={\@author},
|
||||
pdftitle={\@title},
|
||||
pdfsubject={\subtitle},
|
||||
pdfkeywords={Forschung, Entwicklung, Informatik},
|
||||
}
|
||||
\hypersetup{pageanchor=false}% avoid duplicate destination warnings
|
||||
\begin{titlepage}
|
||||
% Deckblatt - Hochschule Augsburg
|
||||
\thispagestyle{empty}\null
|
||||
% Logo - Hochschule Augsburg - Informatik
|
||||
\begin{textblock}{10}(8.0,1.1)
|
||||
\begin{figure}[h]
|
||||
\centering
|
||||
\includegraphics[width=0.45\textwidth]{hsa_informatik_logo_lq.pdf}
|
||||
\end{figure}
|
||||
|
||||
\end{textblock}
|
||||
|
||||
% Text unter Logo
|
||||
\begin{textblock}{15}(12.43,2.4)
|
||||
\LARGE
|
||||
\textsf{
|
||||
\textbf{\textcolor[rgb]{1,0.41,0.13}{\\
|
||||
\begin{flushleft}
|
||||
Fakultät für\\
|
||||
Informatik\\
|
||||
\end{flushleft}
|
||||
}
|
||||
}
|
||||
}
|
||||
\end{textblock}
|
||||
|
||||
% Textbox links - Informationen
|
||||
\begin{textblock}{15}(2,2)
|
||||
%\LARGE
|
||||
\begin{flushleft}
|
||||
\begin{spacing} {1.2}
|
||||
\huge
|
||||
\textbf{\@title}
|
||||
\vspace{30pt}
|
||||
\textcolor[rgb]{1,0.41,0.13}{\\
|
||||
\textbf{\subtitle}}\\
|
||||
\vspace{60pt}
|
||||
\LARGE
|
||||
Studienrichtung\\
|
||||
\hscourse\\
|
||||
\vspace{30pt}
|
||||
\@author\\
|
||||
\vspace{60pt}
|
||||
\LARGE
|
||||
Prüfer: \examiner\\
|
||||
\vspace{10pt}
|
||||
Abgabedatum: \deadline\\
|
||||
\end{spacing}
|
||||
\end{flushleft}
|
||||
|
||||
\end{textblock}
|
||||
|
||||
|
||||
|
||||
% Textbox rechts - Hochschule
|
||||
\begin{textblock}{5}(12.45,8.0)
|
||||
\textcolor[rgb]{1,0,0}{\\
|
||||
\footnotesize
|
||||
\begin{flushleft}
|
||||
\begin{spacing} {1.3}
|
||||
Hochschule f\"ur angewandte\\
|
||||
Wissenschaften Augsburg\\
|
||||
\vspace{4pt}
|
||||
An der Hochschule 1\\
|
||||
D-86161 Augsburg\\
|
||||
\vspace{4pt}
|
||||
Telefon +49 821 55 86-0\\
|
||||
Fax +49 821 55 86-3222\\
|
||||
www.hs-augsburg.de\\
|
||||
info(at)hs-augsburg-de
|
||||
\end{spacing}
|
||||
\end{flushleft}
|
||||
}
|
||||
\end{textblock}
|
||||
|
||||
|
||||
% Textbox rechts mitte - Fakultät
|
||||
\begin{textblock}{5}(12.45,11.4)
|
||||
\footnotesize
|
||||
\begin{flushleft}
|
||||
\begin{spacing} {1.3}
|
||||
Fakult\"at f\"ur Informatik\\
|
||||
Telefon +49 821 55 86-3450\\
|
||||
Fax \hspace{10pt} +49 821 55 86-3499\\
|
||||
\end{spacing}
|
||||
\end{flushleft}
|
||||
\end{textblock}
|
||||
\end{titlepage}%
|
||||
\setcounter{footnote}{0}%
|
||||
\let\thanks\relax\let\maketitle\relax
|
||||
%\gdef\@thanks{}\gdef\@author{}\gdef\@title{}
|
||||
\clearpage
|
||||
\ifdefined\sphinxbackoftitlepage\sphinxbackoftitlepage\fi
|
||||
\if@openright\cleardoublepage\else\clearpage\fi
|
||||
\sphinxrestorepageanchorsetting
|
||||
}
|
92
docs/conf.py
|
@ -1,92 +0,0 @@
|
|||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('../code'))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "Ucast"
|
||||
subtitle = "Projektarbeit Webtechnologien"
|
||||
author = "Thomas Hampp"
|
||||
copyright = "2022 " + author
|
||||
|
||||
examiner = "Fabian Ziegler"
|
||||
deadline = "09.07.2022"
|
||||
course = "Master Informatik"
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinxcontrib.cairosvgconverter",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = "de"
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = [".tox"]
|
||||
|
||||
# Pygments-Styling used for code syntax highlighting.
|
||||
# See this page for an overview of all styles including live demo:
|
||||
# https://pygments.org/demo/
|
||||
pygments_style = "vs"
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ["_static"]
|
||||
|
||||
# -- Options for PDF output -------------------------------------------------
|
||||
latex_engine = "xelatex"
|
||||
# latex_theme = 'hsathesis'
|
||||
latex_elements = {
|
||||
"extraclassoptions": "openany,oneside",
|
||||
"preamble": r"""
|
||||
\usepackage{hsastyle}
|
||||
|
||||
\newcommand\subtitle{%s}
|
||||
\newcommand\deadline{%s}
|
||||
\newcommand\examiner{%s}
|
||||
\newcommand\hscourse{%s}
|
||||
"""
|
||||
% (subtitle, deadline, examiner, course),
|
||||
"maketitle": r"\hsamaketitle",
|
||||
}
|
||||
|
||||
latex_additional_files = [
|
||||
"_latex/logos/hsa_informatik_logo_lq.pdf",
|
||||
"_latex/hsastyle.sty",
|
||||
]
|
|
@ -1,9 +0,0 @@
|
|||
Ucast
|
||||
#####
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Inhalt:
|
||||
:glob:
|
||||
|
||||
src/*
|
|
@ -1,4 +0,0 @@
|
|||
Sphinx==4.4.0
|
||||
sphinx-autobuild
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-svg2pdfconverter[CairoSVG]
|
|
@ -1,245 +0,0 @@
|
|||
Einleitung
|
||||
##########
|
||||
|
||||
Bei den meisten YouTube-Videos, die ich mir anschaue, handelt es sich um
|
||||
Nachrichten oder Kommentarvideos. Da diese Videos sehr textlastig sind,
|
||||
spiele ich sie oft im Hintergrund ab und arbeite währenddessen an meinen Projekten.
|
||||
|
||||
Unterwegs habe ich aber keine Möglichkeit, YouTube-Videos im Hintergrund
|
||||
abzuspielen, da die YouTube-App im Hintergrund die Wiedergabe unterbricht.
|
||||
Es ist zwar möglich, YouTube-Videos mit entsprechenden Webdiensten herunterzuladen,
|
||||
dies ist aber relativ unkomfortabel.
|
||||
|
||||
Deshalb höre ich unterwegs häufiger Podcasts, die mit entsprechenden Apps
|
||||
(ich benutze AntennaPod) sowohl gestreamt als auch offline aufs Handy geladen werden
|
||||
können.
|
||||
|
||||
Ich habe dann überlegt, ob es möglch wäre, YouTube-Kanäle automatisch in Podcasts
|
||||
umzuwandeln. So kam ich auf die Idee, einen Server zu entwickeln,
|
||||
der YouTube-Videos automatisch als MP3-Dateien herunterlädt und im Podcast-Format
|
||||
bereitstellt. Auf diese Weise kann man sich die Audioinhalte von YouTube sowohl
|
||||
am PC als auch unterwegs mit einer Podcast-App anhören.
|
||||
|
||||
Technik
|
||||
#######
|
||||
|
||||
Webframework
|
||||
************
|
||||
|
||||
Ich habe ucast mit dem Webframework Django entwickelt. Django hat den Vorteil,
|
||||
das es grundlegende Funktionen von Webanwendungen wie ein Login-System bereits
|
||||
implementiert hat. Dadurch konnte ich mich schneller auf die eigentlichen Features
|
||||
meiner Anwendung konzentrieren.
|
||||
|
||||
|
||||
YouTube-Downloading
|
||||
*******************
|
||||
|
||||
Zum Herunterladen von Videos wird die Python-Library
|
||||
`yt-dlp <https://github.com/yt-dlp/yt-dlp>`_ verwendet.
|
||||
Diese Library kann Videos von YouTube und diversen anderen Videoplattformen
|
||||
herunterladen und mithilfe von ffmpeg ins MP3-Format konvertieren.
|
||||
|
||||
Yt-dlp benötigt den Link oder die YouTube-ID eines Videos, um es herunterladen zu können.
|
||||
Deswegen wird zusätzlich eine Möglichkeit benötigt, die aktuellen Videos eines
|
||||
Kanals und dessen Metadaten (Profilbild, Beschreibung) abzurufen.
|
||||
|
||||
Hierfür gibt es zwei Möglichkeiten:
|
||||
erstens Scraping der YouTube-Webseite und zweitens YouTube's eigene RSS-Feeds.
|
||||
|
||||
YouTube stellt für jeden Kanal einen RSS-Feed unter der Adresse
|
||||
``https://www.youtube.com/feeds/videos.xml?channel_id=<Kanal-ID>`` bereit.
|
||||
Der Feed listet allerdings nur die letzten 15 Videos eines Kanals auf.
|
||||
Um ältere Videos sowie die Metadaten eines Kanals abrufen
|
||||
zu können, muss die YouTube-Webseite aufgerufen und geparsed werden. Hierfür habe ich
|
||||
die ``scrapetube``-Library als Grundlage verwendet und um eine Methode zum Abrufen
|
||||
von Kanalinformationen erweitert.
|
||||
|
||||
|
||||
Task-Queue
|
||||
**********
|
||||
|
||||
Ucast muss regelmäßig die abonnierten Kanäle abrufen und Videos herunterladen.
|
||||
Hier kommt eine `Task-Queue <https://python-rq.org>`_
|
||||
zum Einsatz. Die Webanwendung kann neue Tasks in die
|
||||
Queue einreihen, die dann im Hintergrund von Workern ausgeführt werden.
|
||||
Mit einem Scheduler ist es auch möglich, periodisch (bspw. alle 15 Minuten)
|
||||
Tasks auszuführen.
|
||||
|
||||
Die Queue benötigt eine Möglichkeit, Daten zwischen der Anwendung und den Workern
|
||||
auszutauschen. Hier kommt eine Redis-Datenbank zum Einsatz.
|
||||
|
||||
|
||||
Frontend
|
||||
********
|
||||
|
||||
Da Ucast keine komplexen Funktionen auf der Clientseite bereitstellen muss,
|
||||
wird das Frontend mithilfe von Django-Templates serverseitig gerendert und es
|
||||
wurde auf ein Frontend-Framework verzichtet. Als CSS-Framework habe ich Bulma
|
||||
verwendet, was eine Bibliothek von Komponenten bereitstellt. Bulma ist in Sass
|
||||
geschrieben, wodurch es einfach an ein gewünschtes Designsthema angepasst werden kann.
|
||||
|
||||
Komplett auf Javascript verzichtet habe ich jedoch nicht.
|
||||
Beispielsweise habe ich ``clipboard.js`` verwendet, um die Feed-URLs mit Klick auf einen
|
||||
Button kopieren zu können.
|
||||
|
||||
Das endlose Scrolling auf den Videoseiten habe ich mit ``htmx`` umgesetzt, einer
|
||||
JS-Library, mit der man dynamisch Webinhalte nachladen kann, ohne dafür eigenen
|
||||
JS-Code zu schreiben.
|
||||
|
||||
|
||||
Inbetriebnahme
|
||||
##############
|
||||
|
||||
Docker-Compose
|
||||
**************
|
||||
|
||||
Ucast ist als Docker-Image mit dem Namen
|
||||
`thetadev256/ucast <https://hub.docker.com/r/thetadev256/ucast>`_ verfügbar.
|
||||
Eine docker-compose-Datei mit einer Basiskonfiguration befindet sich im
|
||||
Projektordner unter ``deploy/docker-compose.yml``. Um Ucast zu starten, müssen
|
||||
die folgenden Befehle ausgeführt werden.
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
mkdir _run # Arbeitsverzeichnis erstellen
|
||||
docker-compose -f deploy/docker-compose.yml up -d # Anwendung starten
|
||||
docker exec -it ucast-ucast-1 ucast-manage createsuperuser # Benutzerkonto anlegen
|
||||
|
||||
Die Weboberfläche ist unter http://127.0.0.1:8001 erreichbar.
|
||||
|
||||
Konfiguration
|
||||
*************
|
||||
|
||||
Die Konfiguration erfolgt durch Umgebungsvariablen. Alle Umgebungsvariablen
|
||||
sind mit dem Präfix ``UCAST_`` zu versehen (z.B. ``UCAST_DEBUG``).
|
||||
|
||||
**DEBUG**
|
||||
`Debug-Modus <https://docs.djangoproject.com/en/4.0/ref/settings/#debug>`_ von Django aktivieren.
|
||||
Standard: ``false``
|
||||
|
||||
**ALLOWED_HOSTS**
|
||||
Erlaubte `Hosts/Domains <https://docs.djangoproject.com/en/4.0/ref/settings/#allowed-hosts>`_.
|
||||
Beispiel: ``"ucast.thetadev.de"``
|
||||
|
||||
**DB_ENGINE**
|
||||
Verwendete Datenbanksoftware (``sqlite`` / ``mysql`` / ``postgresql``).
|
||||
Standard: ``sqlite``
|
||||
|
||||
**DB_NAME**
|
||||
Name der Datenbank. Standard: ``db``
|
||||
|
||||
**DB_HOST**
|
||||
Adresse der Datenbank. Standard: ``127.0.0.1``
|
||||
|
||||
**DB_PORT**
|
||||
Port der Datenbank. Standard: 3306 (mysql), 5432 (postgresql)
|
||||
|
||||
**DB_USER**, **DB_PASS**
|
||||
Benutzername/Passwort für die Datenbank
|
||||
|
||||
**WORKDIR**
|
||||
Hauptverzeichnis für Ucast (Siehe Verzeichnisstruktur).
|
||||
Standard: aktuelles Arbeitsverzeichnis
|
||||
|
||||
**STATIC_ROOT**
|
||||
Ordner für statische Dateien (``WORKDIR/static``)
|
||||
|
||||
**DOWNLOAD_ROOT**
|
||||
Ordner für heruntergeladene Bilder und Audiodateien (``WORKDIR/data``)
|
||||
|
||||
**CACHE_ROOT**
|
||||
Ordner für temporäre Dateien (``{WORKDIR}/cache``)
|
||||
|
||||
**DB_DIR**
|
||||
Ordner für die SQLite-Datenbankdatei (``{WORKDIR}/db``)
|
||||
|
||||
**TZ**
|
||||
Zeitzone. Standard: Systemeinstellung
|
||||
|
||||
**REDIS_URL**
|
||||
Redis-Addresse. Standard: ``redis://localhost:6379``
|
||||
|
||||
**REDIS_QUEUE_TIMEOUT**
|
||||
Timeout für gestartete Jobs [s]. Standard: 600
|
||||
|
||||
**REDIS_QUEUE_RESULT_TTL**
|
||||
Speicherdauer für abgeschlossene Tasks [s]. Standard: 600
|
||||
|
||||
**YT_UPDATE_INTERVAL**
|
||||
Zeitabstand, in dem die YouTube-Kanäle abgerufen werden [s].
|
||||
Standard: 900
|
||||
|
||||
**FEED_MAX_ITEMS**
|
||||
Maximale Anzahl Videos, die in den Feeds enthalten sind.
|
||||
Standard: 50
|
||||
|
||||
**N_WORKERS**
|
||||
Anzahl an Worker-Prozessen, die gestartet werden sollen
|
||||
(nur im Docker-Container verfügbar).
|
||||
Standard: 1
|
||||
|
||||
|
||||
Verzeichnisstruktur
|
||||
*******************
|
||||
|
||||
Ucast erstellt in seinem Arbeitsverzeichnis vier Unterordner, in denen die
|
||||
Daten der Anwendung abgelegt werden.
|
||||
|
||||
.. code-block:: txt
|
||||
|
||||
- workdir
|
||||
|_ cache Temporäre Dateien
|
||||
|_ data Heruntergeladene Medien
|
||||
|_ db SQLite-Datenbank
|
||||
|_ static Statische Websitedaten
|
||||
|
||||
|
||||
Bedienung
|
||||
#########
|
||||
|
||||
Nach dem Login kommt man auf die Übersichtsseite, auf der alle abonnierten
|
||||
Kanäle aufgelistet werden. Um einen neuen Kanal zu abonnieren, muss die YouTube-URL
|
||||
(z.B. https://youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q)
|
||||
in das Eingabefeld kopiert werden.
|
||||
|
||||
Wurde ein neuer Kanal hinzugefügt, beginnt ucast damit, die neuesten 15 Videos
|
||||
herunterzuladen. Um zu überprüfen, welche Videos momentan heruntergeladen werden,
|
||||
kann man auf die *Downloads*-Seite gehen. Auf dieser Seite werden auch fehlgeschlagene
|
||||
Downloadtasks aufgelistet, die auch manuell wiederholt werden können (bspw. nach einem
|
||||
Ausfall der Internetverbindung). Es gibt auch eine Suchfunktion, mit der man nach
|
||||
einem Video mit einem bestimmten Titel suchen kann.
|
||||
|
||||
Um die abonnierten Kanäle zu seinem Podcast-Client hinzuzufügen, kann man die
|
||||
Feed-URL auf der Übersichtsseite einfach kopieren und einfügen.
|
||||
|
||||
Die meisten Podcast-Clients bieten zudem eine Funktion zum Import von OPML-Dateien an.
|
||||
In diesem Fall kann man einfach auf den Link *Download OPML* unten auf der Seite
|
||||
klicken und die heruntergeladen Datei importieren. Auf diese Weise hat man schnell
|
||||
alle abonnierten Kanäle zu seinem Podcast-Client hinzugefügt.
|
||||
|
||||
|
||||
Fazit
|
||||
#####
|
||||
|
||||
Ich betreibe Ucast seit einer Woche auf meiner NAS
|
||||
und verwende es, um mir Videos sowohl am Rechner als auch unterwegs anzuhören.
|
||||
|
||||
In den ersten Tagen habe ich noch einige Bugs festgestellt, die beseitigt werden
|
||||
mussten. Beispielsweise liegen nicht alle YouTube-Thumbnails im 16:9-Format vor,
|
||||
weswegen sie zugeschnitten werden müssen, um das Layout der Webseite nicht zu
|
||||
verschieben.
|
||||
|
||||
Am Anfang habe ich geplant, `SponsorBlock <https://sponsor.ajay.app>`_ in Ucast
|
||||
zu integrieren, um Werbeinhalte aus den Videos zu entfernen. Yt-dlp hat dieses
|
||||
Feature bereits integriert. Allerdings basiert Sponsorblock auf einer von der
|
||||
Community verwalteten Datenbank, d.h. je nach Beliebtheit des Videos dauert es
|
||||
zwischen einer halben und mehreren Stunden nach Release, bis Markierungen verfügbar
|
||||
sind. Damit Sponsorblock zuverlässig funktioniert, müsste Ucast regelmäßig nach dem
|
||||
Release des Videos die Datenbank abfragen und das Video bei Änderungen erneut
|
||||
herunterladen und zuschneiden. Dies war mir zunächst zu komplex und ich habe mich
|
||||
dazu entschieden, das Feature erst in Zukunft umzusetzen.
|
||||
|
||||
Ein weiteres Feature, das ich in Zukunft umsetzen werde,
|
||||
ist die Unterstützung von alternativen Videoplattformen wie Peertube,
|
||||
Odysee und Bitchute.
|
20
docs/tox.ini
|
@ -1,20 +0,0 @@
|
|||
[tox]
|
||||
skipsdist = True
|
||||
envlist =
|
||||
html
|
||||
pdf
|
||||
|
||||
[testenv]
|
||||
description = Dokumentation bauen
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
|
||||
[testenv:html]
|
||||
commands = sphinx-build -b html -d build/doctrees . build/html
|
||||
|
||||
[testenv:pdf]
|
||||
allowlist_externals = make
|
||||
commands = make latexpdf
|
||||
|
||||
[testenv:live]
|
||||
description = Live update mit sphinx-autobuild
|
||||
commands = sphinx-autobuild . build/html --open-browser
|
|
@ -1 +0,0 @@
|
|||
ucast_project/manage.py
|
|
@ -1,14 +1,6 @@
|
|||
# Coverbilder
|
||||
|
||||
Podcast-Cover sind quadratisch, während YT-Thumbnails das Seitenverhältnis
|
||||
16:9 haben. Da Thumbnails häufig Textelemente beinhalten, ist es nicht
|
||||
vorteilhaft, das Thumbnail einfach quadratisch zuzuschneiden.
|
||||
|
||||
Stattdessen sollte Ucast das Thumbnail nach oben und unten farblich
|
||||
passend erweitern und den Videotitel und Kanalnamen einfügen.
|
||||
|
||||
![](../tests/testfiles/thumbnail/t2.webp)
|
||||
![](../tests/testfiles/cover/c2.png)
|
||||
Podcast-Cover sind quadratisch.
|
||||
|
||||
- Durchschnittliche Farbe der oberen und unteren 20% des Bilds berechnen
|
||||
- Farbverlauf zwischen diesen Farben als Hintergrund verwenden
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
Django-Klasse: `django.utils.feedgenerator.Rss201rev2Feed`
|
||||
|
||||
### Channel-Attribute
|
||||
|
||||
| Tag | Beschreibung | Django-Attribut |
|
||||
|--------------------------------------|-----------------------------------------|----------------------|
|
||||
| `\<atom:link href="" rel="self">` | Feed-URL | `feed_url` |
|
||||
| `\<title>` | Kanalname | `title` |
|
||||
| `\<language>` | Sprache | `language` |
|
||||
| `\<lastBuildDate>` | Datum der letzten Veränderung des Feeds | `latest_post_date()` |
|
||||
| `\<description>` | Kanalbeschreibung | `description` |
|
||||
| `\<link>` | Link zum Kanal | `link` |
|
||||
| `\<copyright>` | Autor | `feed_copyright` |
|
||||
| `\<image><url><title><link></image>` | Cover-URL / Kanalname / Link | - |
|
||||
| `\<itunes:image href="">` | Cover-URL | - |
|
||||
| `\<itunes:author>` | Autor | - |
|
||||
| `\<itunes:summary>` | Kanalbeschreibung | - |
|
||||
|
||||
|
||||
### Item-Attribute
|
||||
|
||||
| Tag | Beschreibung | Django-Attribut |
|
||||
|--------------------------------------------------|------------------------|-----------------|
|
||||
| `\<title>` | Titel | `title` |
|
||||
| `\<itunes:title>` | Titel | - |
|
||||
| `\<description>` | Beschreibung | `description` |
|
||||
| `\<pubDate>` | Veröffentlichungsdatum | `pubdate` |
|
||||
| `\<link>` | Link | `link` |
|
||||
| `\<guid>` | Eindeutige ID/ | `unique_id` |
|
||||
| `\<itunes:summary>` | Bechreibung | - |
|
||||
| `\<itunes:author>` | Autor | - |
|
||||
| `\<enclosure url="" type="audio/mpeg" length=1>` | Audiodatei | `enclosures ` |
|
||||
| `\<itunes:duration>00:40:35</itunes:duration>` | Dauer | - |
|
||||
| `\<itunes:image href="">` | Cover-URL | - |
|
|
@ -3,54 +3,71 @@
|
|||
## Verzeichnisstruktur
|
||||
|
||||
```txt
|
||||
_ config
|
||||
|_ config.py
|
||||
_ data
|
||||
|_ LinusTechTips
|
||||
|_ _ucast
|
||||
|_ avatar.jpg # Profilbild des Kanals
|
||||
|_ avatar_sm.webp
|
||||
|_ covers # Cover-Bilder
|
||||
|_ 220409_Building_a_1_000_000_Computer.png
|
||||
|_ 220410_Apple_makes_GREAT_Gaming_Computers.png
|
||||
|_ thumbnails
|
||||
|_ 220409_Building_a_1_000_000_Computer.webp
|
||||
|_ 220409_Building_a_1_000_000_Computer_sm.webp
|
||||
|_ 220410_Apple_makes_GREAT_Gaming_Computers.webp
|
||||
|_ 220410_Apple_makes_GREAT_Gaming_Computers_sm.webp
|
||||
|_ 220409_Building_a_1_000_000_Computer.mp3
|
||||
|_ 220410_Apple_makes_GREAT_Gaming_Computers.mp3
|
||||
| |_ .ucast
|
||||
| | |_ videos.json # IDs und Metadaten aller heruntergeladenen Videos
|
||||
| | |_ options.json # Kanalspezifische Optionen (ID, enabled)
|
||||
| | |_ avatar.png # Profilbild des Kanals
|
||||
| | |_ feed.xml # RSS-Feed
|
||||
| | |_ covers # Cover-Bilder
|
||||
| | |_ 220409_Building a _1_000_000 Computer.png
|
||||
| | |_ 220410_Apple makes GREAT Gaming Computers.png
|
||||
| |_ 220409_Building a _1_000_000 Computer.mp3
|
||||
| |_ 220410_Apple makes GREAT Gaming Computers.mp3
|
||||
|
|
||||
|_ Andreas Spiess
|
||||
|_ ...
|
||||
```
|
||||
|
||||
## Verzeichnisstruktur (mit Datenbank)
|
||||
|
||||
```txt
|
||||
_ config
|
||||
|_ config.py
|
||||
_ data
|
||||
|_ ucast.db
|
||||
|
|
||||
|_ LinusTechTips
|
||||
| |_ .ucast
|
||||
| | |_ avatar.png # Profilbild des Kanals
|
||||
| | |_ feed.xml # RSS-Feed
|
||||
| | |_ covers # Cover-Bilder
|
||||
| | |_ 220409_Building a _1_000_000 Computer.png
|
||||
| | |_ 220410_Apple makes GREAT Gaming Computers.png
|
||||
| |_ 220409_Building a _1_000_000 Computer.mp3
|
||||
| |_ 220410_Apple makes GREAT Gaming Computers.mp3
|
||||
|
|
||||
|_ Andreas Spiess
|
||||
|_ ...
|
||||
```
|
||||
|
||||
## Datenmodelle
|
||||
|
||||
### LastScan
|
||||
|
||||
- LastScan: datetime
|
||||
|
||||
### ChannelOptions
|
||||
|
||||
- ID: `str, max_length=30`
|
||||
- Active: `bool = True`
|
||||
- LastScan: `datetime`
|
||||
- SkipLivestreams: `bool = True`
|
||||
- SkipShorts: `bool = True`
|
||||
- KeepVideos: `int, nullable`
|
||||
- Videos: `-> Video (1->n)`
|
||||
### Channel
|
||||
|
||||
- ID: str, VARCHAR(30), PKEY
|
||||
- Name: str, VARCHAR(100)
|
||||
- Active: bool = True
|
||||
- SkipLivestreams: bool = True
|
||||
- SkipShorts: bool = True
|
||||
- KeepVideos: int = -1
|
||||
|
||||
### Video
|
||||
|
||||
- ID: `str, max_length=30`
|
||||
- Title: `str, max_length=200`
|
||||
- Slug: `str, max_length=209` (YYYYMMDD_Title, used as filename)
|
||||
- Published: `datetime`
|
||||
- Downloaded: `datetime, nullable`
|
||||
- Description: `text`
|
||||
- ID: str, VARCHAR(30), PKEY
|
||||
- Channel: -> Channel.ID
|
||||
- Title: str, VARCHAR(200)
|
||||
- Slug: str (YYYYMMDD_Title, used as filename), VARCHAR(209)
|
||||
- Published: datetime
|
||||
- Downloaded: datetime
|
||||
- Description: str, VARCHAR(1000)
|
||||
|
||||
### Config
|
||||
|
||||
- RedisURL: str
|
||||
- ScanInterval: 1h
|
||||
- DefaultChannelOptions: ChannelOptions
|
||||
- AppriseUrl: str (für Benachrichtigungen, https://github.com/caronc/apprise/wiki)
|
||||
|
|
1899
package-lock.json
generated
29
package.json
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"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": "sass --style expanded --watch assets/sass/style.sass _run/static/bulma/css/style.min.css"
|
||||
}
|
||||
}
|
1269
poetry.lock
generated
|
@ -1,59 +1,46 @@
|
|||
[tool.poetry]
|
||||
name = "ucast"
|
||||
version = "0.4.6"
|
||||
version = "0.0.1"
|
||||
description = "YouTube to Podcast converter"
|
||||
authors = ["Theta-Dev <t.testboy@gmail.com>"]
|
||||
packages = [
|
||||
{ include = "ucast" },
|
||||
{ include = "ucast_project" },
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
Django = "^4.0.4"
|
||||
yt-dlp = "^2022.6.29"
|
||||
requests = "^2.28.1"
|
||||
starlette = {extras = ["full"], version = "^0.19.1"}
|
||||
uvicorn = "^0.17.6"
|
||||
yt-dlp = "^2022.3.8"
|
||||
scrapetube = "^2.2.2"
|
||||
rfeed = "^1.1.1"
|
||||
feedparser = "^6.0.8"
|
||||
Pillow = "^9.1.0"
|
||||
colorthief = "^0.2.1"
|
||||
wcag-contrast-ratio = "^0.9"
|
||||
font-source-sans-pro = "^0.0.1"
|
||||
fonts = "^0.0.3"
|
||||
django-bulma = "^0.8.3"
|
||||
python-dotenv = "^0.20.0"
|
||||
psycopg2 = "^2.9.3"
|
||||
mysqlclient = "^2.1.1"
|
||||
alembic = "^1.7.7"
|
||||
python-slugify = "^6.1.2"
|
||||
mutagen = "^1.45.1"
|
||||
rq = "^1.10.1"
|
||||
rq-scheduler = "^0.11.0"
|
||||
pycryptodomex = "^3.14.1"
|
||||
django-htmx = "^1.12.0"
|
||||
starlette-core = "^0.0.1"
|
||||
click = "^8.1.3"
|
||||
python-dotenv = "^0.20.0"
|
||||
mysqlclient = "^2.1.0"
|
||||
psycopg2 = "^2.9.3"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.1.1"
|
||||
pytest = "^7.1.2"
|
||||
pytest-cov = "^3.0.0"
|
||||
invoke = "^1.7.0"
|
||||
pytest-django = "^4.5.2"
|
||||
pre-commit = "^2.19.0"
|
||||
honcho = "^1.1.0"
|
||||
pytest-mock = "^3.7.0"
|
||||
fakeredis = "^1.7.5"
|
||||
gunicorn = "^20.1.0"
|
||||
bump2version = "^1.0.1"
|
||||
pre-commit = "^2.18.1"
|
||||
virtualenv = "20.14.1"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
"ucast-manage" = "ucast_project.manage:main"
|
||||
ucast = "ucast.__main__:cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "ucast_project.settings"
|
||||
|
||||
[tool.flake8]
|
||||
extend-ignore = "E501"
|
||||
max-line-length = 88
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
|
|
152
tasks.py
|
@ -1,150 +1,68 @@
|
|||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from honcho import manager
|
||||
from invoke import Responder, task
|
||||
from invoke import task
|
||||
|
||||
from ucast import tests
|
||||
from ucast.service import cover, util, youtube
|
||||
import tests
|
||||
from ucast import cover, util, youtube
|
||||
|
||||
os.chdir(Path(__file__).absolute().parent)
|
||||
db_file = Path("_run/ucast.db").absolute()
|
||||
|
||||
DIR_RUN = Path("_run").absolute()
|
||||
DIR_STATIC = DIR_RUN / "static"
|
||||
DIR_DOWNLOAD = DIR_RUN / "data"
|
||||
FILE_DB = DIR_RUN / "db.sqlite"
|
||||
# Configure application
|
||||
os.environ["DEBUG"] = "true"
|
||||
os.environ["SECRET_KEY"] = "1234"
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_file}"
|
||||
|
||||
|
||||
@task
|
||||
def test(c):
|
||||
"""Run unit tests"""
|
||||
c.run("pytest", pty=True)
|
||||
c.run("pytest tests", 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):
|
||||
"""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],
|
||||
)
|
||||
def run(c):
|
||||
os.chdir("ucast")
|
||||
c.run("alembic upgrade head")
|
||||
c.run("python app.py")
|
||||
|
||||
|
||||
@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_details(vid)
|
||||
title = vinfo.title
|
||||
channel_name = vinfo.channel_name
|
||||
channel_id = vinfo.channel_id
|
||||
channel_metadata = youtube.get_channel_metadata(
|
||||
youtube.channel_url_from_id(channel_id)
|
||||
)
|
||||
vinfo = youtube.get_video_info(vid)
|
||||
title = vinfo["fulltitle"]
|
||||
channel_name = vinfo["uploader"]
|
||||
thumbnail_url = youtube.get_thumbnail_url(vinfo)
|
||||
channel_url = vinfo["channel_url"]
|
||||
channel_metadata = youtube.get_channel_metadata(channel_url)
|
||||
|
||||
ti = 1
|
||||
while os.path.exists(tests.DIR_TESTFILES / "avatar" / f"a{ti}.jpg"):
|
||||
while os.path.exists(tests.DIR_TESTFILES / "cover" / f"c{ti}.png"):
|
||||
ti += 1
|
||||
|
||||
tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{ti}.webp"
|
||||
av_file = tests.DIR_TESTFILES / "avatar" / f"a{ti}.jpg"
|
||||
cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_gradient.png"
|
||||
cv_blur_file = tests.DIR_TESTFILES / "cover" / f"c{ti}_blur.png"
|
||||
cv_file = tests.DIR_TESTFILES / "cover" / f"c{ti}.png"
|
||||
|
||||
youtube.download_thumbnail(vinfo, tn_file)
|
||||
util.download_image_file(channel_metadata.avatar_url, av_file)
|
||||
util.download_file(thumbnail_url, tn_file)
|
||||
util.download_file(channel_metadata.avatar_url, av_file)
|
||||
|
||||
cover.create_cover_file(
|
||||
tn_file, av_file, title, channel_name, cover.COVER_STYLE_GRADIENT, cv_file
|
||||
)
|
||||
cover.create_cover_file(
|
||||
tn_file, av_file, title, channel_name, cover.COVER_STYLE_BLUR, cv_blur_file
|
||||
)
|
||||
cover.create_cover_file(tn_file, av_file, title, channel_name, cv_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"
|
||||
)
|
||||
def add_migration(c, m=""):
|
||||
if not m:
|
||||
raise Exception("please input migration name")
|
||||
|
||||
tmpdir_o = TemporaryDirectory()
|
||||
tmpdir = Path(tmpdir_o.name)
|
||||
db_file = tmpdir / "migrate.db"
|
||||
|
||||
@task
|
||||
def reset(c):
|
||||
if DIR_DOWNLOAD.exists():
|
||||
shutil.rmtree(DIR_DOWNLOAD)
|
||||
if FILE_DB.exists():
|
||||
os.remove(FILE_DB)
|
||||
os.makedirs(DIR_DOWNLOAD, exist_ok=True)
|
||||
migrate(c)
|
||||
create_testuser(c)
|
||||
collectstatic(c)
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_file}"
|
||||
|
||||
os.chdir("ucast")
|
||||
|
||||
@task
|
||||
def worker(c, n=2):
|
||||
m = manager.Manager()
|
||||
|
||||
for i in range(n):
|
||||
m.add_process(f"worker_{i}", "python manage.py rqworker")
|
||||
|
||||
m.add_process("scheduler", "python manage.py rqscheduler")
|
||||
|
||||
m.loop()
|
||||
sys.exit(m.returncode)
|
||||
|
||||
|
||||
@task
|
||||
def optimize_svg(c):
|
||||
out_dir = Path("ucast/static/ucast")
|
||||
|
||||
for icon in (Path("assets/icons/logo.svg"), Path("assets/icons/logo_dark.svg")):
|
||||
c.run(
|
||||
f"scour --indent=none --no-line-breaks --enable-comment-stripping {icon} {out_dir / icon.name}"
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def build_sass(c):
|
||||
c.run("npm run build")
|
||||
collectstatic(c)
|
||||
c.run("alembic upgrade head")
|
||||
c.run(f"alembic revision --autogenerate -m '{m}'")
|
||||
|
|
4
tests/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
# coding=utf-8
|
||||
from importlib.resources import files
|
||||
|
||||
DIR_TESTFILES = files("tests.testfiles")
|
|
@ -1,3 +1,4 @@
|
|||
# coding=utf-8
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
@ -6,8 +7,8 @@ import pytest
|
|||
from fonts.ttf import SourceSansPro
|
||||
from PIL import Image, ImageChops, ImageFont
|
||||
|
||||
from ucast import tests
|
||||
from ucast.service import cover, typ
|
||||
import tests
|
||||
from ucast import cover, typ
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -25,7 +26,8 @@ from ucast.service import cover, typ
|
|||
(
|
||||
1000,
|
||||
300,
|
||||
"Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! Du nicht von Gott, Tyrann!",
|
||||
"Ha! du wärst Obrigkeit von Gott? Gott spendet Segen aus; du raubst! \
|
||||
Du nicht von Gott, Tyrann!",
|
||||
[
|
||||
"Ha! du wärst",
|
||||
"Obrigkeit von",
|
||||
|
@ -48,7 +50,7 @@ def test_split_text(height: int, width: int, text: str, expect: List[str]):
|
|||
"file_name,color",
|
||||
[
|
||||
("t1.webp", (63, 63, 62)),
|
||||
("t2.webp", (22, 20, 20)),
|
||||
("t2.webp", (74, 45, 37)),
|
||||
("t3.webp", (54, 24, 28)),
|
||||
],
|
||||
)
|
||||
|
@ -71,89 +73,23 @@ def test_get_text_color(bg_color: typ.Color, text_color: typ.Color):
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"n_image,title,channel,style",
|
||||
"n_image,title,channel",
|
||||
[
|
||||
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_GRADIENT),
|
||||
(1, "ThetaDev @ Embedded World 2019", "ThetaDev", cover.COVER_STYLE_BLUR),
|
||||
(
|
||||
2,
|
||||
"Sintel - Open Movie by Blender Foundation",
|
||||
"Blender",
|
||||
cover.COVER_STYLE_GRADIENT,
|
||||
),
|
||||
(
|
||||
2,
|
||||
"Sintel - Open Movie by Blender Foundation",
|
||||
"Blender",
|
||||
cover.COVER_STYLE_BLUR,
|
||||
),
|
||||
(
|
||||
3,
|
||||
"Systemabsturz Teaser zur DiVOC bb3",
|
||||
"media.ccc.de",
|
||||
cover.COVER_STYLE_GRADIENT,
|
||||
),
|
||||
(
|
||||
3,
|
||||
"Systemabsturz Teaser zur DiVOC bb3",
|
||||
"media.ccc.de",
|
||||
cover.COVER_STYLE_BLUR,
|
||||
),
|
||||
(1, "ThetaDev @ Embedded World 2019", "ThetaDev"),
|
||||
(2, "Sintel - Open Movie by Blender Foundation", "Blender"),
|
||||
(3, "Systemabsturz Teaser zur DiVOC bb3", "media.ccc.de"),
|
||||
],
|
||||
)
|
||||
def test_create_cover_image(
|
||||
n_image: int, title: str, channel: str, style: cover.CoverStyle
|
||||
):
|
||||
def test_create_cover_image(n_image: int, title: str, channel: str):
|
||||
tn_file = tests.DIR_TESTFILES / "thumbnail" / f"t{n_image}.webp"
|
||||
av_file = tests.DIR_TESTFILES / "avatar" / f"a{n_image}.jpg"
|
||||
expected_cv_file = tests.DIR_TESTFILES / "cover" / f"c{n_image}_{style}.png"
|
||||
expected_cv_file = tests.DIR_TESTFILES / "cover" / f"c{n_image}.png"
|
||||
|
||||
tn_image = Image.open(tn_file)
|
||||
av_image = Image.open(av_file)
|
||||
expected_cv_image = Image.open(expected_cv_file)
|
||||
|
||||
cv_image = cover._create_cover_image(tn_image, av_image, title, channel, 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.COVER_STYLE_GRADIENT,
|
||||
)
|
||||
|
||||
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
|
||||
cv_image = cover._create_cover_image(tn_image, av_image, title, channel)
|
||||
|
||||
diff = ImageChops.difference(cv_image, expected_cv_image)
|
||||
assert diff.getbbox() is None
|
||||
|
@ -162,19 +98,14 @@ def test_create_blank_cover_image():
|
|||
def test_create_cover_file():
|
||||
tn_file = tests.DIR_TESTFILES / "thumbnail" / "t1.webp"
|
||||
av_file = tests.DIR_TESTFILES / "avatar" / "a1.jpg"
|
||||
expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1_gradient.png"
|
||||
expected_cv_file = tests.DIR_TESTFILES / "cover" / "c1.png"
|
||||
|
||||
tmpdir_o = tempfile.TemporaryDirectory()
|
||||
tmpdir = Path(tmpdir_o.name)
|
||||
cv_file = tmpdir / "cover.png"
|
||||
|
||||
cover.create_cover_file(
|
||||
tn_file,
|
||||
av_file,
|
||||
"ThetaDev @ Embedded World 2019",
|
||||
"ThetaDev",
|
||||
"gradient",
|
||||
cv_file,
|
||||
tn_file, av_file, "ThetaDev @ Embedded World 2019", "ThetaDev", cv_file
|
||||
)
|
||||
|
||||
cv_image = Image.open(cv_file)
|
86
tests/test_database.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
# coding=utf-8
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy import orm
|
||||
|
||||
# from ucast import models
|
||||
from ucast import db
|
||||
|
||||
|
||||
def test_insert_channel(testdb):
|
||||
c1 = db.models.Channel(id="UCE1PLliRk3urTjDG6kGByiA", name="Natalie Gold")
|
||||
session.add(c1)
|
||||
session.commit()
|
||||
|
||||
c2 = db.models.Channel(id="UCGiJh0NZ52wRhYKYnuZI08Q", name="ThetaDev")
|
||||
|
||||
session.add(c2)
|
||||
session.commit()
|
||||
|
||||
# stmt = sqlalchemy.select(models.Channel).where(models.Channel.name == "LinusTechTips")
|
||||
# stmt = sqlalchemy.select(models.Channel)
|
||||
# res = testdb.execute(stmt)
|
||||
|
||||
res = session.query(models.Channel).all()
|
||||
assert len(res) == 2
|
||||
assert res[0].name == "Natalie Gold"
|
||||
assert res[1].name == "ThetaDev"
|
||||
|
||||
|
||||
"""
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def testdb():
|
||||
try:
|
||||
os.remove("test.db")
|
||||
except:
|
||||
pass
|
||||
# url = "sqlite:///:memory:"
|
||||
url = "sqlite:///test.db"
|
||||
engine = sqlalchemy.create_engine(url)
|
||||
models.metadata.create_all(engine) # Create the tables.
|
||||
return engine
|
||||
|
||||
|
||||
def test_insert_channel(testdb):
|
||||
session_maker = orm.sessionmaker(bind=testdb)
|
||||
session = session_maker()
|
||||
c1 = models.Channel(id="UCE1PLliRk3urTjDG6kGByiA", name="Natalie Gold")
|
||||
session.add(c1)
|
||||
session.commit()
|
||||
|
||||
c2 = models.Channel(id="UCGiJh0NZ52wRhYKYnuZI08Q", name="ThetaDev")
|
||||
|
||||
session.add(c2)
|
||||
session.commit()
|
||||
|
||||
# stmt = sqlalchemy.select(models.Channel).where(models.Channel.name == "LinusTechTips")
|
||||
# stmt = sqlalchemy.select(models.Channel)
|
||||
# res = testdb.execute(stmt)
|
||||
|
||||
res = session.query(models.Channel).all()
|
||||
assert len(res) == 2
|
||||
assert res[0].name == "Natalie Gold"
|
||||
assert res[1].name == "ThetaDev"
|
||||
|
||||
|
||||
def test_insert_video(testdb):
|
||||
session_maker = orm.sessionmaker(bind=testdb)
|
||||
session = session_maker()
|
||||
|
||||
c1 = models.Channel(id="UC0QEucPrn0-Ddi3JBTcs5Kw", name="Saria Delaney")
|
||||
session.add(c1)
|
||||
session.commit()
|
||||
|
||||
v1 = models.Video(id="Bxhxzj8R_i0",
|
||||
channel=c1,
|
||||
title="Verschwiegen. Verraten. Verstummt. [Reupload: 10.10.2018]",
|
||||
published=datetime(2020, 7, 4, 12, 21, 30),
|
||||
description="")
|
||||
v1.slug = v1.get_slug()
|
||||
|
||||
session.add(v1)
|
||||
session.commit()
|
||||
"""
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 186 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
BIN
tests/testfiles/cover/c2.png
Normal file
After Width: | Height: | Size: 229 KiB |
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
5
tests/testfiles/sources.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
### 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)
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
BIN
tests/testfiles/thumbnail/t2.webp
Normal file
After Width: | Height: | Size: 101 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
@ -1,5 +1,7 @@
|
|||
__version__ = "0.4.6"
|
||||
# coding=utf-8
|
||||
__version__ = "0.0.1"
|
||||
|
||||
|
||||
def template_context(request):
|
||||
return {"version": __version__}
|
||||
UCAST_BANNER = """\
|
||||
┬ ┬┌─┐┌─┐┌─┐┌┬┐
|
||||
│ ││ ├─┤└─┐ │
|
||||
└─┘└─┘┴ ┴└─┘ ┴ """
|
||||
|
|
77
ucast/__main__.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
import os
|
||||
import sys
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import dotenv
|
||||
import uvicorn
|
||||
from alembic import config as alembic_cmd
|
||||
|
||||
import ucast
|
||||
|
||||
|
||||
def load_dotenv():
|
||||
dotenv_path = dotenv.find_dotenv()
|
||||
if dotenv_path:
|
||||
dotenv.load_dotenv(dotenv_path)
|
||||
os.chdir(Path(dotenv_path).absolute().parent)
|
||||
print(f"Loaded config from envfile at {dotenv_path}")
|
||||
|
||||
|
||||
def print_banner():
|
||||
print(ucast.UCAST_BANNER + ucast.__version__)
|
||||
|
||||
|
||||
def print_help():
|
||||
print_banner()
|
||||
print(
|
||||
"""
|
||||
Available commands:
|
||||
run: start the server
|
||||
migrate: apply database migrations
|
||||
alembic: run the alembic migrator
|
||||
|
||||
Configuration is read from the .env file or environment variables.
|
||||
Refer to the project page for more information: https://code.thetadev.de/HSA/Ucast"""
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
print_banner()
|
||||
load_dotenv()
|
||||
from ucast import config
|
||||
|
||||
uvicorn.run(
|
||||
"ucast.app:create_app",
|
||||
host="0.0.0.0",
|
||||
port=config.HTTP_PORT,
|
||||
factory=True,
|
||||
reload=config.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def alembic(args):
|
||||
load_dotenv()
|
||||
alembic_ini_path = resources.path("ucast", "alembic.ini")
|
||||
os.environ["ALEMBIC_CONFIG"] = str(alembic_ini_path)
|
||||
|
||||
alembic_cmd.main(args, f"{sys.argv[0]} alembic")
|
||||
|
||||
|
||||
def cli():
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(print_help())
|
||||
|
||||
cmd = sys.argv[1]
|
||||
args = sys.argv[2:]
|
||||
|
||||
if cmd == "run":
|
||||
sys.exit(run())
|
||||
elif cmd == "alembic":
|
||||
sys.exit(alembic(args))
|
||||
else:
|
||||
sys.exit(print_help())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
|
@ -1,17 +0,0 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from ucast.models import Channel, User, Video
|
||||
|
||||
|
||||
class ChannelAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "id"]
|
||||
|
||||
|
||||
class VideoAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "published"]
|
||||
ordering = ("-published",)
|
||||
|
||||
|
||||
admin.site.register(Channel, ChannelAdmin)
|
||||
admin.site.register(Video, VideoAdmin)
|
||||
admin.site.register(User)
|
100
ucast/alembic.ini
Normal file
|
@ -0,0 +1,100 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = ucast:migrations
|
||||
|
||||
# template used to generate migration files
|
||||
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
20
ucast/app.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# coding=utf-8
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
|
||||
from ucast import config, views
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Starlette(
|
||||
config.DEBUG,
|
||||
routes=[
|
||||
Route("/", views.homepage),
|
||||
Route("/err", views.error),
|
||||
],
|
||||
)
|
||||
|
||||
if app.debug:
|
||||
print("Debug mode enabled.")
|
||||
|
||||
return app
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UcastConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "ucast"
|
12
ucast/config.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
# coding=utf-8
|
||||
from starlette.config import Config
|
||||
from starlette.datastructures import Secret
|
||||
from starlette_core.database import DatabaseURL
|
||||
|
||||
config = Config()
|
||||
|
||||
# Basic configuration
|
||||
DEBUG = config("DEBUG", cast=bool, default=False)
|
||||
DATABASE_URL = config("DATABASE_URL", cast=DatabaseURL)
|
||||
SECRET_KEY = config("SECRET_KEY", cast=Secret)
|
||||
HTTP_PORT = config("HTTP_PORT", cast=int, default=8000)
|
210
ucast/cover.py
Normal file
|
@ -0,0 +1,210 @@
|
|||
# coding=utf-8
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import wcag_contrast_ratio
|
||||
from colorthief import ColorThief
|
||||
from fonts.ttf import SourceSansPro
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from ucast import typ
|
||||
|
||||
CHAR_ELLIPSIS = "…"
|
||||
COVER_WIDTH = 500
|
||||
|
||||
|
||||
def _split_text(
|
||||
height: int, width: int, text: str, font: ImageFont.FreeTypeFont, line_spacing=0
|
||||
) -> List[str]:
|
||||
if height < font.size:
|
||||
return []
|
||||
|
||||
max_lines = math.floor((height - font.size) / (font.size + line_spacing)) + 1
|
||||
|
||||
lines = []
|
||||
line = ""
|
||||
|
||||
for word in text.split(" "):
|
||||
if len(lines) >= max_lines:
|
||||
line = word
|
||||
break
|
||||
|
||||
if line == "":
|
||||
nline = word
|
||||
else:
|
||||
nline = line + " " + word
|
||||
|
||||
if font.getsize(nline)[0] <= width:
|
||||
line = nline
|
||||
elif line != "":
|
||||
lines.append(line)
|
||||
line = word
|
||||
else:
|
||||
# try to trim current word
|
||||
while nline:
|
||||
nline = nline[:-1]
|
||||
nline_e = nline + CHAR_ELLIPSIS
|
||||
if font.getsize(nline_e)[0] <= width:
|
||||
lines.append(nline_e)
|
||||
break
|
||||
|
||||
if line != "":
|
||||
if len(lines) >= max_lines:
|
||||
# Drop the last line and add ... to the end
|
||||
lastline = lines[-1] + CHAR_ELLIPSIS
|
||||
if font.getsize(lastline)[0] <= width:
|
||||
lines[-1] = lastline
|
||||
else:
|
||||
i_last_space = lines[-1].rfind(" ")
|
||||
lines[-1] = lines[-1][:i_last_space] + CHAR_ELLIPSIS
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _draw_text_box(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
box: Tuple[int, int, int, int],
|
||||
text: str,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
color: typ.Color = (0, 0, 0),
|
||||
line_spacing=0,
|
||||
vertical_center=True,
|
||||
):
|
||||
x_tl, y_tl, x_br, y_br = box
|
||||
height = y_br - y_tl
|
||||
width = x_br - x_tl
|
||||
|
||||
lines = _split_text(height, width, text, font, line_spacing)
|
||||
|
||||
y_start = y_tl
|
||||
if vertical_center:
|
||||
text_height = len(lines) * (font.size + line_spacing) - line_spacing
|
||||
y_start += int((height - text_height) / 2)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
y_pos = y_start + i * (font.size + line_spacing)
|
||||
draw.text((x_tl, y_pos), line, color, font)
|
||||
|
||||
|
||||
def _get_dominant_color(img: Image.Image):
|
||||
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):
|
||||
yield [round(f + det * i) for f, det in zip(color_from, det_co)]
|
||||
|
||||
|
||||
def _get_text_color(bg_color) -> typ.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)
|
||||
if c_wht > c_blk:
|
||||
return 255, 255, 255
|
||||
return 0, 0, 0
|
||||
|
||||
|
||||
def _create_cover_image(
|
||||
thumbnail: Image.Image, avatar: Optional[Image.Image], title: str, channel: str
|
||||
) -> Image.Image:
|
||||
# Scale the thumbnail image down to cover size
|
||||
tn_height = int(COVER_WIDTH / thumbnail.width * thumbnail.height)
|
||||
tn = thumbnail.resize((COVER_WIDTH, tn_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# 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)
|
||||
|
||||
# Create new cover image
|
||||
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
|
||||
cover_draw = ImageDraw.Draw(cover)
|
||||
|
||||
# Draw background gradient
|
||||
for i, color in enumerate(
|
||||
_interpolate_color(top_color, bottom_color, cover.height)
|
||||
):
|
||||
cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1)
|
||||
|
||||
# Insert thumbnail image in the middle
|
||||
tn_margin = int((COVER_WIDTH - tn_height) / 2)
|
||||
cover.paste(tn, (0, tn_margin))
|
||||
|
||||
# Add channel avatar
|
||||
avt_margin = 0
|
||||
avt_size = 0
|
||||
|
||||
if avatar:
|
||||
avt_margin = int(tn_margin * 0.05)
|
||||
avt_size = tn_margin - 2 * avt_margin
|
||||
|
||||
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
|
||||
|
||||
circle_mask = Image.new("L", (avt_size, avt_size))
|
||||
circle_mask_draw = ImageDraw.Draw(circle_mask)
|
||||
circle_mask_draw.ellipse((0, 0, avt_size, avt_size), 255)
|
||||
|
||||
cover.paste(avt, (avt_margin, avt_margin), circle_mask)
|
||||
|
||||
# Add text
|
||||
text_margin_x = 16
|
||||
text_margin_topleft = avt_margin + avt_size + text_margin_x
|
||||
text_vertical_offset = -17
|
||||
text_line_space = -4
|
||||
|
||||
fnt = ImageFont.truetype(SourceSansPro, 50)
|
||||
top_text_color = _get_text_color(top_color)
|
||||
bottom_text_color = _get_text_color(bottom_color)
|
||||
|
||||
_draw_text_box(
|
||||
cover_draw,
|
||||
(
|
||||
text_margin_topleft,
|
||||
text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x,
|
||||
tn_margin,
|
||||
),
|
||||
channel,
|
||||
fnt,
|
||||
top_text_color,
|
||||
text_line_space,
|
||||
)
|
||||
_draw_text_box(
|
||||
cover_draw,
|
||||
(
|
||||
text_margin_x,
|
||||
COVER_WIDTH - tn_margin + text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x,
|
||||
COVER_WIDTH,
|
||||
),
|
||||
title,
|
||||
fnt,
|
||||
bottom_text_color,
|
||||
text_line_space,
|
||||
)
|
||||
|
||||
return cover
|
||||
|
||||
|
||||
def create_cover_file(
|
||||
thumbnail_path: Path,
|
||||
avatar_path: Optional[Path],
|
||||
title: str,
|
||||
channel: str,
|
||||
cover_path: Path,
|
||||
):
|
||||
thumbnail = Image.open(thumbnail_path)
|
||||
|
||||
avatar = None
|
||||
if avatar_path:
|
||||
avatar = Image.open(avatar_path)
|
||||
|
||||
cvr = _create_cover_image(thumbnail, avatar, title, channel)
|
||||
cvr.save(cover_path)
|
14
ucast/db.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
# coding=utf-8
|
||||
from starlette_core.database import Database, metadata # noqa: F401
|
||||
|
||||
from ucast import models # noqa: F401
|
||||
from ucast.config import DATABASE_URL
|
||||
|
||||
# set db config options
|
||||
if DATABASE_URL.driver == "psycopg2":
|
||||
engine_kwargs = {"pool_size": 20, "max_overflow": 0}
|
||||
else:
|
||||
engine_kwargs = {}
|
||||
|
||||
# setup database url
|
||||
db = Database(DATABASE_URL, engine_kwargs=engine_kwargs)
|
201
ucast/feed.py
|
@ -1,201 +0,0 @@
|
|||
import re
|
||||
from xml.sax import saxutils
|
||||
|
||||
from django import http
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.contrib.syndication.views import Feed, add_domain
|
||||
from django.utils import feedgenerator
|
||||
from django.utils.feedgenerator import Rss201rev2Feed, rfc2822_date
|
||||
from django.utils.xmlutils import SimplerXMLGenerator
|
||||
|
||||
from ucast.models import Channel, Video
|
||||
from ucast.service import util
|
||||
|
||||
URL_REGEX = r"""http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"""
|
||||
|
||||
|
||||
class PodcastFeedType(Rss201rev2Feed):
|
||||
content_type = "application/xml; charset=utf-8"
|
||||
|
||||
def rss_attributes(self):
|
||||
attrs = super().rss_attributes()
|
||||
attrs["xmlns:itunes"] = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
return attrs
|
||||
|
||||
@staticmethod
|
||||
def _xml_escape(text: str) -> str:
|
||||
text = saxutils.escape(text)
|
||||
text = re.sub(URL_REGEX, lambda m: f'<a href="{m[0]}">{m[0]}</a>', text)
|
||||
text = text.replace("\n", "<br>")
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _add_text_element(handler: SimplerXMLGenerator, text: str):
|
||||
handler.startElement("description", {})
|
||||
handler.ignorableWhitespace(f"<![CDATA[{PodcastFeedType._xml_escape(text)}]]>")
|
||||
handler.endElement("description")
|
||||
|
||||
@staticmethod
|
||||
def _format_secs(secs: int) -> str:
|
||||
mm, ss = divmod(secs, 60)
|
||||
hh, mm = divmod(mm, 60)
|
||||
s = "%02d:%02d:%02d" % (hh, mm, ss)
|
||||
return s
|
||||
|
||||
def add_root_elements(self, handler: SimplerXMLGenerator):
|
||||
handler.addQuickElement("title", self.feed["title"])
|
||||
handler.addQuickElement("link", self.feed["link"])
|
||||
self._add_text_element(handler, self.feed["description"])
|
||||
if self.feed["feed_url"] is not None:
|
||||
handler.addQuickElement(
|
||||
"atom:link", None, {"rel": "self", "href": self.feed["feed_url"]}
|
||||
)
|
||||
if self.feed["language"] is not None:
|
||||
handler.addQuickElement("language", self.feed["language"])
|
||||
for cat in self.feed["categories"]:
|
||||
handler.addQuickElement("category", cat)
|
||||
if self.feed["feed_copyright"] is not None:
|
||||
handler.addQuickElement("copyright", self.feed["feed_copyright"])
|
||||
handler.addQuickElement("lastBuildDate", rfc2822_date(self.latest_post_date()))
|
||||
if self.feed["ttl"] is not None:
|
||||
handler.addQuickElement("ttl", self.feed["ttl"])
|
||||
|
||||
if self.feed.get("image_url") is not None:
|
||||
handler.startElement("image", {})
|
||||
handler.addQuickElement("url", self.feed["image_url"])
|
||||
handler.addQuickElement("title", self.feed["title"])
|
||||
handler.addQuickElement("link", self.feed["link"])
|
||||
handler.endElement("image")
|
||||
|
||||
handler.addQuickElement(
|
||||
"itunes:image", None, {"href": self.feed["image_url"]}
|
||||
)
|
||||
|
||||
def add_item_elements(self, handler: SimplerXMLGenerator, item):
|
||||
handler.addQuickElement("title", item["title"])
|
||||
handler.addQuickElement("link", item["link"])
|
||||
|
||||
if item["description"] is not None:
|
||||
self._add_text_element(handler, item["description"])
|
||||
|
||||
# Author information.
|
||||
if item["author_name"] and item["author_email"]:
|
||||
handler.addQuickElement(
|
||||
"author", "%s (%s)" % (item["author_email"], item["author_name"])
|
||||
)
|
||||
elif item["author_email"]:
|
||||
handler.addQuickElement("author", item["author_email"])
|
||||
elif item["author_name"]:
|
||||
handler.addQuickElement(
|
||||
"dc:creator",
|
||||
item["author_name"],
|
||||
{"xmlns:dc": "http://purl.org/dc/elements/1.1/"},
|
||||
)
|
||||
|
||||
if item["pubdate"] is not None:
|
||||
handler.addQuickElement("pubDate", rfc2822_date(item["pubdate"]))
|
||||
if item["comments"] is not None:
|
||||
handler.addQuickElement("comments", item["comments"])
|
||||
if item["unique_id"] is not None:
|
||||
guid_attrs = {}
|
||||
if isinstance(item.get("unique_id_is_permalink"), bool):
|
||||
guid_attrs["isPermaLink"] = str(item["unique_id_is_permalink"]).lower()
|
||||
handler.addQuickElement("guid", item["unique_id"], guid_attrs)
|
||||
if item["ttl"] is not None:
|
||||
handler.addQuickElement("ttl", item["ttl"])
|
||||
|
||||
# Enclosure.
|
||||
if item["enclosures"]:
|
||||
enclosures = list(item["enclosures"])
|
||||
if len(enclosures) > 1:
|
||||
raise ValueError(
|
||||
"RSS feed items may only have one enclosure, see "
|
||||
"http://www.rssboard.org/rss-profile#element-channel-item-enclosure"
|
||||
)
|
||||
enclosure = enclosures[0]
|
||||
handler.addQuickElement(
|
||||
"enclosure",
|
||||
"",
|
||||
{
|
||||
"url": enclosure.url,
|
||||
"length": enclosure.length,
|
||||
"type": enclosure.mime_type,
|
||||
},
|
||||
)
|
||||
|
||||
# Categories.
|
||||
for cat in item["categories"]:
|
||||
handler.addQuickElement("category", cat)
|
||||
|
||||
# Cover image
|
||||
if item.get("image_url"):
|
||||
handler.addQuickElement("itunes:image", None, {"href": item["image_url"]})
|
||||
|
||||
# Duration
|
||||
if item.get("duration"):
|
||||
handler.addQuickElement(
|
||||
"itunes:duration", self._format_secs(item["duration"])
|
||||
)
|
||||
|
||||
|
||||
class UcastFeed(Feed):
|
||||
feed_type = PodcastFeedType
|
||||
|
||||
def get_object(self, request, *args, **kwargs):
|
||||
channel_slug = kwargs["channel"]
|
||||
return Channel.objects.get(slug=channel_slug)
|
||||
|
||||
def get_feed(self, channel: Channel, request: http.HttpRequest):
|
||||
max_items = settings.FEED_MAX_ITEMS
|
||||
try:
|
||||
max_items = int(request.GET.get("items"))
|
||||
except TypeError or ValueError:
|
||||
pass
|
||||
|
||||
feed = self.feed_type(
|
||||
title=channel.name,
|
||||
link=channel.get_absolute_url(),
|
||||
description=channel.description,
|
||||
language=self.language,
|
||||
feed_url=self.full_link_url(request, f"/feed/{channel.slug}"),
|
||||
image_url=self.full_link_url(request, f"/files/avatar/{channel.slug}.jpg"),
|
||||
)
|
||||
|
||||
for video in channel.video_set.filter(downloaded__isnull=False).order_by(
|
||||
"-published"
|
||||
)[:max_items]:
|
||||
feed.add_item(
|
||||
title=video.title,
|
||||
link=video.get_absolute_url(),
|
||||
description=video.description,
|
||||
unique_id=video.get_absolute_url(),
|
||||
unique_id_is_permalink=True,
|
||||
enclosures=self.item_enclosures_domain(video, request),
|
||||
pubdate=video.published,
|
||||
updateddate=video.downloaded,
|
||||
image_url=self.full_link_url(
|
||||
request, f"/files/cover/{channel.slug}/{video.slug}.png"
|
||||
),
|
||||
duration=video.duration,
|
||||
)
|
||||
return feed
|
||||
|
||||
@staticmethod
|
||||
def full_link_url(request: http.HttpRequest, page_url: str) -> str:
|
||||
anon_url = add_domain(
|
||||
get_current_site(request).domain,
|
||||
page_url,
|
||||
request.is_secure(),
|
||||
)
|
||||
return util.add_key_to_url(anon_url, request.user.get_feed_key())
|
||||
|
||||
def item_enclosures_domain(self, item: Video, request: http.HttpRequest):
|
||||
enc = feedgenerator.Enclosure(
|
||||
url=self.full_link_url(
|
||||
request, f"/files/audio/{item.channel.slug}/{item.slug}.mp3"
|
||||
),
|
||||
length=str(item.download_size),
|
||||
mime_type="audio/mpeg",
|
||||
)
|
||||
return [enc]
|
|
@ -1,26 +0,0 @@
|
|||
from django import forms
|
||||
|
||||
|
||||
class AddChannelForm(forms.Form):
|
||||
channel_str = forms.CharField(label="Channel-ID / URL")
|
||||
|
||||
|
||||
class DeleteVideoForm(forms.Form):
|
||||
id = forms.IntegerField()
|
||||
|
||||
|
||||
class EditChannelForm(forms.Form):
|
||||
skip_shorts = forms.BooleanField(
|
||||
label="Skip shorts (vertical videos < 1m)", required=False
|
||||
)
|
||||
skip_livestreams = forms.BooleanField(label="Skip livestreams", required=False)
|
||||
|
||||
|
||||
class DownloadChannelForm(forms.Form):
|
||||
n_videos = forms.IntegerField(
|
||||
label="Number of videos (counting from most recent)", initial=50, min_value=1
|
||||
)
|
||||
|
||||
|
||||
class RequeueForm(forms.Form):
|
||||
id = forms.UUIDField()
|
|
@ -1,34 +0,0 @@
|
|||
"""
|
||||
Based on the django-rq package by Selwin Ong (MIT License)
|
||||
https://github.com/rq/django-rq
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from ucast import queue
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Queue a function with the given arguments."""
|
||||
|
||||
help = __doc__
|
||||
args = "<function arg arg ...>"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--timeout", "-t", type=int, dest="timeout", help="A timeout in seconds"
|
||||
)
|
||||
|
||||
parser.add_argument("args", nargs="*")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Queues the function given with the first argument with the
|
||||
parameters given with the rest of the argument list.
|
||||
"""
|
||||
verbosity = int(options.get("verbosity", 1))
|
||||
timeout = options.get("timeout")
|
||||
q = queue.get_queue()
|
||||
job = q.enqueue_call(args[0], args=args[1:], timeout=timeout)
|
||||
if verbosity:
|
||||
print("Job %s created" % job.id)
|
|
@ -1,58 +0,0 @@
|
|||
"""
|
||||
Based on the django-rq package by Selwin Ong (MIT License)
|
||||
https://github.com/rq/django-rq
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from rq_scheduler.utils import setup_loghandlers
|
||||
|
||||
from ucast import queue
|
||||
from ucast.tasks import schedule
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Runs RQ Scheduler"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--pid",
|
||||
action="store",
|
||||
dest="pid",
|
||||
default=None,
|
||||
help="PID file to write the scheduler`s pid into",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
"-i",
|
||||
type=int,
|
||||
dest="interval",
|
||||
default=60,
|
||||
help="""How often the scheduler checks for new jobs to add to the
|
||||
queue (in seconds).""",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
schedule.clear_scheduled_jobs()
|
||||
schedule.register_scheduled_jobs()
|
||||
|
||||
pid = options.get("pid")
|
||||
if pid:
|
||||
with open(os.path.expanduser(pid), "w") as fp:
|
||||
fp.write(str(os.getpid()))
|
||||
|
||||
# Verbosity is defined by default in BaseCommand for all commands
|
||||
verbosity = options.get("verbosity")
|
||||
if verbosity >= 2:
|
||||
level = "DEBUG"
|
||||
elif verbosity == 0:
|
||||
level = "WARNING"
|
||||
else:
|
||||
level = "INFO"
|
||||
setup_loghandlers(level)
|
||||
|
||||
scheduler = queue.get_scheduler(options.get("interval"))
|
||||
scheduler.run()
|
|
@ -1,122 +0,0 @@
|
|||
"""
|
||||
Based on the django-rq package by Selwin Ong (MIT License)
|
||||
https://github.com/rq/django-rq
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import click
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from ucast import queue
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Print RQ statistics"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json",
|
||||
help="Output statistics as JSON",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-y",
|
||||
"--yaml",
|
||||
action="store_true",
|
||||
dest="yaml",
|
||||
help="Output statistics as YAML",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--interval",
|
||||
dest="interval",
|
||||
type=float,
|
||||
help="Poll statistics every N seconds",
|
||||
)
|
||||
|
||||
def _print_separator(self):
|
||||
try:
|
||||
click.echo(self._separator)
|
||||
except AttributeError:
|
||||
self._separator = "-" * self.table_width
|
||||
click.echo(self._separator)
|
||||
|
||||
def _print_stats_dashboard(self, statistics):
|
||||
if self.interval:
|
||||
click.clear()
|
||||
|
||||
click.echo()
|
||||
click.echo("Django RQ CLI Dashboard")
|
||||
click.echo()
|
||||
self._print_separator()
|
||||
|
||||
# Header
|
||||
click.echo(
|
||||
"""| %-15s|%10s |%10s |%10s |%10s |%10s |%10s |"""
|
||||
% ("Name", "Queued", "Active", "Deferred", "Finished", "Failed", "Workers")
|
||||
)
|
||||
|
||||
self._print_separator()
|
||||
|
||||
click.echo(
|
||||
"""| %-15s|%10s |%10s |%10s |%10s |%10s |%10s |"""
|
||||
% (
|
||||
statistics["name"],
|
||||
statistics["jobs"],
|
||||
statistics["started_jobs"],
|
||||
statistics["deferred_jobs"],
|
||||
statistics["finished_jobs"],
|
||||
statistics["failed_jobs"],
|
||||
statistics["workers"],
|
||||
)
|
||||
)
|
||||
|
||||
self._print_separator()
|
||||
|
||||
if self.interval:
|
||||
click.echo()
|
||||
click.echo("Press 'Ctrl+c' to quit")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
if options.get("json"):
|
||||
import json
|
||||
|
||||
click.echo(json.dumps(queue.get_statistics()))
|
||||
return
|
||||
|
||||
if options.get("yaml"):
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
click.echo("Aborting. LibYAML is not installed.")
|
||||
return
|
||||
# Disable YAML alias
|
||||
yaml.Dumper.ignore_aliases = lambda *args: True
|
||||
click.echo(yaml.dump(queue.get_statistics(), default_flow_style=False))
|
||||
return
|
||||
|
||||
self.interval = options.get("interval")
|
||||
|
||||
# Arbitrary
|
||||
self.table_width = 90
|
||||
|
||||
# Do not continuously poll
|
||||
if not self.interval:
|
||||
self._print_stats_dashboard(queue.get_statistics())
|
||||
return
|
||||
|
||||
# Abuse clicks to 'live' render CLI dashboard
|
||||
try:
|
||||
while True:
|
||||
self._print_stats_dashboard(queue.get_statistics())
|
||||
time.sleep(self.interval)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
|
@ -1,103 +0,0 @@
|
|||
"""
|
||||
Based on the django-rq package by Selwin Ong (MIT License)
|
||||
https://github.com/rq/django-rq
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connections
|
||||
from redis.exceptions import ConnectionError
|
||||
from rq import use_connection
|
||||
from rq.logutils import setup_loghandlers
|
||||
|
||||
from ucast import queue
|
||||
|
||||
|
||||
def reset_db_connections():
|
||||
for c in connections.all():
|
||||
c.close()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Runs RQ worker"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--pid",
|
||||
action="store",
|
||||
dest="pid",
|
||||
default=None,
|
||||
help="PID file to write the worker`s pid into",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--burst",
|
||||
action="store_true",
|
||||
dest="burst",
|
||||
default=False,
|
||||
help="Run worker in burst mode",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with-scheduler",
|
||||
action="store_true",
|
||||
dest="with_scheduler",
|
||||
default=False,
|
||||
help="Run worker with scheduler enabled",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name",
|
||||
action="store",
|
||||
dest="name",
|
||||
default=None,
|
||||
help="Name of the worker",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--worker-ttl",
|
||||
action="store",
|
||||
type=int,
|
||||
dest="worker_ttl",
|
||||
default=420,
|
||||
help="Default worker timeout to be used",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
pid = options.get("pid")
|
||||
if pid:
|
||||
with open(os.path.expanduser(pid), "w") as fp:
|
||||
fp.write(str(os.getpid()))
|
||||
|
||||
# Verbosity is defined by default in BaseCommand for all commands
|
||||
verbosity = options.get("verbosity")
|
||||
if verbosity >= 2:
|
||||
level = "DEBUG"
|
||||
elif verbosity == 0:
|
||||
level = "WARNING"
|
||||
else:
|
||||
level = "INFO"
|
||||
setup_loghandlers(level)
|
||||
|
||||
try:
|
||||
# Instantiate a worker
|
||||
worker_kwargs = {
|
||||
"name": options["name"],
|
||||
"default_worker_ttl": options["worker_ttl"],
|
||||
}
|
||||
w = queue.get_worker(**worker_kwargs)
|
||||
|
||||
# Call use_connection to push the redis connection into LocalStack
|
||||
# without this, jobs using RQ's get_current_job() will fail
|
||||
use_connection(w.connection)
|
||||
# Close any opened DB connection before any fork
|
||||
reset_db_connections()
|
||||
|
||||
w.work(
|
||||
burst=options.get("burst", False),
|
||||
with_scheduler=options.get("with_scheduler", False),
|
||||
logging_level=level,
|
||||
)
|
||||
except ConnectionError as e:
|
||||
self.stderr.write(str(e))
|
||||
sys.exit(1)
|
|
@ -1,192 +0,0 @@
|
|||
# Generated by Django 4.0.4 on 2022-06-21 23:07
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Channel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("channel_id", models.CharField(db_index=True, max_length=30)),
|
||||
("name", models.CharField(max_length=100)),
|
||||
("slug", models.CharField(db_index=True, max_length=100)),
|
||||
("description", models.TextField()),
|
||||
("subscribers", models.CharField(max_length=20, null=True)),
|
||||
("active", models.BooleanField(default=True)),
|
||||
("skip_livestreams", models.BooleanField(default=True)),
|
||||
("skip_shorts", models.BooleanField(default=True)),
|
||||
("avatar_url", models.CharField(max_length=250, null=True)),
|
||||
(
|
||||
"last_update",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Video",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("video_id", models.CharField(db_index=True, max_length=30)),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("slug", models.CharField(db_index=True, max_length=209)),
|
||||
("published", models.DateTimeField()),
|
||||
("downloaded", models.DateTimeField(null=True)),
|
||||
("description", models.TextField()),
|
||||
("duration", models.IntegerField()),
|
||||
("is_livestream", models.BooleanField(default=False)),
|
||||
("is_short", models.BooleanField(default=False)),
|
||||
("download_size", models.IntegerField(null=True)),
|
||||
("is_deleted", models.BooleanField(default=False)),
|
||||
(
|
||||
"channel",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="ucast.channel"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
("feed_key", models.CharField(default=None, max_length=50, null=True)),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -0,0 +1 @@
|
|||
# coding=utf-8
|
74
ucast/migrations/env.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from ucast import db
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
config.set_main_option("sqlalchemy.url", str(db.DATABASE_URL))
|
||||
target_metadata = db.metadata
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
ucast/migrations/script.py.mako
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
|
@ -0,0 +1,52 @@
|
|||
"""Initial revision
|
||||
|
||||
Revision ID: 0ae786127cd8
|
||||
Revises:
|
||||
Create Date: 2022-05-03 10:03:42.224721
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0ae786127cd8"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"channels",
|
||||
sa.Column("id", sa.String(length=30), nullable=False),
|
||||
sa.Column("name", sa.Unicode(length=100), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("skip_livestreams", sa.Boolean(), nullable=False),
|
||||
sa.Column("skip_shorts", sa.Boolean(), nullable=False),
|
||||
sa.Column("keep_videos", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"videos",
|
||||
sa.Column("id", sa.String(length=30), nullable=False),
|
||||
sa.Column("channel_id", sa.String(length=30), nullable=False),
|
||||
sa.Column("title", sa.Unicode(length=200), nullable=False),
|
||||
sa.Column("slug", sa.String(length=209), nullable=False),
|
||||
sa.Column("published", sa.DateTime(), nullable=False),
|
||||
sa.Column("downloaded", sa.DateTime(), nullable=True),
|
||||
sa.Column("description", sa.UnicodeText(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["channel_id"],
|
||||
["channels.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("videos")
|
||||
op.drop_table("channels")
|
||||
# ### end Alembic commands ###
|
161
ucast/models.py
|
@ -1,139 +1,38 @@
|
|||
import base64
|
||||
import datetime
|
||||
# coding=utf-8
|
||||
import slugify
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
from starlette_core.database import Base
|
||||
|
||||
from Cryptodome import Random
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from ucast.service import util
|
||||
# metadata = sa.MetaData()
|
||||
# Base = declarative_base(metadata=metadata)
|
||||
|
||||
|
||||
def _get_unique_slug(
|
||||
str_in: str, objects: models.query.QuerySet, model_name: str
|
||||
) -> str:
|
||||
"""
|
||||
Get a new, unique slug for a database item
|
||||
class Channel(Base):
|
||||
__tablename__ = "channels"
|
||||
|
||||
:param str_in: Input string to slugify
|
||||
:param objects: Django query set
|
||||
:return: Slug
|
||||
"""
|
||||
original_slug = util.get_slug(str_in)
|
||||
slug = original_slug
|
||||
|
||||
for i in range(1, objects.count() + 2):
|
||||
if not objects.filter(slug=slug).exists():
|
||||
return slug
|
||||
|
||||
slug = f"{original_slug}_{i}"
|
||||
|
||||
raise Exception(f"unique {model_name} slug for {original_slug} could not be found")
|
||||
id = sa.Column(sa.String(30), primary_key=True)
|
||||
name = sa.Column(sa.Unicode(100), nullable=False)
|
||||
videos = orm.relationship("Video", cascade="all, delete")
|
||||
active = sa.Column(sa.Boolean, nullable=False, default=True)
|
||||
skip_livestreams = sa.Column(sa.Boolean, nullable=False, default=True)
|
||||
skip_shorts = sa.Column(sa.Boolean, nullable=False, default=True)
|
||||
keep_videos = sa.Column(sa.Integer, nullable=True, default=None)
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
channel_id = models.CharField(max_length=30, db_index=True)
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.CharField(max_length=100, db_index=True)
|
||||
description = models.TextField()
|
||||
subscribers = models.CharField(max_length=20, null=True)
|
||||
active = models.BooleanField(default=True)
|
||||
skip_livestreams = models.BooleanField(default=True)
|
||||
skip_shorts = models.BooleanField(default=True)
|
||||
avatar_url = models.CharField(max_length=250, null=True)
|
||||
last_update = models.DateTimeField(default=timezone.now)
|
||||
class Video(Base):
|
||||
__tablename__ = "videos"
|
||||
|
||||
@classmethod
|
||||
def get_new_slug(cls, name: str) -> str:
|
||||
return _get_unique_slug(name, cls.objects, "channel")
|
||||
id = sa.Column(sa.String(30), primary_key=True)
|
||||
channel_id = sa.Column(sa.String(30), sa.ForeignKey("channels.id"), nullable=False)
|
||||
channel = orm.relationship("Channel", back_populates="videos")
|
||||
title = sa.Column(sa.Unicode(200), nullable=False)
|
||||
slug = sa.Column(sa.String(209), nullable=False)
|
||||
published = sa.Column(sa.DateTime, nullable=False)
|
||||
downloaded = sa.Column(sa.DateTime, nullable=True)
|
||||
description = sa.Column(sa.UnicodeText(), nullable=False, default="")
|
||||
|
||||
def get_full_description(self) -> str:
|
||||
desc = f"https://www.youtube.com/channel/{self.channel_id}"
|
||||
if self.description:
|
||||
desc = f"{self.description}\n\n{desc}"
|
||||
return desc
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return "https://www.youtube.com/channel/" + self.channel_id
|
||||
|
||||
def should_download(self, video: "Video") -> bool:
|
||||
if self.skip_livestreams and video.is_livestream:
|
||||
return False
|
||||
|
||||
if self.skip_shorts and video.is_short:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def download_size(self) -> int:
|
||||
return self.video_set.aggregate(models.Sum("download_size")).get(
|
||||
"download_size__sum"
|
||||
)
|
||||
|
||||
def vfilter_args(self) -> dict:
|
||||
filter_args = {}
|
||||
if self.skip_livestreams:
|
||||
filter_args["is_livestream"] = False
|
||||
|
||||
if self.skip_shorts:
|
||||
filter_args["is_short"] = False
|
||||
|
||||
return filter_args
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Video(models.Model):
|
||||
video_id = models.CharField(max_length=30, db_index=True)
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.CharField(max_length=209, db_index=True)
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
published = models.DateTimeField()
|
||||
downloaded = models.DateTimeField(null=True)
|
||||
description = models.TextField()
|
||||
duration = models.IntegerField()
|
||||
is_livestream = models.BooleanField(default=False)
|
||||
is_short = models.BooleanField(default=False)
|
||||
download_size = models.IntegerField(null=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
|
||||
@classmethod
|
||||
def get_new_slug(cls, title: str, date: datetime.date, channel_id: str) -> str:
|
||||
title_w_date = f"{date.strftime('%Y%m%d')}_{title}"
|
||||
|
||||
return _get_unique_slug(
|
||||
title_w_date, cls.objects.filter(channel__channel_id=channel_id), "video"
|
||||
)
|
||||
|
||||
def get_full_description(self) -> str:
|
||||
desc = f"https://youtu.be/{self.video_id}"
|
||||
if self.description:
|
||||
desc = f"{self.description}\n\n{desc}"
|
||||
return desc
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return f"https://www.youtube.com/watch?v={self.video_id}"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
feed_key = models.CharField(max_length=50, null=True, default=None)
|
||||
|
||||
def generate_feed_key(self):
|
||||
for _ in range(0, User.objects.count()):
|
||||
key = base64.urlsafe_b64encode(Random.get_random_bytes(18)).decode()
|
||||
|
||||
if not User.objects.filter(feed_key=key).exists():
|
||||
self.feed_key = key
|
||||
self.save()
|
||||
return
|
||||
|
||||
raise Exception("unique feed key could not be found")
|
||||
|
||||
def get_feed_key(self) -> str:
|
||||
if self.feed_key is None:
|
||||
self.generate_feed_key()
|
||||
return self.feed_key
|
||||
def get_slug(self) -> str:
|
||||
title_slug = slugify.slugify(self.title, separator="_", lowercase=False)
|
||||
date_slug = self.published.strftime("%Y%m%d")
|
||||
return f"{date_slug}_{title_slug}"
|
||||
|
|
115
ucast/queue.py
|
@ -1,115 +0,0 @@
|
|||
import redis
|
||||
import rq
|
||||
import rq_scheduler
|
||||
from django.conf import settings
|
||||
from django.db.models import ObjectDoesNotExist
|
||||
from rq import registry
|
||||
|
||||
from ucast.models import Video
|
||||
from ucast.service import util
|
||||
|
||||
|
||||
def get_redis_connection() -> redis.client.Redis:
|
||||
return redis.Redis.from_url(settings.REDIS_URL)
|
||||
|
||||
|
||||
def get_queue() -> rq.Queue:
|
||||
redis_conn = get_redis_connection()
|
||||
return rq.Queue(default_timeout=settings.REDIS_QUEUE_TIMEOUT, connection=redis_conn)
|
||||
|
||||
|
||||
def get_scheduler(interval=60) -> rq_scheduler.Scheduler:
|
||||
redis_conn = get_redis_connection()
|
||||
return rq_scheduler.Scheduler(connection=redis_conn, interval=interval)
|
||||
|
||||
|
||||
def get_worker(**kwargs) -> rq.Worker:
|
||||
queue = get_queue()
|
||||
return rq.Worker(
|
||||
queue,
|
||||
connection=queue.connection,
|
||||
default_result_ttl=settings.REDIS_QUEUE_RESULT_TTL,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def enqueue(f, *args, **kwargs) -> rq.job.Job:
|
||||
queue = get_queue()
|
||||
return queue.enqueue(f, *args, **kwargs)
|
||||
|
||||
|
||||
def get_statistics() -> dict:
|
||||
"""
|
||||
Return statistics from the RQ Queue.
|
||||
|
||||
Taken from the django-rq package by Selwin Ong (MIT License)
|
||||
https://github.com/rq/django-rq
|
||||
|
||||
:return: RQ statistics
|
||||
"""
|
||||
queue = get_queue()
|
||||
connection = queue.connection
|
||||
connection_kwargs = connection.connection_pool.connection_kwargs
|
||||
|
||||
# Raw access to the first item from left of the redis list.
|
||||
# This might not be accurate since new job can be added from the left
|
||||
# with `at_front` parameters.
|
||||
# Ideally rq should supports Queue.oldest_job
|
||||
last_job_id = connection.lindex(queue.key, 0)
|
||||
last_job = queue.fetch_job(last_job_id.decode("utf-8")) if last_job_id else None
|
||||
if last_job:
|
||||
oldest_job_timestamp = util.to_localtime(last_job.enqueued_at).strftime(
|
||||
"%Y-%m-%d, %H:%M:%S"
|
||||
)
|
||||
else:
|
||||
oldest_job_timestamp = "-"
|
||||
|
||||
# parse_class and connection_pool are not needed and not JSON serializable
|
||||
connection_kwargs.pop("parser_class", None)
|
||||
connection_kwargs.pop("connection_pool", None)
|
||||
|
||||
finished_job_registry = registry.FinishedJobRegistry(queue.name, queue.connection)
|
||||
started_job_registry = registry.StartedJobRegistry(queue.name, queue.connection)
|
||||
deferred_job_registry = registry.DeferredJobRegistry(queue.name, queue.connection)
|
||||
failed_job_registry = registry.FailedJobRegistry(queue.name, queue.connection)
|
||||
scheduled_job_registry = registry.ScheduledJobRegistry(queue.name, queue.connection)
|
||||
|
||||
return {
|
||||
"name": queue.name,
|
||||
"jobs": queue.count,
|
||||
"oldest_job_timestamp": oldest_job_timestamp,
|
||||
"connection_kwargs": connection_kwargs,
|
||||
"workers": rq.Worker.count(queue=queue),
|
||||
"finished_jobs": len(finished_job_registry),
|
||||
"started_jobs": len(started_job_registry),
|
||||
"deferred_jobs": len(deferred_job_registry),
|
||||
"failed_jobs": len(failed_job_registry),
|
||||
"scheduled_jobs": len(scheduled_job_registry),
|
||||
}
|
||||
|
||||
|
||||
def get_failed_job_registry():
|
||||
queue = get_queue()
|
||||
return registry.FailedJobRegistry(queue.name, queue.connection)
|
||||
|
||||
|
||||
def get_downloading_videos(offset=0, limit=-1):
|
||||
queue = get_queue()
|
||||
v_ids = set()
|
||||
|
||||
for job in queue.get_jobs(offset, limit):
|
||||
if (
|
||||
job.func_name == "ucast.tasks.download.download_video"
|
||||
and job.args
|
||||
and job.args[0] > 0
|
||||
):
|
||||
v_ids.add(job.args[0])
|
||||
|
||||
videos = []
|
||||
for v_id in v_ids:
|
||||
try:
|
||||
videos.append(Video.objects.get(id=v_id))
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
return videos
|
Before Width: | Height: | Size: 1.4 KiB |
|
@ -1,74 +0,0 @@
|
|||
import shutil
|
||||
|
||||
from ucast.models import Channel, Video
|
||||
from ucast.service import storage, util, videoutil, youtube
|
||||
|
||||
|
||||
class ChannelAlreadyExistsException(Exception):
|
||||
def __init__(self, *args: object) -> None:
|
||||
super().__init__("channel already exists", *args)
|
||||
|
||||
|
||||
def download_channel_avatar(channel: Channel):
|
||||
store = storage.Storage()
|
||||
channel_folder = store.get_or_create_channel_folder(channel.slug)
|
||||
util.download_image_file(
|
||||
channel.avatar_url, channel_folder.file_avatar, videoutil.AVATAR_SIZE
|
||||
)
|
||||
videoutil.resize_avatar(channel_folder.file_avatar, channel_folder.file_avatar_sm)
|
||||
|
||||
|
||||
def create_channel(channel_str: str) -> Channel:
|
||||
if youtube.CHANID_REGEX.match(channel_str):
|
||||
if Channel.objects.filter(channel_id=channel_str).exists():
|
||||
raise ChannelAlreadyExistsException()
|
||||
|
||||
channel_url = youtube.channel_url_from_str(channel_str)
|
||||
channel_data = youtube.get_channel_metadata(channel_url)
|
||||
|
||||
if Channel.objects.filter(channel_id=channel_data.id).exists():
|
||||
raise ChannelAlreadyExistsException()
|
||||
|
||||
channel_slug = Channel.get_new_slug(channel_data.name)
|
||||
|
||||
channel = Channel(
|
||||
channel_id=channel_data.id,
|
||||
name=channel_data.name,
|
||||
slug=channel_slug,
|
||||
description=channel_data.description,
|
||||
subscribers=channel_data.subscribers,
|
||||
avatar_url=channel_data.avatar_url,
|
||||
)
|
||||
|
||||
download_channel_avatar(channel)
|
||||
|
||||
channel.save()
|
||||
return channel
|
||||
|
||||
|
||||
def delete_video(id: int):
|
||||
video = Video.objects.get(id=id)
|
||||
|
||||
store = storage.Storage()
|
||||
channel_folder = store.get_channel_folder(video.channel.slug)
|
||||
|
||||
util.remove_if_exists(channel_folder.get_audio(video.slug))
|
||||
util.remove_if_exists(channel_folder.get_cover(video.slug))
|
||||
util.remove_if_exists(channel_folder.get_thumbnail(video.slug))
|
||||
util.remove_if_exists(channel_folder.get_thumbnail(video.slug, True))
|
||||
|
||||
video.is_deleted = True
|
||||
video.downloaded = None
|
||||
video.download_size = None
|
||||
video.save()
|
||||
|
||||
|
||||
def delete_channel(id: int):
|
||||
channel = Channel.objects.get(id=id)
|
||||
|
||||
store = storage.Storage()
|
||||
channel_folder = store.get_channel_folder(channel.slug)
|
||||
|
||||
shutil.rmtree(channel_folder.dir_root)
|
||||
|
||||
channel.delete()
|
|
@ -1,451 +0,0 @@
|
|||
import math
|
||||
import random
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import List, Literal, Optional, Tuple
|
||||
|
||||
import wcag_contrast_ratio
|
||||
from colorthief import ColorThief
|
||||
from fonts.ttf import SourceSansPro
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
|
||||
|
||||
from ucast.service import typ, util
|
||||
|
||||
COVER_STYLE_BLUR = "blur"
|
||||
COVER_STYLE_GRADIENT = "gradient"
|
||||
CoverStyle = Literal["blur", "gradient"]
|
||||
|
||||
CHAR_ELLIPSIS = "…"
|
||||
COVER_WIDTH = 500
|
||||
MIN_CONTRAST = 4.5
|
||||
|
||||
|
||||
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: List of lines
|
||||
"""
|
||||
if height < font.size:
|
||||
return []
|
||||
|
||||
max_lines = math.floor((height - font.size) / (font.size + line_spacing)) + 1
|
||||
|
||||
lines = []
|
||||
line = ""
|
||||
|
||||
for word in text.split(" "):
|
||||
if len(lines) >= max_lines:
|
||||
line = word
|
||||
break
|
||||
|
||||
if line == "":
|
||||
nline = word
|
||||
else:
|
||||
nline = line + " " + word
|
||||
|
||||
if font.getsize(nline)[0] <= width:
|
||||
line = nline
|
||||
elif line != "":
|
||||
lines.append(line)
|
||||
line = word
|
||||
else:
|
||||
# try to trim current word
|
||||
while nline:
|
||||
nline = nline[:-1]
|
||||
nline_e = nline + CHAR_ELLIPSIS
|
||||
if font.getsize(nline_e)[0] <= width:
|
||||
lines.append(nline_e)
|
||||
break
|
||||
|
||||
if line != "":
|
||||
if len(lines) >= max_lines:
|
||||
# Drop the last line and add ... to the end
|
||||
lastline = lines[-1] + CHAR_ELLIPSIS
|
||||
if font.getsize(lastline)[0] <= width:
|
||||
lines[-1] = lastline
|
||||
else:
|
||||
i_last_space = lines[-1].rfind(" ")
|
||||
lines[-1] = lines[-1][:i_last_space] + CHAR_ELLIPSIS
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _draw_text_box(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
box: Tuple[int, int, int, int],
|
||||
text: str,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
color: typ.Color = (0, 0, 0),
|
||||
line_spacing=0,
|
||||
vertical_center=True,
|
||||
):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
x_tl, y_tl, x_br, y_br = box
|
||||
height = y_br - y_tl
|
||||
width = x_br - x_tl
|
||||
sanitized_text = util.strip_emoji(text)
|
||||
|
||||
lines = _split_text(height, width, sanitized_text, font, line_spacing)
|
||||
|
||||
y_start = y_tl
|
||||
if vertical_center:
|
||||
text_height = len(lines) * (font.size + line_spacing) - line_spacing
|
||||
y_start += int((height - text_height) / 2)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
y_pos = y_start + i * (font.size + line_spacing)
|
||||
draw.text((x_tl, y_pos), line, color, font)
|
||||
|
||||
|
||||
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, 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 _color_to_float(color: typ.Color) -> tuple[float, ...]:
|
||||
return tuple(c / 255 for c in color)
|
||||
|
||||
|
||||
def _get_text_color(bg_color: typ.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_float = _color_to_float(bg_color)
|
||||
c_blk = wcag_contrast_ratio.rgb((0, 0, 0), color_float)
|
||||
c_wht = wcag_contrast_ratio.rgb((1, 1, 1), color_float)
|
||||
if c_wht > c_blk:
|
||||
return 255, 255, 255
|
||||
return 0, 0, 0
|
||||
|
||||
|
||||
def _get_baseimage(thumbnail: Image.Image, style: CoverStyle):
|
||||
"""
|
||||
Return the background image for the cover.
|
||||
|
||||
:param thumbnail: Thumbnail image object
|
||||
:param style: Style of the cover image
|
||||
:return: Base image
|
||||
"""
|
||||
cover = Image.new("RGB", (COVER_WIDTH, COVER_WIDTH))
|
||||
|
||||
if style == COVER_STYLE_GRADIENT:
|
||||
# Thumbnail with color gradient background
|
||||
|
||||
# Get dominant colors from the top and bottom 20% of the thumbnail image
|
||||
top_part = thumbnail.crop((0, 0, COVER_WIDTH, int(thumbnail.height * 0.2)))
|
||||
bottom_part = thumbnail.crop(
|
||||
(0, int(thumbnail.height * 0.8), COVER_WIDTH, thumbnail.height)
|
||||
)
|
||||
top_color = _get_dominant_color(top_part)
|
||||
bottom_color = _get_dominant_color(bottom_part)
|
||||
|
||||
cover_draw = ImageDraw.Draw(cover)
|
||||
|
||||
for i, color in enumerate(
|
||||
_interpolate_color(top_color, bottom_color, cover.height)
|
||||
):
|
||||
cover_draw.line(((0, i), (cover.width, i)), tuple(color), 1)
|
||||
else:
|
||||
# Thumbnail with blurred background
|
||||
ctn_width = int(COVER_WIDTH / thumbnail.height * thumbnail.width)
|
||||
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))
|
||||
|
||||
return cover
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
# Scale the thumbnail image down to cover size
|
||||
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_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_y_top, COVER_WIDTH, tn_crop_y_bottom))
|
||||
|
||||
|
||||
def _prepare_text_background(
|
||||
base_img: Image.Image, bboxes: List[Tuple[int, int, int, int]]
|
||||
) -> Tuple[Image.Image, typ.Color]:
|
||||
"""
|
||||
Return the preferred text color (black or white) and darken
|
||||
the image if necessary
|
||||
|
||||
:param base_img: Image object
|
||||
:param bboxes: Text boxes
|
||||
:return: Updated image, text color
|
||||
"""
|
||||
rng = random.Random()
|
||||
rng.seed(0x9B38D30461B7F0E6)
|
||||
|
||||
min_contrast_bk = 22
|
||||
min_contrast_wt = 22
|
||||
worst_color_wt = None
|
||||
|
||||
def corr_x(x: int) -> int:
|
||||
return min(max(0, x), base_img.width)
|
||||
|
||||
def corr_y(y: int) -> int:
|
||||
return min(max(0, y), base_img.height)
|
||||
|
||||
for bbox in bboxes:
|
||||
x_tl, y_tl, x_br, y_br = bbox
|
||||
x_tl = corr_x(x_tl)
|
||||
y_tl = corr_y(y_tl)
|
||||
x_br = corr_x(x_br)
|
||||
y_br = corr_y(y_br)
|
||||
|
||||
height = y_br - y_tl
|
||||
width = x_br - x_tl
|
||||
|
||||
for _ in range(math.ceil(width * height * 0.01)):
|
||||
target_pos = (rng.randint(x_tl, x_br - 1), rng.randint(y_tl, y_br - 1))
|
||||
img_color = base_img.getpixel(target_pos)
|
||||
img_color_float = _color_to_float(img_color)
|
||||
|
||||
ct_bk = wcag_contrast_ratio.rgb((0, 0, 0), img_color_float)
|
||||
ct_wt = wcag_contrast_ratio.rgb((1, 1, 1), img_color_float)
|
||||
|
||||
if ct_bk < min_contrast_bk:
|
||||
min_contrast_bk = ct_bk
|
||||
|
||||
if ct_wt < min_contrast_wt:
|
||||
worst_color_wt = img_color
|
||||
min_contrast_wt = ct_wt
|
||||
|
||||
if min_contrast_bk >= MIN_CONTRAST:
|
||||
return base_img, (0, 0, 0)
|
||||
if min_contrast_wt >= MIN_CONTRAST:
|
||||
return base_img, (255, 255, 255)
|
||||
|
||||
pixel = Image.new("RGB", (1, 1), worst_color_wt)
|
||||
|
||||
for i in range(1, 100):
|
||||
brightness_f = 1 - i / 100
|
||||
contrast_f = 1 - i / 1000
|
||||
|
||||
pixel_c = ImageEnhance.Brightness(pixel).enhance(brightness_f)
|
||||
pixel_c = ImageEnhance.Contrast(pixel_c).enhance(contrast_f)
|
||||
new_color = pixel_c.getpixel((0, 0))
|
||||
|
||||
if (
|
||||
wcag_contrast_ratio.rgb((1, 1, 1), _color_to_float(new_color))
|
||||
>= MIN_CONTRAST
|
||||
):
|
||||
new_img = ImageEnhance.Brightness(base_img).enhance(brightness_f)
|
||||
new_img = ImageEnhance.Contrast(new_img).enhance(contrast_f)
|
||||
return new_img, (255, 255, 255)
|
||||
|
||||
return base_img, (255, 255, 255)
|
||||
|
||||
|
||||
def _draw_text_avatar(
|
||||
cover: Image.Image,
|
||||
avatar: Optional[Image.Image],
|
||||
title: str,
|
||||
channel: str,
|
||||
) -> Image.Image:
|
||||
# Add channel avatar
|
||||
avt_margin = 0
|
||||
avt_size = 0
|
||||
|
||||
tn_16_9_height = int(COVER_WIDTH / 16 * 9) # typical: 281
|
||||
tn_16_9_margin = int((COVER_WIDTH - tn_16_9_height) / 2) # typical: 110
|
||||
|
||||
if avatar:
|
||||
avt_margin = int(tn_16_9_margin * 0.05) # typical: 14
|
||||
avt_size = tn_16_9_margin - 2 * avt_margin # typical: 82
|
||||
|
||||
# Add text
|
||||
text_margin_x = 16
|
||||
text_margin_topleft = avt_margin + avt_size + text_margin_x # typical: 112
|
||||
text_vertical_offset = -17
|
||||
text_line_space = -4
|
||||
|
||||
fnt = ImageFont.truetype(SourceSansPro, 50)
|
||||
top_text_box = ( # typical: (112, -17, 484, 110)
|
||||
text_margin_topleft,
|
||||
text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x,
|
||||
tn_16_9_margin,
|
||||
)
|
||||
bottom_text_box = ( # typical: (16, 373, 484, 500)
|
||||
text_margin_x,
|
||||
COVER_WIDTH - tn_16_9_margin + text_vertical_offset,
|
||||
COVER_WIDTH - text_margin_x,
|
||||
COVER_WIDTH,
|
||||
)
|
||||
|
||||
cover, text_color = _prepare_text_background(cover, [top_text_box, bottom_text_box])
|
||||
cover_draw = ImageDraw.Draw(cover)
|
||||
|
||||
_draw_text_box(
|
||||
cover_draw,
|
||||
top_text_box,
|
||||
channel,
|
||||
fnt,
|
||||
text_color,
|
||||
text_line_space,
|
||||
)
|
||||
_draw_text_box(
|
||||
cover_draw,
|
||||
bottom_text_box,
|
||||
title,
|
||||
fnt,
|
||||
text_color,
|
||||
text_line_space,
|
||||
)
|
||||
|
||||
if avatar:
|
||||
avt = avatar.resize((avt_size, avt_size), Image.Resampling.LANCZOS)
|
||||
|
||||
circle_mask = Image.new("L", (avt_size, avt_size))
|
||||
circle_mask_draw = ImageDraw.Draw(circle_mask)
|
||||
circle_mask_draw.ellipse((0, 0, avt_size, avt_size), 255)
|
||||
|
||||
cover.paste(avt, (avt_margin, avt_margin), circle_mask)
|
||||
|
||||
return cover
|
||||
|
||||
|
||||
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)
|
||||
|
||||
cover = _get_baseimage(tn, style)
|
||||
|
||||
cover = _draw_text_avatar(cover, avatar, title, channel)
|
||||
|
||||
# Insert thumbnail image in the middle
|
||||
tn_margin = int((COVER_WIDTH - tn.height) / 2)
|
||||
cover.paste(tn, (0, tn_margin))
|
||||
|
||||
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)
|
||||
|
||||
return cover
|
||||
|
||||
|
||||
def create_cover_file(
|
||||
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)
|
|
@ -1,40 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from django.utils.xmlutils import SimplerXMLGenerator
|
||||
|
||||
from ucast.models import Channel
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedElement:
|
||||
url: str
|
||||
title: str
|
||||
|
||||
|
||||
def __add_feed_element(handler: SimplerXMLGenerator, element: FeedElement):
|
||||
handler.addQuickElement(
|
||||
"outline", attrs={"xmlUrl": element.url, "title": element.title}
|
||||
)
|
||||
|
||||
|
||||
def write_opml(elements: Iterable[FeedElement], outfile):
|
||||
handler = SimplerXMLGenerator(outfile, "utf-8", short_empty_elements=True)
|
||||
handler.startDocument()
|
||||
handler.startElement("opml", {})
|
||||
handler.addQuickElement("head")
|
||||
handler.startElement("body", {"version": "1.0"})
|
||||
|
||||
for element in elements:
|
||||
__add_feed_element(handler, element)
|
||||
|
||||
handler.endElement("body")
|
||||
handler.endElement("opml")
|
||||
handler.endDocument()
|
||||
|
||||
|
||||
def write_channels_opml(channels: Iterable[Channel], site_url: str, key: str, outfile):
|
||||
elements = [
|
||||
FeedElement(f"{site_url}/feed/{c.slug}?key={key}", c.name) for c in channels
|
||||
]
|
||||
write_opml(elements, outfile)
|
|
@ -1,244 +0,0 @@
|
|||
"""
|
||||
Based on the scrapetube package from dermasmid (MIT License)
|
||||
https://github.com/dermasmid/scrapetube
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from typing import Generator, Literal, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def get_channel(
|
||||
channel_url: str,
|
||||
limit: int = None,
|
||||
sleep: int = 1,
|
||||
sort_by: Literal["newest", "oldest", "popular"] = "newest",
|
||||
) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Get videos for a channel.
|
||||
|
||||
:param channel_url: The url of the channel you want to get the videos for.
|
||||
:param limit: Limit the number of videos you want to get.
|
||||
:param sleep: Seconds to sleep between API calls to youtube, in order to prevent
|
||||
getting blocked. Defaults to ``1``.
|
||||
:param sort_by: In what order to retrive to videos. Pass one of the following values.
|
||||
``"newest"``: Get the new videos first.
|
||||
``"oldest"``: Get the old videos first.
|
||||
``"popular"``: Get the popular videos first.
|
||||
Defaults to ``"newest"``.
|
||||
:return: Generator providing the videos
|
||||
"""
|
||||
|
||||
sort_by_map = {"newest": "dd", "oldest": "da", "popular": "p"}
|
||||
url = "{url}/videos?view=0&sort={sort_by}&flow=grid".format(
|
||||
url=channel_url,
|
||||
sort_by=sort_by_map[sort_by],
|
||||
)
|
||||
api_endpoint = "https://www.youtube.com/youtubei/v1/browse"
|
||||
videos = _get_videos(url, api_endpoint, "gridVideoRenderer", limit, sleep)
|
||||
for video in videos:
|
||||
yield video
|
||||
|
||||
|
||||
def get_channel_metadata(channel_url: str) -> dict:
|
||||
"""
|
||||
Get metadata of a channel.
|
||||
|
||||
:param channel_url: Channel URL
|
||||
:return: Raw channel metadata
|
||||
"""
|
||||
session = _new_session()
|
||||
|
||||
url = f"{channel_url}/videos?view=0&flow=grid"
|
||||
|
||||
html = _get_initial_data(session, url)
|
||||
return json.loads(_get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}")
|
||||
|
||||
|
||||
def get_playlist(
|
||||
playlist_id: str, limit: int = None, sleep: int = 1
|
||||
) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Get videos for a playlist.
|
||||
|
||||
:param playlist_id: The playlist id from the playlist you want to get the videos for.
|
||||
:param limit: Limit the number of videos you want to get.
|
||||
:param sleep: Seconds to sleep between API calls to youtube, in order to prevent
|
||||
getting blocked. Defaults to ``1``.
|
||||
:return: Generator providing the videos
|
||||
"""
|
||||
|
||||
url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
api_endpoint = "https://www.youtube.com/youtubei/v1/browse"
|
||||
videos = _get_videos(url, api_endpoint, "playlistVideoRenderer", limit, sleep)
|
||||
for video in videos:
|
||||
yield video
|
||||
|
||||
|
||||
def get_search(
|
||||
query: str,
|
||||
limit: int = None,
|
||||
sleep: int = 1,
|
||||
sort_by: Literal["relevance", "upload_date", "view_count", "rating"] = "relevance",
|
||||
results_type: Literal["video", "channel", "playlist", "movie"] = "video",
|
||||
) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Search youtube and get videos.
|
||||
|
||||
:param query: The term you want to search for.
|
||||
:param limit: Limit the number of videos you want to get.
|
||||
:param sleep: Seconds to sleep between API calls to youtube, in order to prevent
|
||||
getting blocked. Defaults to ``1``.
|
||||
:param sort_by: In what order to retrive to videos. Pass one of the following values.
|
||||
``"relevance"``: Get the new videos in order of relevance.
|
||||
``"upload_date"``: Get the new videos first.
|
||||
``"view_count"``: Get the popular videos first.
|
||||
``"rating"``: Get videos with more likes first.
|
||||
Defaults to ``"relevance"``.
|
||||
:param results_type: What type you want to search for.
|
||||
Pass one of the following values: ``"video"|"channel"|
|
||||
"playlist"|"movie"``. Defaults to ``"video"``.
|
||||
:return: Generator providing the videos
|
||||
"""
|
||||
|
||||
sort_by_map = {
|
||||
"relevance": "A",
|
||||
"upload_date": "I",
|
||||
"view_count": "M",
|
||||
"rating": "E",
|
||||
}
|
||||
|
||||
results_type_map = {
|
||||
"video": ["B", "videoRenderer"],
|
||||
"channel": ["C", "channelRenderer"],
|
||||
"playlist": ["D", "playlistRenderer"],
|
||||
"movie": ["E", "videoRenderer"],
|
||||
}
|
||||
|
||||
param_string = f"CA{sort_by_map[sort_by]}SAhA{results_type_map[results_type][0]}"
|
||||
url = f"https://www.youtube.com/results?search_query={query}&sp={param_string}"
|
||||
api_endpoint = "https://www.youtube.com/youtubei/v1/search"
|
||||
videos = _get_videos(
|
||||
url, api_endpoint, results_type_map[results_type][1], limit, sleep
|
||||
)
|
||||
for video in videos:
|
||||
yield video
|
||||
|
||||
|
||||
def _get_videos(
|
||||
url: str, api_endpoint: str, selector: str, limit: int, sleep: int
|
||||
) -> Generator[dict, None, None]:
|
||||
session = _new_session()
|
||||
is_first = True
|
||||
quit = False
|
||||
count = 0
|
||||
while True:
|
||||
if is_first:
|
||||
html = _get_initial_data(session, url)
|
||||
client = json.loads(
|
||||
_get_json_from_html(html, "INNERTUBE_CONTEXT", 2, '"}},') + '"}}'
|
||||
)["client"]
|
||||
api_key = _get_json_from_html(html, "innertubeApiKey", 3)
|
||||
session.headers["X-YouTube-Client-Name"] = "1"
|
||||
session.headers["X-YouTube-Client-Version"] = client["clientVersion"]
|
||||
data = json.loads(
|
||||
_get_json_from_html(html, "var ytInitialData = ", 0, "};") + "}"
|
||||
)
|
||||
next_data = _get_next_data(data)
|
||||
is_first = False
|
||||
else:
|
||||
data = _get_ajax_data(session, api_endpoint, api_key, next_data, client)
|
||||
next_data = _get_next_data(data)
|
||||
for result in _get_videos_items(data, selector):
|
||||
try:
|
||||
count += 1
|
||||
yield result
|
||||
if count == limit:
|
||||
quit = True
|
||||
break
|
||||
except GeneratorExit:
|
||||
quit = True
|
||||
break
|
||||
|
||||
if not next_data or quit:
|
||||
break
|
||||
|
||||
time.sleep(sleep)
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def _new_session() -> requests.Session:
|
||||
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"
|
||||
session.headers["Accept-Language"] = "en"
|
||||
return session
|
||||
|
||||
|
||||
def _get_initial_data(session: requests.Session, url: str) -> str:
|
||||
response = session.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
if "uxe=" in response.request.url:
|
||||
session.cookies.set("CONSENT", "YES+cb", domain=".youtube.com")
|
||||
response = session.get(url)
|
||||
|
||||
html = response.text
|
||||
return html
|
||||
|
||||
|
||||
def _get_ajax_data(
|
||||
session: requests.Session,
|
||||
api_endpoint: str,
|
||||
api_key: str,
|
||||
next_data: dict,
|
||||
client: dict,
|
||||
) -> dict:
|
||||
data = {
|
||||
"context": {"clickTracking": next_data["click_params"], "client": client},
|
||||
"continuation": next_data["token"],
|
||||
}
|
||||
response = session.post(api_endpoint, params={"key": api_key}, json=data)
|
||||
return response.json()
|
||||
|
||||
|
||||
def _get_json_from_html(
|
||||
html: str, key: str, num_chars: int = 2, stop: str = '"'
|
||||
) -> str:
|
||||
pos_begin = html.find(key) + len(key) + num_chars
|
||||
pos_end = html.find(stop, pos_begin)
|
||||
return html[pos_begin:pos_end]
|
||||
|
||||
|
||||
def _get_next_data(data: dict) -> Optional[dict]:
|
||||
raw_next_data = next(_search_dict(data, "continuationEndpoint"), None)
|
||||
if not raw_next_data:
|
||||
return None
|
||||
next_data = {
|
||||
"token": raw_next_data["continuationCommand"]["token"],
|
||||
"click_params": {"clickTrackingParams": raw_next_data["clickTrackingParams"]},
|
||||
}
|
||||
|
||||
return next_data
|
||||
|
||||
|
||||
def _search_dict(partial: dict, search_key: str) -> Generator[dict, None, None]:
|
||||
stack = [partial]
|
||||
while stack:
|
||||
current_item = stack.pop(0)
|
||||
if isinstance(current_item, dict):
|
||||
for key, value in current_item.items():
|
||||
if key == search_key:
|
||||
yield value
|
||||
else:
|
||||
stack.append(value)
|
||||
elif isinstance(current_item, list):
|
||||
for value in current_item:
|
||||
stack.append(value)
|
||||
|
||||
|
||||
def _get_videos_items(data: dict, selector: str) -> Generator[dict, None, None]:
|
||||
return _search_dict(data, selector)
|
|
@ -1,96 +0,0 @@
|
|||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
UCAST_DIRNAME = "_ucast"
|
||||
|
||||
|
||||
class ChannelFolder:
|
||||
def __init__(self, dir_root: Path):
|
||||
self.dir_root = dir_root
|
||||
dir_ucast = self.dir_root / UCAST_DIRNAME
|
||||
|
||||
self.file_avatar = dir_ucast / "avatar.jpg"
|
||||
self.file_avatar_sm = dir_ucast / "avatar_sm.webp"
|
||||
|
||||
self.dir_covers = dir_ucast / "covers"
|
||||
self.dir_thumbnails = dir_ucast / "thumbnails"
|
||||
|
||||
@staticmethod
|
||||
def _glob_file(parent_dir: Path, glob: str, default_filename: str = None) -> Path:
|
||||
try:
|
||||
return parent_dir.glob(glob).__next__()
|
||||
except StopIteration:
|
||||
if default_filename:
|
||||
return parent_dir / default_filename
|
||||
raise FileNotFoundError(f"file {str(parent_dir)}/{glob} not found")
|
||||
|
||||
def does_exist(self) -> bool:
|
||||
return os.path.isdir(self.dir_covers)
|
||||
|
||||
def create(self):
|
||||
os.makedirs(self.dir_covers, exist_ok=True)
|
||||
os.makedirs(self.dir_thumbnails, exist_ok=True)
|
||||
|
||||
def get_cover(self, title_slug: str) -> Path:
|
||||
return self.dir_covers / f"{title_slug}.png"
|
||||
|
||||
def get_thumbnail(self, title_slug: str, sm=False) -> Path:
|
||||
filename = title_slug
|
||||
if sm:
|
||||
filename += "_sm"
|
||||
|
||||
return self._glob_file(self.dir_thumbnails, f"{filename}.*", f"{filename}.webp")
|
||||
|
||||
def get_audio(self, title_slug: str) -> Path:
|
||||
return self.dir_root / f"{title_slug}.mp3"
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self):
|
||||
self.dir_data = settings.DOWNLOAD_ROOT
|
||||
|
||||
def get_channel_folder(self, channel_slug: str) -> ChannelFolder:
|
||||
cf = ChannelFolder(self.dir_data / channel_slug)
|
||||
if not cf.does_exist():
|
||||
raise FileNotFoundError
|
||||
return cf
|
||||
|
||||
def get_or_create_channel_folder(self, channel_slug: str) -> ChannelFolder:
|
||||
cf = ChannelFolder(self.dir_data / channel_slug)
|
||||
if not cf.does_exist():
|
||||
cf.create()
|
||||
return cf
|
||||
|
||||
|
||||
class Cache:
|
||||
def __init__(self):
|
||||
self.dir_cache = settings.CACHE_ROOT
|
||||
self.dir_ytdlp_cache = self.dir_cache / "yt_dlp"
|
||||
os.makedirs(self.dir_ytdlp_cache, exist_ok=True)
|
||||
|
||||
def create_tmpdir(self, prefix="dld") -> tempfile.TemporaryDirectory:
|
||||
return tempfile.TemporaryDirectory(prefix=prefix + "_", dir=self.dir_cache)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Delete temporary directories that are older than 24h and are most likely left
|
||||
over after unexpected shutdowns.
|
||||
"""
|
||||
for dirname in os.listdir(self.dir_cache):
|
||||
if dirname == "yt_dlp":
|
||||
continue
|
||||
|
||||
try:
|
||||
ctime = os.path.getctime(dirname)
|
||||
# Cache folders may get removed by concurrent jobs
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
age = datetime.now() - datetime.fromtimestamp(ctime)
|
||||
|
||||
if age > timedelta(days=1):
|
||||
shutil.rmtree(self.dir_cache / dirname, ignore_errors=True)
|
|
@ -1,202 +0,0 @@
|
|||
import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
from urllib import parse
|
||||
|
||||
import requests
|
||||
import slugify
|
||||
from django.utils import timezone
|
||||
from PIL import Image
|
||||
|
||||
EMOJI_PATTERN = re.compile(
|
||||
"["
|
||||
"\U0001F1E0-\U0001F1FF" # flags (iOS)
|
||||
"\U0001F300-\U0001F5FF" # symbols & pictographs
|
||||
"\U0001F600-\U0001F64F" # emoticons
|
||||
"\U0001F680-\U0001F6FF" # transport & map symbols
|
||||
"\U0001F700-\U0001F77F" # alchemical symbols
|
||||
"\U0001F780-\U0001F7FF" # Geometric Shapes Extended
|
||||
"\U0001F800-\U0001F8FF" # Supplemental Arrows-C
|
||||
"\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs
|
||||
"\U0001FA00-\U0001FA6F" # Chess Symbols
|
||||
"\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A
|
||||
"\U00002702-\U000027B0" # Dingbats
|
||||
"\U000024C2-\U0001F251"
|
||||
"]+"
|
||||
)
|
||||
|
||||
|
||||
def download_file(url: str, download_path: Path):
|
||||
r = requests.get(url, allow_redirects=True)
|
||||
r.raise_for_status()
|
||||
open(download_path, "wb").write(r.content)
|
||||
|
||||
|
||||
def resize_image(img: Image, resize: Tuple[int, int]):
|
||||
if img.size == resize:
|
||||
return img
|
||||
|
||||
w_ratio = resize[0] / img.width
|
||||
h_ratio = resize[1] / img.height
|
||||
box = None
|
||||
|
||||
# Too tall
|
||||
if h_ratio < w_ratio:
|
||||
crop_height = int(img.width / resize[0] * resize[1])
|
||||
border = int((img.height - crop_height) / 2)
|
||||
box = (0, border, img.width, img.height - border)
|
||||
# Too wide
|
||||
elif w_ratio < h_ratio:
|
||||
crop_width = int(img.height / resize[1] * resize[0])
|
||||
border = int((img.width - crop_width) / 2)
|
||||
box = (border, 0, img.width - border, img.height)
|
||||
|
||||
return img.resize(resize, Image.Resampling.LANCZOS, box)
|
||||
|
||||
|
||||
def download_image_file(
|
||||
url: str, download_path: Path, resize: Optional[Tuple[int, int]] = None
|
||||
):
|
||||
"""
|
||||
Download an image and convert it to the type given
|
||||
by the path.
|
||||
|
||||
:param url: Image URL
|
||||
:param download_path: Download path
|
||||
:param resize: target image size (set to None for no resizing)
|
||||
"""
|
||||
r = requests.get(url, allow_redirects=True)
|
||||
r.raise_for_status()
|
||||
|
||||
img = Image.open(io.BytesIO(r.content))
|
||||
img_ext = img.format.lower()
|
||||
if img_ext == "jpeg":
|
||||
img_ext = "jpg"
|
||||
|
||||
do_resize = resize and img.size != resize
|
||||
if do_resize:
|
||||
img = resize_image(img, resize)
|
||||
|
||||
if not do_resize and "." + img_ext == download_path.suffix:
|
||||
open(download_path, "wb").write(r.content)
|
||||
else:
|
||||
img.save(download_path)
|
||||
|
||||
|
||||
def get_slug(text: str) -> str:
|
||||
return slugify.slugify(text, lowercase=False, separator="_")
|
||||
|
||||
|
||||
def to_localtime(time: datetime.datetime):
|
||||
"""Converts naive datetime to localtime based on settings"""
|
||||
|
||||
utc_time = time.replace(tzinfo=datetime.timezone.utc)
|
||||
to_zone = timezone.get_default_timezone()
|
||||
return utc_time.astimezone(to_zone)
|
||||
|
||||
|
||||
def _get_np_attrs(o) -> dict:
|
||||
"""
|
||||
Return all non-protected attributes of the given object.
|
||||
:param o: Object
|
||||
:return: Dict of attributes
|
||||
"""
|
||||
return {k: v for k, v in o.__dict__.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
def serializer(o: Any) -> Union[str, dict, int, float, bool]:
|
||||
"""
|
||||
Serialize object to json-storable format
|
||||
:param o: Object to serialize
|
||||
:return: Serialized output data
|
||||
"""
|
||||
if hasattr(o, "serialize"):
|
||||
return o.serialize()
|
||||
if isinstance(o, (datetime.datetime, datetime.date)):
|
||||
return o.isoformat()
|
||||
if isinstance(o, (bool, float, int)):
|
||||
return o
|
||||
if hasattr(o, "__dict__"):
|
||||
return _get_np_attrs(o)
|
||||
return str(o)
|
||||
|
||||
|
||||
def to_json(o, pretty=False) -> str:
|
||||
"""
|
||||
Convert object to json.
|
||||
Uses the ``serialize()`` method of the target object if available.
|
||||
:param o: Object to serialize
|
||||
:param pretty: Prettify with indents
|
||||
:return: JSON string
|
||||
"""
|
||||
return json.dumps(
|
||||
o, default=serializer, indent=2 if pretty else None, ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _urlencode(query, safe="", encoding=None, errors=None, quote_via=parse.quote_plus):
|
||||
"""
|
||||
Same as the urllib.parse.urlencode function, but does not add an
|
||||
equals sign to no-value flags.
|
||||
"""
|
||||
|
||||
if hasattr(query, "items"):
|
||||
query = query.items()
|
||||
else:
|
||||
# It's a bother at times that strings and string-like objects are
|
||||
# sequences.
|
||||
try:
|
||||
# non-sequence items should not work with len()
|
||||
# non-empty strings will fail this
|
||||
if len(query) and not isinstance(query[0], tuple):
|
||||
raise TypeError
|
||||
# Zero-length sequences of all types will get here and succeed,
|
||||
# but that's a minor nit. Since the original implementation
|
||||
# allowed empty dicts that type of behavior probably should be
|
||||
# preserved for consistency
|
||||
except TypeError:
|
||||
raise TypeError("not a valid non-string sequence " "or mapping object")
|
||||
|
||||
lst = []
|
||||
|
||||
for k, v in query:
|
||||
if isinstance(k, bytes):
|
||||
k = quote_via(k, safe)
|
||||
else:
|
||||
k = quote_via(str(k), safe, encoding, errors)
|
||||
|
||||
if isinstance(v, bytes):
|
||||
v = quote_via(v, safe)
|
||||
else:
|
||||
v = quote_via(str(v), safe, encoding, errors)
|
||||
|
||||
if v:
|
||||
lst.append(k + "=" + v)
|
||||
else:
|
||||
lst.append(k)
|
||||
|
||||
return "&".join(lst)
|
||||
|
||||
|
||||
def add_key_to_url(url: str, key: str):
|
||||
if not key:
|
||||
return url
|
||||
url_parts = list(parse.urlparse(url))
|
||||
query = dict(parse.parse_qsl(url_parts[4], keep_blank_values=True))
|
||||
query["key"] = key
|
||||
url_parts[4] = _urlencode(query)
|
||||
return parse.urlunparse(url_parts)
|
||||
|
||||
|
||||
def remove_if_exists(file: Path):
|
||||
if os.path.isfile(file):
|
||||
os.remove(file)
|
||||
|
||||
|
||||
def strip_emoji(str_in: str) -> str:
|
||||
stripped = EMOJI_PATTERN.sub("", str_in)
|
||||
return re.sub(" +", " ", stripped)
|
|
@ -1,52 +0,0 @@
|
|||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from mutagen import id3
|
||||
from PIL import Image
|
||||
|
||||
AVATAR_SM_WIDTH = 100
|
||||
THUMBNAIL_SM_WIDTH = 360
|
||||
THUMBNAIL_SIZE = (1280, 720)
|
||||
AVATAR_SIZE = (900, 900)
|
||||
|
||||
|
||||
def tag_audio(
|
||||
audio_path: Path,
|
||||
title: str,
|
||||
channel: str,
|
||||
published: date,
|
||||
description: str,
|
||||
cover_path: Path,
|
||||
):
|
||||
title_text = f"{published.isoformat()} {title}"
|
||||
|
||||
tag = id3.ID3(audio_path)
|
||||
tag["TPE1"] = id3.TPE1(encoding=3, text=channel) # Artist
|
||||
tag["TALB"] = id3.TALB(encoding=3, text=channel) # Album
|
||||
tag["TIT2"] = id3.TIT2(encoding=3, text=title_text) # Title
|
||||
tag["TDRC"] = id3.TDRC(encoding=3, text=published.isoformat()) # Date
|
||||
tag["COMM"] = id3.COMM(encoding=3, text=description) # Comment
|
||||
|
||||
with open(cover_path, "rb") as albumart:
|
||||
tag["APIC"] = id3.APIC(
|
||||
encoding=3, mime="image/png", type=3, desc="Cover", data=albumart.read()
|
||||
)
|
||||
tag.save()
|
||||
|
||||
|
||||
def resize_avatar(original_file: Path, new_file: Path):
|
||||
avatar = Image.open(original_file)
|
||||
avatar_new_height = int(AVATAR_SM_WIDTH / avatar.width * avatar.height)
|
||||
avatar = avatar.resize(
|
||||
(AVATAR_SM_WIDTH, avatar_new_height), Image.Resampling.LANCZOS
|
||||
)
|
||||
avatar.save(new_file)
|
||||
|
||||
|
||||
def resize_thumbnail(original_file: Path, new_file: Path):
|
||||
thumbnail = Image.open(original_file)
|
||||
tn_new_height = int(THUMBNAIL_SM_WIDTH / thumbnail.width * thumbnail.height)
|
||||
thumbnail = thumbnail.resize(
|
||||
(THUMBNAIL_SM_WIDTH, tn_new_height), Image.Resampling.LANCZOS
|
||||
)
|
||||
thumbnail.save(new_file)
|
|
@ -1,316 +0,0 @@
|
|||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
from typing import Generator, List, Optional
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ucast.service import scrapetube, storage, util, videoutil
|
||||
|
||||
CHANID_REGEX = re.compile(r"""[-_a-zA-Z\d]{24}""")
|
||||
|
||||
|
||||
class ItemNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ThumbnailNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMetadataError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoScraped:
|
||||
"""
|
||||
Video object, as it is scraped from the website/rss feed.
|
||||
RSS feeds contain the second-accurate publishing date, which cannot
|
||||
be scraped from the video info and is therefore included in this object.
|
||||
"""
|
||||
|
||||
id: str
|
||||
published: Optional[datetime.datetime]
|
||||
|
||||
def __str__(self):
|
||||
return self.id
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoDetails:
|
||||
"""Mapping of YoutubeDL's video information"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
channel_id: str
|
||||
channel_name: str
|
||||
duration: int
|
||||
published: datetime.datetime
|
||||
thumbnails: List[dict]
|
||||
is_currently_live: bool
|
||||
is_livestream: bool
|
||||
is_short: bool
|
||||
|
||||
@classmethod
|
||||
def from_vinfo(cls, info: dict):
|
||||
published_date = datetime.datetime.strptime(
|
||||
info["upload_date"], "%Y%m%d"
|
||||
).replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
return VideoDetails(
|
||||
id=info["id"],
|
||||
title=info["title"],
|
||||
description=info["description"],
|
||||
channel_id=info["channel_id"],
|
||||
channel_name=info["uploader"],
|
||||
duration=info["duration"],
|
||||
published=published_date,
|
||||
thumbnails=info["thumbnails"],
|
||||
is_currently_live=bool(info.get("is_live")),
|
||||
is_livestream=info.get("is_live") or info.get("was_live"),
|
||||
is_short=info["duration"] <= 60
|
||||
and (info["width"] or 0) < (info["height"] or 0),
|
||||
)
|
||||
|
||||
def add_scraped_data(self, scraped: VideoScraped):
|
||||
if scraped.id != self.id:
|
||||
raise ValueError("scraped data does not belong to video")
|
||||
|
||||
if scraped.published:
|
||||
self.published = scraped.published
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelMetadata:
|
||||
"""Channel information"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
avatar_url: str
|
||||
subscribers: Optional[str]
|
||||
|
||||
|
||||
def download_thumbnail(vinfo: VideoDetails, download_path: Path):
|
||||
"""
|
||||
Download the thumbnail image of a YouTube video and save it at the given filepath.
|
||||
The thumbnail file ending is added to the path.
|
||||
|
||||
: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)
|
||||
:return: Path with file ending
|
||||
"""
|
||||
|
||||
for tn in sorted(vinfo.thumbnails, key=itemgetter("preference"), reverse=True):
|
||||
url = tn["url"]
|
||||
logging.info(f"downloading thumbnail {url}...")
|
||||
|
||||
try:
|
||||
util.download_image_file(url, download_path, videoutil.THUMBNAIL_SIZE)
|
||||
return
|
||||
except requests.HTTPError:
|
||||
logging.warning(f"downloading thumbnail {url} failed")
|
||||
pass
|
||||
|
||||
raise ThumbnailNotFoundError(f"could not find thumbnail for video {vinfo}")
|
||||
|
||||
|
||||
def get_video_details(video_id: str) -> VideoDetails:
|
||||
"""
|
||||
Get the details of a YouTube video without downloading it.
|
||||
|
||||
:param video_id: YouTube video ID
|
||||
:return: VideoDetails
|
||||
"""
|
||||
cache = storage.Cache()
|
||||
|
||||
ydl_params = {
|
||||
"cachedir": str(cache.dir_ytdlp_cache),
|
||||
}
|
||||
|
||||
with YoutubeDL(ydl_params) as ydl:
|
||||
info = ydl.extract_info(video_id, download=False)
|
||||
return VideoDetails.from_vinfo(info)
|
||||
|
||||
|
||||
def download_audio(
|
||||
video_id: str, download_path: Path, sponsorblock=False
|
||||
) -> VideoDetails:
|
||||
"""
|
||||
Download the audio track from a YouTube video save it at the given filepath.
|
||||
|
||||
:param video_id: YouTube video ID
|
||||
:param download_path: Download path
|
||||
:param sponsorblock: Enable Sponsorblock
|
||||
:return: VideoDetails
|
||||
"""
|
||||
cache = storage.Cache()
|
||||
tmpdir = cache.create_tmpdir()
|
||||
tmp_dld_file = Path(tmpdir.name) / "audio.mp3"
|
||||
|
||||
ydl_params = {
|
||||
"format": "bestaudio",
|
||||
"postprocessors": [
|
||||
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3"},
|
||||
],
|
||||
"outtmpl": str(tmp_dld_file),
|
||||
"cachedir": str(cache.dir_ytdlp_cache),
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
downloaded_file = info["requested_downloads"][0]["filepath"]
|
||||
shutil.move(downloaded_file, download_path)
|
||||
return VideoDetails.from_vinfo(info)
|
||||
|
||||
|
||||
def channel_url_from_id(channel_id: str) -> str:
|
||||
return "https://www.youtube.com/channel/" + channel_id
|
||||
|
||||
|
||||
def channel_url_from_str(channel_str: str) -> str:
|
||||
"""
|
||||
Get the channel URL from user input. The following types are accepted:
|
||||
|
||||
- Channel ID URL: https://www.youtube.com/channel/UCGiJh0NZ52wRhYKYnuZI08Q
|
||||
- Vanity URL: https://www.youtube.com/c/MrBeast6000
|
||||
- User URL: https://www.youtube.com/user/LinusTechTips
|
||||
- Channel ID: ``UCGiJh0NZ52wRhYKYnuZI08Q``
|
||||
|
||||
:param channel_str: Channel string
|
||||
:return: Channel URL
|
||||
"""
|
||||
channel_url_regex = re.compile(
|
||||
r"""(?:https?://)?[-a-zA-Z\d@:%._+~#=]+\.[a-zA-Z\d]{1,6}/(?:(channel|c|user)/)?([-_a-zA-Z\d]*)"""
|
||||
)
|
||||
|
||||
match = channel_url_regex.match(channel_str)
|
||||
if match:
|
||||
url_type = match[1]
|
||||
# Vanity URL
|
||||
if not url_type or url_type == "c":
|
||||
return "https://www.youtube.com/c/" + match[2]
|
||||
# Username
|
||||
if url_type == "user":
|
||||
return "https://www.youtube.com/user/" + match[2]
|
||||
# Channel ID
|
||||
return "https://www.youtube.com/channel/" + match[2]
|
||||
|
||||
if CHANID_REGEX.match(channel_str):
|
||||
return "https://www.youtube.com/channel/" + channel_str
|
||||
|
||||
raise ValueError("invalid channel string")
|
||||
|
||||
|
||||
def get_channel_metadata(channel_url: str) -> ChannelMetadata:
|
||||
"""
|
||||
Get the metadata of a channel
|
||||
|
||||
:param channel_url: Channel-URL
|
||||
:return: Channel metadata
|
||||
"""
|
||||
data = scrapetube.get_channel_metadata(channel_url)
|
||||
metadata = data["metadata"]["channelMetadataRenderer"]
|
||||
|
||||
channel_id = metadata["externalId"]
|
||||
name = metadata["title"]
|
||||
description = metadata["description"].strip()
|
||||
avatar = metadata["avatar"]["thumbnails"][0]["url"]
|
||||
subscribers = None
|
||||
# The subscriber count is not always visible
|
||||
try:
|
||||
raw_subscribers = data["header"]["c4TabbedHeaderRenderer"][
|
||||
"subscriberCountText"
|
||||
]["simpleText"]
|
||||
subscribers = raw_subscribers.split(" ", 1)[0]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if not CHANID_REGEX.match(channel_id):
|
||||
raise InvalidMetadataError(f"got invalid channel id {repr(channel_id)}")
|
||||
|
||||
if not name:
|
||||
raise InvalidMetadataError(f"no channel name found for channel {channel_id}")
|
||||
|
||||
if not avatar.startswith("https://"):
|
||||
raise InvalidMetadataError(
|
||||
f"got invalid avatar url for channel {channel_id}: {avatar}"
|
||||
)
|
||||
|
||||
return ChannelMetadata(channel_id, name, description, avatar, subscribers)
|
||||
|
||||
|
||||
def get_channel_videos_from_feed(channel_id: str) -> List[VideoScraped]:
|
||||
"""
|
||||
Return videos of a channel using YouTube's RSS feed. Using the feed is fast,
|
||||
but you only get the 15 latest videos.
|
||||
|
||||
:param channel_id: YouTube channel id
|
||||
:return: Videos: video_id -> VideoScraped
|
||||
"""
|
||||
feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
||||
feed = feedparser.parse(feed_url)
|
||||
videos = []
|
||||
|
||||
for item in feed["entries"]:
|
||||
video_id = item.get("yt_videoid")
|
||||
if not video_id:
|
||||
logging.warning(
|
||||
f"found invalid item in rss feed of channel {channel_id}: {item}"
|
||||
)
|
||||
continue
|
||||
|
||||
publish_date_str = item.get("published")
|
||||
publish_date = None
|
||||
if publish_date_str:
|
||||
publish_date = datetime.datetime.fromisoformat(publish_date_str)
|
||||
|
||||
videos.append(VideoScraped(video_id, publish_date))
|
||||
|
||||
return videos
|
||||
|
||||
|
||||
def get_channel_videos_from_scraper(
|
||||
channel_id: str, limit: int = None
|
||||
) -> Generator[VideoScraped, None, None]:
|
||||
"""
|
||||
Return all videos of a channel by scraping the YouTube website.
|
||||
|
||||
:param channel_id: YouTube channel id
|
||||
:param limit: Limit number of scraped videos
|
||||
:return: Generator of Videos
|
||||
"""
|
||||
|
||||
for item in scrapetube.get_channel(channel_url_from_id(channel_id), limit):
|
||||
video_id = item.get("videoId")
|
||||
if not video_id:
|
||||
logging.warning(
|
||||
f"found invalid item in scraped feed of channel {channel_id}: {item}"
|
||||
)
|
||||
continue
|
||||
|
||||
yield VideoScraped(video_id, None)
|
1
ucast/static/bulma/css/style.min.css
vendored
6
ucast/static/ucast/css/fontawesome.css
vendored
Before Width: | Height: | Size: 4.2 KiB |
7
ucast/static/ucast/js/clipboard.min.js
vendored
2
ucast/static/ucast/js/htmx.min.js
vendored
|
@ -1,9 +0,0 @@
|
|||
const confirmButtons = document.getElementsByClassName("dialog-confirm")
|
||||
for(let btn of confirmButtons) {
|
||||
btn.addEventListener("click", function(e) {
|
||||
const result = window.confirm(btn.getAttribute("confirm-msg"));
|
||||
if(!result) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="svg5" width="68.5mm" height="15.79mm" version="1.1" viewBox="0 0 68.5 15.79" xmlns="http://www.w3.org/2000/svg"><g id="layer1" transform="translate(-1.4688 -18.46)" fill="none" stroke-linecap="square"><path id="path3041" d="m67.469 21.167h-10.583" stroke="#282828"/><path id="path3043" d="m62.177 21.445v10.305" stroke="#282828" stroke-width=".98677"/><path id="path3572" d="m3.9688 21.167v6.6146l3.9687 3.9688h2.6458l3.9688-3.9688v-6.6146" stroke="#e00"/><path id="path3687" d="m27.781 21.167h-6.6146l-3.9688 3.9688v2.6458l3.9688 3.9688h6.6146" stroke="#282828"/><path id="path3802" d="m30.427 31.75v-5.2917l5.2917-5.2917 5.2917 5.2917v5.2917" stroke="#282828"/><path id="path3954" d="m54.24 21.167h-7.9375l-2.6458 2.6458 2.6458 2.6458h5.2917l2.6458 2.6458-2.6458 2.6458h-7.9375" stroke="#282828"/></g></svg>
|
Before Width: | Height: | Size: 858 B |
|
@ -1,2 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="svg5" width="68.5mm" height="15.79mm" version="1.1" viewBox="0 0 68.5 15.79" xmlns="http://www.w3.org/2000/svg"><g id="layer1" transform="translate(-1.4688 -18.46)" fill="none" stroke-linecap="square"><path id="path3041" d="m67.469 21.167h-10.583" stroke="#fff"/><path id="path3043" d="m62.177 21.445v10.305" stroke="#fff" stroke-width=".98677"/><path id="path3572" d="m3.9688 21.167v6.6146l3.9687 3.9688h2.6458l3.9688-3.9688v-6.6146" stroke="#e00"/><path id="path3687" d="m27.781 21.167h-6.6146l-3.9688 3.9688v2.6458l3.9688 3.9688h6.6146" stroke="#fff"/><path id="path3802" d="m30.427 31.75v-5.2917l5.2917-5.2917 5.2917 5.2917v5.2917" stroke="#fff"/><path id="path3954" d="m54.24 21.167h-7.9375l-2.6458 2.6458 2.6458 2.6458h5.2917l2.6458 2.6458-2.6458 2.6458h-7.9375" stroke="#fff"/></g></svg>
|
Before Width: | Height: | Size: 843 B |