Compare commits

...

4 commits

Author SHA1 Message Date
f79220515b add dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2022-06-26 23:23:16 +02:00
69c27d5266 download videos in tmpdir 2022-06-26 20:34:44 +02:00
40bc4d9147 add queue error page 2022-06-26 20:16:24 +02:00
33212a7963 add channel edit/download 2022-06-26 18:15:11 +02:00
29 changed files with 513 additions and 91 deletions

23
.dockerignore Normal file
View file

@ -0,0 +1,23 @@
.idea
# Python
venv
dist
.tox
__pycache__
*.egg-info
.pytest_cache
# JS
node_modules
# Jupyter
.ipynb_checkpoints
# Application data
/.env
/_run*
*.sqlite3
assets
notes

2
assets/icons/icon.svg Normal file
View file

@ -0,0 +1,2 @@
<?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>

After

Width:  |  Height:  |  Size: 626 B

View file

@ -39,3 +39,7 @@
&:last-child &:last-child
padding-bottom: 0.5vw padding-bottom: 0.5vw
// Fix almost invisible navbar items on mobile
.navbar-item
color: #fff

47
deploy/Dockerfile Normal file
View file

@ -0,0 +1,47 @@
FROM thetadev256/ucast-dev
COPY . /build
WORKDIR /build
RUN poetry build -f wheel
FROM python:3.10
ARG TARGETPLATFORM
COPY --from=0 /build/dist /install
RUN pip install -- /install/*.whl gunicorn honcho
# 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
ENV UCAST_WORKDIR=/ucast
EXPOSE 8001
ENTRYPOINT /entrypoint.py

16
deploy/docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
version: "3"
services:
ucast:
image: thetadev256/ucast
user: 1000:1000
ports:
- "8001:8001"
volumes:
- "../_run:/ucast"
environment:
UCAST_REDIS_URL: "redis://redis:6379"
UCAST_SECRET_KEY: "django-insecure-Es/+plApGxNBy8+ewB+74zMlmfV2H3whw6gu7i0ESwGrEWAUYRP3HM2EX0PLr3UJ"
redis:
container_name: redis
image: redis:alpine

View file

@ -11,5 +11,4 @@ services:
network_mode: "host" network_mode: "host"
volumes: volumes:
- "./nginx:/etc/nginx/conf.d:ro" - "./nginx:/etc/nginx/conf.d:ro"
- "../_run/static:/static:ro" - "../_run:/ucast:ro"
- "../_run/data:/files:ro"

30
deploy/entrypoint.py Executable file
View file

@ -0,0 +1,30 @@
#!/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)

61
deploy/nginx.conf Normal file
View file

@ -0,0 +1,61 @@
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/*;
}

View file

@ -1,17 +1,17 @@
server { server {
listen 80; listen 8001;
server_name localhost; server_name localhost;
client_max_body_size 1M; client_max_body_size 1M;
# serve media files # serve media files
location /static/ { location /static/ {
alias /static/; alias /ucast/static/;
} }
location /internal_files/ { location /internal_files/ {
internal; internal;
alias /files/; alias /ucast/data/;
} }
location / { location / {

20
poetry.lock generated
View file

@ -268,6 +268,20 @@ category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]]
name = "gunicorn"
version = "20.1.0"
description = "WSGI HTTP Server for UNIX"
category = "dev"
optional = false
python-versions = ">=3.5"
[package.extras]
eventlet = ["eventlet (>=0.24.1)"]
gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"]
tornado = ["tornado (>=0.2)"]
[[package]] [[package]]
name = "honcho" name = "honcho"
version = "1.1.0" version = "1.1.0"
@ -754,7 +768,7 @@ websockets = "*"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "5c24c22351390a472cd905bf1d08890314441ef590c46a64e7940fb180f909a2" content-hash = "a3da05c0c8552c9149eb04dee6e52c7f3fffd2de297a82595422511e3674f861"
[metadata.files] [metadata.files]
asgiref = [ asgiref = [
@ -1034,6 +1048,10 @@ fonts = [
{file = "fonts-0.0.3-py3-none-any.whl", hash = "sha256:e5f551379088ab260c2537980c3ccdff8af93408d9d4fa3319388d2ee25b7b6d"}, {file = "fonts-0.0.3-py3-none-any.whl", hash = "sha256:e5f551379088ab260c2537980c3ccdff8af93408d9d4fa3319388d2ee25b7b6d"},
{file = "fonts-0.0.3.tar.gz", hash = "sha256:c626655b75a60715e118e44e270656fd22fd8f54252901ff6ebf1308ad01c405"}, {file = "fonts-0.0.3.tar.gz", hash = "sha256:c626655b75a60715e118e44e270656fd22fd8f54252901ff6ebf1308ad01c405"},
] ]
gunicorn = [
{file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
]
honcho = [ honcho = [
{file = "honcho-1.1.0-py2.py3-none-any.whl", hash = "sha256:a4d6e3a88a7b51b66351ecfc6e9d79d8f4b87351db9ad7e923f5632cc498122f"}, {file = "honcho-1.1.0-py2.py3-none-any.whl", hash = "sha256:a4d6e3a88a7b51b66351ecfc6e9d79d8f4b87351db9ad7e923f5632cc498122f"},
{file = "honcho-1.1.0.tar.gz", hash = "sha256:c5eca0bded4bef6697a23aec0422fd4f6508ea3581979a3485fc4b89357eb2a9"}, {file = "honcho-1.1.0.tar.gz", hash = "sha256:c5eca0bded4bef6697a23aec0422fd4f6508ea3581979a3485fc4b89357eb2a9"},

View file

@ -39,6 +39,7 @@ pre-commit = "^2.19.0"
honcho = "^1.1.0" honcho = "^1.1.0"
pytest-mock = "^3.7.0" pytest-mock = "^3.7.0"
fakeredis = "^1.7.5" fakeredis = "^1.7.5"
gunicorn = "^20.1.0"
[tool.poetry.scripts] [tool.poetry.scripts]
"ucast-manage" = "ucast_project.manage:main" "ucast-manage" = "ucast_project.manage:main"

View file

@ -7,3 +7,20 @@ class AddChannelForm(forms.Form):
class DeleteVideoForm(forms.Form): class DeleteVideoForm(forms.Form):
id = forms.IntegerField() 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()

View file

@ -59,20 +59,21 @@ class Command(BaseCommand):
# Header # Header
click.echo( click.echo(
"""| %-15s|%10s |%10s |%10s |%10s |%10s |""" """| %-15s|%10s |%10s |%10s |%10s |%10s |%10s |"""
% ("Name", "Queued", "Active", "Deferred", "Finished", "Workers") % ("Name", "Queued", "Active", "Deferred", "Finished", "Failed", "Workers")
) )
self._print_separator() self._print_separator()
click.echo( click.echo(
"""| %-15s|%10s |%10s |%10s |%10s |%10s |""" """| %-15s|%10s |%10s |%10s |%10s |%10s |%10s |"""
% ( % (
statistics["name"], statistics["name"],
statistics["jobs"], statistics["jobs"],
statistics["started_jobs"], statistics["started_jobs"],
statistics["deferred_jobs"], statistics["deferred_jobs"],
statistics["finished_jobs"], statistics["finished_jobs"],
statistics["failed_jobs"],
statistics["workers"], statistics["workers"],
) )
) )
@ -105,7 +106,7 @@ class Command(BaseCommand):
self.interval = options.get("interval") self.interval = options.get("interval")
# Arbitrary # Arbitrary
self.table_width = 78 self.table_width = 90
# Do not continuously poll # Do not continuously poll
if not self.interval: if not self.interval:

View file

@ -85,3 +85,8 @@ def get_statistics() -> dict:
"failed_jobs": len(failed_job_registry), "failed_jobs": len(failed_job_registry),
"scheduled_jobs": len(scheduled_job_registry), "scheduled_jobs": len(scheduled_job_registry),
} }
def get_failed_job_registry():
queue = get_queue()
return registry.FailedJobRegistry(queue.name, queue.connection)

View file

@ -2,6 +2,7 @@ import datetime
import logging import logging
import re import re
import shutil import shutil
import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from operator import itemgetter from operator import itemgetter
from pathlib import Path from pathlib import Path
@ -141,7 +142,8 @@ def download_audio(
:param sponsorblock: Enable Sponsorblock :param sponsorblock: Enable Sponsorblock
:return: VideoDetails :return: VideoDetails
""" """
tmp_dld_file = download_path.with_suffix(".dld" + download_path.suffix) tmpdir = tempfile.TemporaryDirectory(prefix="ucast_")
tmp_dld_file = Path(tmpdir.name) / "audio.mp3"
ydl_params = { ydl_params = {
"format": "bestaudio", "format": "bestaudio",

View file

@ -215,8 +215,7 @@ fieldset[disabled] .file-name, fieldset[disabled] .select select, .select fields
padding: 0; padding: 0;
} }
/* Bulma Base */ /* Bulma Base */ /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
html, html,
body, body,
p, p,
@ -1576,16 +1575,13 @@ a.box:active {
.button.is-responsive.is-small { .button.is-responsive.is-small {
font-size: 0.5625rem; font-size: 0.5625rem;
} }
.button.is-responsive, .button.is-responsive,
.button.is-responsive.is-normal { .button.is-responsive.is-normal {
font-size: 0.65625rem; font-size: 0.65625rem;
} }
.button.is-responsive.is-medium { .button.is-responsive.is-medium {
font-size: 0.75rem; font-size: 0.75rem;
} }
.button.is-responsive.is-large { .button.is-responsive.is-large {
font-size: 1rem; font-size: 1rem;
} }
@ -1594,16 +1590,13 @@ a.box:active {
.button.is-responsive.is-small { .button.is-responsive.is-small {
font-size: 0.65625rem; font-size: 0.65625rem;
} }
.button.is-responsive, .button.is-responsive,
.button.is-responsive.is-normal { .button.is-responsive.is-normal {
font-size: 0.75rem; font-size: 0.75rem;
} }
.button.is-responsive.is-medium { .button.is-responsive.is-medium {
font-size: 1rem; font-size: 1rem;
} }
.button.is-responsive.is-large { .button.is-responsive.is-large {
font-size: 1.25rem; font-size: 1.25rem;
} }
@ -5250,17 +5243,14 @@ a.navbar-item:focus, a.navbar-item:focus-within, a.navbar-item:hover, a.navbar-i
.navbar > .container { .navbar > .container {
display: block; display: block;
} }
.navbar-brand .navbar-item, .navbar-brand .navbar-item,
.navbar-tabs .navbar-item { .navbar-tabs .navbar-item {
align-items: center; align-items: center;
display: flex; display: flex;
} }
.navbar-link::after { .navbar-link::after {
display: none; display: none;
} }
.navbar-menu { .navbar-menu {
background-color: hsl(0deg, 0%, 100%); background-color: hsl(0deg, 0%, 100%);
box-shadow: 0 8px 16px rgba(10, 10, 10, 0.1); box-shadow: 0 8px 16px rgba(10, 10, 10, 0.1);
@ -5269,7 +5259,6 @@ a.navbar-item:focus, a.navbar-item:focus-within, a.navbar-item:hover, a.navbar-i
.navbar-menu.is-active { .navbar-menu.is-active {
display: block; display: block;
} }
.navbar.is-fixed-bottom-touch, .navbar.is-fixed-top-touch { .navbar.is-fixed-bottom-touch, .navbar.is-fixed-top-touch {
left: 0; left: 0;
position: fixed; position: fixed;
@ -5290,7 +5279,6 @@ a.navbar-item:focus, a.navbar-item:focus-within, a.navbar-item:hover, a.navbar-i
max-height: calc(100vh - 3.25rem); max-height: calc(100vh - 3.25rem);
overflow: auto; overflow: auto;
} }
html.has-navbar-fixed-top-touch, html.has-navbar-fixed-top-touch,
body.has-navbar-fixed-top-touch { body.has-navbar-fixed-top-touch {
padding-top: 3.25rem; padding-top: 3.25rem;
@ -5308,7 +5296,6 @@ body.has-navbar-fixed-bottom-touch {
align-items: stretch; align-items: stretch;
display: flex; display: flex;
} }
.navbar { .navbar {
min-height: 3.25rem; min-height: 3.25rem;
} }
@ -5340,17 +5327,14 @@ body.has-navbar-fixed-bottom-touch {
background-color: hsl(0deg, 0%, 96%); background-color: hsl(0deg, 0%, 96%);
color: hsl(229deg, 53%, 53%); color: hsl(229deg, 53%, 53%);
} }
.navbar-burger { .navbar-burger {
display: none; display: none;
} }
.navbar-item, .navbar-item,
.navbar-link { .navbar-link {
align-items: center; align-items: center;
display: flex; display: flex;
} }
.navbar-item.has-dropdown { .navbar-item.has-dropdown {
align-items: stretch; align-items: stretch;
} }
@ -5373,22 +5357,18 @@ body.has-navbar-fixed-bottom-touch {
pointer-events: auto; pointer-events: auto;
transform: translateY(0); transform: translateY(0);
} }
.navbar-menu { .navbar-menu {
flex-grow: 1; flex-grow: 1;
flex-shrink: 0; flex-shrink: 0;
} }
.navbar-start { .navbar-start {
justify-content: flex-start; justify-content: flex-start;
margin-right: auto; margin-right: auto;
} }
.navbar-end { .navbar-end {
justify-content: flex-end; justify-content: flex-end;
margin-left: auto; margin-left: auto;
} }
.navbar-dropdown { .navbar-dropdown {
background-color: hsl(0deg, 0%, 100%); background-color: hsl(0deg, 0%, 100%);
border-bottom-left-radius: 6px; border-bottom-left-radius: 6px;
@ -5434,11 +5414,9 @@ body.has-navbar-fixed-bottom-touch {
left: auto; left: auto;
right: 0; right: 0;
} }
.navbar-divider { .navbar-divider {
display: block; display: block;
} }
.navbar > .container .navbar-brand, .navbar > .container .navbar-brand,
.container > .navbar .navbar-brand { .container > .navbar .navbar-brand {
margin-left: -0.75rem; margin-left: -0.75rem;
@ -5447,7 +5425,6 @@ body.has-navbar-fixed-bottom-touch {
.container > .navbar .navbar-menu { .container > .navbar .navbar-menu {
margin-right: -0.75rem; margin-right: -0.75rem;
} }
.navbar.is-fixed-bottom-desktop, .navbar.is-fixed-top-desktop { .navbar.is-fixed-bottom-desktop, .navbar.is-fixed-top-desktop {
left: 0; left: 0;
position: fixed; position: fixed;
@ -5463,7 +5440,6 @@ body.has-navbar-fixed-bottom-touch {
.navbar.is-fixed-top-desktop { .navbar.is-fixed-top-desktop {
top: 0; top: 0;
} }
html.has-navbar-fixed-top-desktop, html.has-navbar-fixed-top-desktop,
body.has-navbar-fixed-top-desktop { body.has-navbar-fixed-top-desktop {
padding-top: 3.25rem; padding-top: 3.25rem;
@ -5480,7 +5456,6 @@ body.has-spaced-navbar-fixed-top {
body.has-spaced-navbar-fixed-bottom { body.has-spaced-navbar-fixed-bottom {
padding-bottom: 5.25rem; padding-bottom: 5.25rem;
} }
a.navbar-item.is-active, a.navbar-item.is-active,
.navbar-link.is-active { .navbar-link.is-active {
color: hsl(0deg, 0%, 4%); color: hsl(0deg, 0%, 4%);
@ -5489,7 +5464,6 @@ body.has-spaced-navbar-fixed-bottom {
.navbar-link.is-active:not(:focus):not(:hover) { .navbar-link.is-active:not(:focus):not(:hover) {
background-color: transparent; background-color: transparent;
} }
.navbar-item.has-dropdown:focus .navbar-link, .navbar-item.has-dropdown:hover .navbar-link, .navbar-item.has-dropdown.is-active .navbar-link { .navbar-item.has-dropdown:focus .navbar-link, .navbar-item.has-dropdown:hover .navbar-link, .navbar-item.has-dropdown.is-active .navbar-link {
background-color: hsl(0deg, 0%, 98%); background-color: hsl(0deg, 0%, 98%);
} }
@ -5605,13 +5579,11 @@ body.has-spaced-navbar-fixed-bottom {
.pagination { .pagination {
flex-wrap: wrap; flex-wrap: wrap;
} }
.pagination-previous, .pagination-previous,
.pagination-next { .pagination-next {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
} }
.pagination-list li { .pagination-list li {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
@ -5624,7 +5596,6 @@ body.has-spaced-navbar-fixed-bottom {
justify-content: flex-start; justify-content: flex-start;
order: 1; order: 1;
} }
.pagination-previous, .pagination-previous,
.pagination-next, .pagination-next,
.pagination-link, .pagination-link,
@ -5632,15 +5603,12 @@ body.has-spaced-navbar-fixed-bottom {
margin-bottom: 0; margin-bottom: 0;
margin-top: 0; margin-top: 0;
} }
.pagination-previous { .pagination-previous {
order: 2; order: 2;
} }
.pagination-next { .pagination-next {
order: 3; order: 3;
} }
.pagination { .pagination {
justify-content: space-between; justify-content: space-between;
margin-bottom: 0; margin-bottom: 0;
@ -8847,27 +8815,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-mobile { .is-size-1-mobile {
font-size: 3rem !important; font-size: 3rem !important;
} }
.is-size-2-mobile { .is-size-2-mobile {
font-size: 2.5rem !important; font-size: 2.5rem !important;
} }
.is-size-3-mobile { .is-size-3-mobile {
font-size: 2rem !important; font-size: 2rem !important;
} }
.is-size-4-mobile { .is-size-4-mobile {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
.is-size-5-mobile { .is-size-5-mobile {
font-size: 1.25rem !important; font-size: 1.25rem !important;
} }
.is-size-6-mobile { .is-size-6-mobile {
font-size: 1rem !important; font-size: 1rem !important;
} }
.is-size-7-mobile { .is-size-7-mobile {
font-size: 0.75rem !important; font-size: 0.75rem !important;
} }
@ -8876,27 +8838,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-tablet { .is-size-1-tablet {
font-size: 3rem !important; font-size: 3rem !important;
} }
.is-size-2-tablet { .is-size-2-tablet {
font-size: 2.5rem !important; font-size: 2.5rem !important;
} }
.is-size-3-tablet { .is-size-3-tablet {
font-size: 2rem !important; font-size: 2rem !important;
} }
.is-size-4-tablet { .is-size-4-tablet {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
.is-size-5-tablet { .is-size-5-tablet {
font-size: 1.25rem !important; font-size: 1.25rem !important;
} }
.is-size-6-tablet { .is-size-6-tablet {
font-size: 1rem !important; font-size: 1rem !important;
} }
.is-size-7-tablet { .is-size-7-tablet {
font-size: 0.75rem !important; font-size: 0.75rem !important;
} }
@ -8905,27 +8861,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-touch { .is-size-1-touch {
font-size: 3rem !important; font-size: 3rem !important;
} }
.is-size-2-touch { .is-size-2-touch {
font-size: 2.5rem !important; font-size: 2.5rem !important;
} }
.is-size-3-touch { .is-size-3-touch {
font-size: 2rem !important; font-size: 2rem !important;
} }
.is-size-4-touch { .is-size-4-touch {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
.is-size-5-touch { .is-size-5-touch {
font-size: 1.25rem !important; font-size: 1.25rem !important;
} }
.is-size-6-touch { .is-size-6-touch {
font-size: 1rem !important; font-size: 1rem !important;
} }
.is-size-7-touch { .is-size-7-touch {
font-size: 0.75rem !important; font-size: 0.75rem !important;
} }
@ -8934,27 +8884,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-desktop { .is-size-1-desktop {
font-size: 3rem !important; font-size: 3rem !important;
} }
.is-size-2-desktop { .is-size-2-desktop {
font-size: 2.5rem !important; font-size: 2.5rem !important;
} }
.is-size-3-desktop { .is-size-3-desktop {
font-size: 2rem !important; font-size: 2rem !important;
} }
.is-size-4-desktop { .is-size-4-desktop {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
.is-size-5-desktop { .is-size-5-desktop {
font-size: 1.25rem !important; font-size: 1.25rem !important;
} }
.is-size-6-desktop { .is-size-6-desktop {
font-size: 1rem !important; font-size: 1rem !important;
} }
.is-size-7-desktop { .is-size-7-desktop {
font-size: 0.75rem !important; font-size: 0.75rem !important;
} }
@ -8963,27 +8907,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-widescreen { .is-size-1-widescreen {
font-size: 3rem !important; font-size: 3rem !important;
} }
.is-size-2-widescreen { .is-size-2-widescreen {
font-size: 2.5rem !important; font-size: 2.5rem !important;
} }
.is-size-3-widescreen { .is-size-3-widescreen {
font-size: 2rem !important; font-size: 2rem !important;
} }
.is-size-4-widescreen { .is-size-4-widescreen {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
.is-size-5-widescreen { .is-size-5-widescreen {
font-size: 1.25rem !important; font-size: 1.25rem !important;
} }
.is-size-6-widescreen { .is-size-6-widescreen {
font-size: 1rem !important; font-size: 1rem !important;
} }
.is-size-7-widescreen { .is-size-7-widescreen {
font-size: 0.75rem !important; font-size: 0.75rem !important;
} }
@ -8992,27 +8930,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-fullhd { .is-size-1-fullhd {
font-size: 3rem !important; font-size: 3rem !important;
} }
.is-size-2-fullhd { .is-size-2-fullhd {
font-size: 2.5rem !important; font-size: 2.5rem !important;
} }
.is-size-3-fullhd { .is-size-3-fullhd {
font-size: 2rem !important; font-size: 2rem !important;
} }
.is-size-4-fullhd { .is-size-4-fullhd {
font-size: 1.5rem !important; font-size: 1.5rem !important;
} }
.is-size-5-fullhd { .is-size-5-fullhd {
font-size: 1.25rem !important; font-size: 1.25rem !important;
} }
.is-size-6-fullhd { .is-size-6-fullhd {
font-size: 1rem !important; font-size: 1rem !important;
} }
.is-size-7-fullhd { .is-size-7-fullhd {
font-size: 0.75rem !important; font-size: 0.75rem !important;
} }
@ -10427,4 +10359,8 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
padding-bottom: 0.5vw; padding-bottom: 0.5vw;
} }
.navbar-item {
color: #fff;
}
/*# sourceMappingURL=style.css.map */ /*# sourceMappingURL=style.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -52,6 +52,11 @@ def download_video(video: Video):
:param video: Video object :param video: Video object
""" """
# Return if the video was already downloaded by a previous task
video.refresh_from_db()
if video.downloaded:
return
store = storage.Storage() store = storage.Storage()
channel_folder = store.get_or_create_channel_folder(video.channel.slug) channel_folder = store.get_or_create_channel_folder(video.channel.slug)
@ -109,3 +114,17 @@ def update_channels():
""" """
for channel in Channel.objects.filter(active=True): for channel in Channel.objects.filter(active=True):
queue.enqueue(update_channel, channel) queue.enqueue(update_channel, channel)
def download_channel(channel: Channel, limit: int):
"""
Download maximum number of videos from a channel.
:param channel: Channel object
:param limit: Max number of videos
"""
if limit < 1:
return
for vid in youtube.get_channel_videos_from_scraper(channel.channel_id, limit):
_load_scraped_video(vid, channel)

View file

@ -26,14 +26,17 @@
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<a class="navbar-item" href="{% url 'download_errors' %}">
Errors
</a>
{% url 'login' as login_url %} {% url 'login' as login_url %}
{% url 'logout' as logout_url %} {% url 'logout' as logout_url %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a class="navbar-item is-hidden-desktop-only" href="{{ logout_url }}"> <a class="navbar-item" href="{{ logout_url }}">
Logout Logout
</a> </a>
{% else %} {% else %}
<a class="navbar-item is-hidden-desktop-only" href="{{ login_url }}"> <a class="navbar-item" href="{{ login_url }}">
Login Login
</a> </a>
{% endif %} {% endif %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% load bulma_tags %}
{% block title %}ucast - {{ channel.name }}{% endblock %}
{% block content %}
<div class="mb-4">
<div>
<a class="title" href="{% url 'videos' channel.slug %}">{{ channel.name }}</a>
</div>
<div>
<span class="subtitle">Batch download</span>
</div>
</div>
<form method="post">
{% csrf_token %}
{{ form|bulma }}
<div class="field">
<button type="submit" class="button is-primary">Download</button>
<a href="{% url 'videos' channel.slug %}" class="button">Back</a>
</div>
</form>
{% endblock content %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% load bulma_tags %}
{% block title %}ucast - {{ channel.name }}{% endblock %}
{% block content %}
<div class="mb-4">
<div>
<a class="title" href="{% url 'videos' channel.slug %}">{{ channel.name }}</a>
</div>
<div>
<span class="subtitle">Edit</span>
</div>
</div>
<form method="post">
{% csrf_token %}
{{ form|bulma }}
<div class="field">
<button type="submit" class="button is-primary">OK</button>
<a href="{% url 'videos' channel.slug %}" class="button">Back</a>
</div>
</form>
{% endblock content %}

View file

@ -45,7 +45,7 @@
</a> </a>
</div> </div>
<div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1"> <div class="ml-3 is-flex is-flex-direction-column is-flex-grow-1">
<a class="subtitle" href="/channel/{{ channel.slug }}">{{ channel.name }}</a> <a class="subtitle" href="{% url 'videos' channel.slug %}">{{ channel.name }}</a>
<div class="field has-addons"> <div class="field has-addons">
<div class="control"> <div class="control">
<a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}" <a href="{{ site_url }}/feed/{{ channel.slug }}?key={{ user.get_feed_key }}"

View file

@ -0,0 +1,50 @@
{% extends 'base.html' %}
{% block title %}ucast - Errors{% endblock %}
{% block content %}
<div class="mb-4">
<div>
<span class="title">Download errors</span>
</div>
</div>
{% if jobs %}
<div class="mb-4">
<form method="post" action="{% url 'download_errors_requeue_all' %}">
{% csrf_token %}
<button class="button is-primary">Requeue all</button>
</form>
</div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Function</th>
<th>Details</th>
<th>Requeue</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td>{{ job.id }}</td>
<td>{{ job.func_name }}</td>
<td><a href="{% url 'error_details' job.id %}">Details</a></td>
<td>
<form method="post" action="{% url 'download_errors_requeue' %}">
{% csrf_token %}
<input type="hidden" name="id" value="{{ job.id }}">
<button class="button is-small">Requeue</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No download errors</p>
{% endif %}
{% endblock content %}

View file

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}ucast - Error Details{% endblock %}
{% block content %}
<div class="mb-4">
<div>
<span class="title">Job {{ job.id }}</span>
</div>
<div>
<span class="subtitle">{{ job.func_name }}</span>
</div>
</div>
<pre class="mb-4">
{{ job.exc_info }}
</pre>
<div>
<form method="post" action="{% url 'download_errors_requeue' %}">
{% csrf_token %}
<input type="hidden" name="id" value="{{ job.id }}">
<button class="button is-primary">Requeue</button>
</form>
</div>
{% endblock content %}

View file

@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}ucast - Videos{% endblock %} {% block title %}ucast - {{ channel.name }}{% endblock %}
{% block content %} {% block content %}
<div class="level"> <div class="level">
@ -39,13 +39,16 @@
</button> </button>
{% endif %} {% endif %}
</div> </div>
<!--
<div class="control"> <div class="control">
<button class="button is-info"> <a class="button is-info" href="{% url 'channel_edit' channel.slug %}">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </a>
</div>
<div class="control">
<a class="button is-info" href="{% url 'channel_download' channel.slug %}">
<i class="fas fa-download"></i>
</a>
</div> </div>
-->
<div class="control"> <div class="control">
<button type="submit" name="delete_channel" class="button is-danger dialog-confirm" <button type="submit" name="delete_channel" class="button is-danger dialog-confirm"
confirm-msg="Do you want to delete the channel '{{ channel.name }}' including {{ videos|length }} videos?"> confirm-msg="Do you want to delete the channel '{{ channel.name }}' including {{ videos|length }} videos?">

View file

@ -1,11 +1,33 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import path from django.urls import path
from django.views.generic.base import RedirectView
from ucast import views from ucast import views
urlpatterns = [ urlpatterns = [
path("", views.home), path("", views.home),
path(
"favicon.ico",
RedirectView.as_view(url=staticfiles_storage.url("ucast/favicon.ico")),
),
path("channel/<str:channel>", views.videos, name="videos"), path("channel/<str:channel>", views.videos, name="videos"),
path("feed/<str:channel>", views.podcast_feed), path("channel/<str:channel>/edit", views.channel_edit, name="channel_edit"),
path(
"channel/<str:channel>/download",
views.channel_download,
name="channel_download",
),
path("errors", views.download_errors, name="download_errors"),
path(
"errors/requeue", views.download_errors_requeue, name="download_errors_requeue"
),
path(
"errors/requeue_all",
views.download_errors_requeue_all,
name="download_errors_requeue_all",
),
path("errors/<str:job_id>", views.error_details, name="error_details"),
path("feed/<str:channel>", views.podcast_feed, name="feed"),
path("files/audio/<str:channel>/<str:video>", views.audio), path("files/audio/<str:channel>/<str:video>", views.audio),
path("files/cover/<str:channel>/<str:video>", views.cover), path("files/cover/<str:channel>/<str:video>", views.cover),
path("files/thumbnail/<str:channel>/<str:video>", views.thumbnail), path("files/thumbnail/<str:channel>/<str:video>", views.thumbnail),

View file

@ -102,6 +102,94 @@ def videos(request: http.HttpRequest, channel: str):
) )
@login_required
def channel_edit(request: http.HttpRequest, channel: str):
chan = Channel.objects.get(slug=channel)
form = forms.EditChannelForm(
initial={
"skip_shorts": chan.skip_shorts,
"skip_livestreams": chan.skip_livestreams,
}
)
if request.method == "POST":
form = forms.EditChannelForm(request.POST)
if form.is_valid():
chan.skip_shorts = form.cleaned_data["skip_shorts"]
chan.skip_livestreams = form.cleaned_data["skip_livestreams"]
chan.save()
return http.HttpResponseRedirect(reverse(videos, args=[channel]))
return render(
request,
"ucast/channel_edit.html",
{
"channel": chan,
"form": form,
},
)
@login_required
def channel_download(request: http.HttpRequest, channel: str):
chan = Channel.objects.get(slug=channel)
if request.method == "POST":
form = forms.DownloadChannelForm(request.POST)
if form.is_valid():
queue.enqueue(
download.download_channel, chan, form.cleaned_data["n_videos"]
)
return http.HttpResponseRedirect(reverse(videos, args=[channel]))
return render(
request,
"ucast/channel_download.html",
{
"channel": chan,
"form": forms.DownloadChannelForm(),
},
)
@login_required
def download_errors(request: http.HttpRequest):
freg = queue.get_failed_job_registry()
ids = freg.get_job_ids(0, 50)
jobs = freg.job_class.fetch_many(ids, freg.connection, freg.serializer)
return render(request, "ucast/download_errors.html", {"jobs": jobs})
@login_required
def error_details(request: http.HttpRequest, job_id: str):
freg = queue.get_failed_job_registry()
job = freg.job_class.fetch(job_id, freg.connection, freg.serializer)
return render(request, "ucast/error_details.html", {"job": job})
@login_required
def download_errors_requeue(request: http.HttpRequest):
form = forms.RequeueForm(request.POST)
if form.is_valid():
freg = queue.get_failed_job_registry()
freg.requeue(str(form.cleaned_data["id"]))
return http.HttpResponseRedirect(reverse(download_errors))
@login_required
def download_errors_requeue_all(request: http.HttpRequest):
freg = queue.get_failed_job_registry()
for job_id in freg.get_job_ids():
freg.requeue(job_id)
return http.HttpResponseRedirect(reverse(download_errors))
def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]): def _channel_file(channel: str, get_file: Callable[[storage.ChannelFolder], Path]):
store = storage.Storage() store = storage.Storage()