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
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"
volumes:
- "./nginx:/etc/nginx/conf.d:ro"
- "../_run/static:/static:ro"
- "../_run/data:/files:ro"
- "../_run:/ucast: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 {
listen 80;
listen 8001;
server_name localhost;
client_max_body_size 1M;
# serve media files
location /static/ {
alias /static/;
alias /ucast/static/;
}
location /internal_files/ {
internal;
alias /files/;
alias /ucast/data/;
}
location / {

20
poetry.lock generated
View file

@ -268,6 +268,20 @@ category = "main"
optional = false
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]]
name = "honcho"
version = "1.1.0"
@ -754,7 +768,7 @@ websockets = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "5c24c22351390a472cd905bf1d08890314441ef590c46a64e7940fb180f909a2"
content-hash = "a3da05c0c8552c9149eb04dee6e52c7f3fffd2de297a82595422511e3674f861"
[metadata.files]
asgiref = [
@ -1034,6 +1048,10 @@ fonts = [
{file = "fonts-0.0.3-py3-none-any.whl", hash = "sha256:e5f551379088ab260c2537980c3ccdff8af93408d9d4fa3319388d2ee25b7b6d"},
{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 = [
{file = "honcho-1.1.0-py2.py3-none-any.whl", hash = "sha256:a4d6e3a88a7b51b66351ecfc6e9d79d8f4b87351db9ad7e923f5632cc498122f"},
{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"
pytest-mock = "^3.7.0"
fakeredis = "^1.7.5"
gunicorn = "^20.1.0"
[tool.poetry.scripts]
"ucast-manage" = "ucast_project.manage:main"

View file

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

View file

@ -59,20 +59,21 @@ class Command(BaseCommand):
# Header
click.echo(
"""| %-15s|%10s |%10s |%10s |%10s |%10s |"""
% ("Name", "Queued", "Active", "Deferred", "Finished", "Workers")
"""| %-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 |"""
"""| %-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"],
)
)
@ -105,7 +106,7 @@ class Command(BaseCommand):
self.interval = options.get("interval")
# Arbitrary
self.table_width = 78
self.table_width = 90
# Do not continuously poll
if not self.interval:

View file

@ -85,3 +85,8 @@ def get_statistics() -> dict:
"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)

View file

@ -2,6 +2,7 @@ import datetime
import logging
import re
import shutil
import tempfile
from dataclasses import dataclass
from operator import itemgetter
from pathlib import Path
@ -141,7 +142,8 @@ def download_audio(
:param sponsorblock: Enable Sponsorblock
: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 = {
"format": "bestaudio",

View file

@ -215,8 +215,7 @@ fieldset[disabled] .file-name, fieldset[disabled] .select select, .select fields
padding: 0;
}
/* Bulma Base */
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
/* Bulma Base */ /*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
html,
body,
p,
@ -1576,16 +1575,13 @@ a.box:active {
.button.is-responsive.is-small {
font-size: 0.5625rem;
}
.button.is-responsive,
.button.is-responsive.is-normal {
font-size: 0.65625rem;
}
.button.is-responsive.is-medium {
font-size: 0.75rem;
}
.button.is-responsive.is-large {
font-size: 1rem;
}
@ -1594,16 +1590,13 @@ a.box:active {
.button.is-responsive.is-small {
font-size: 0.65625rem;
}
.button.is-responsive,
.button.is-responsive.is-normal {
font-size: 0.75rem;
}
.button.is-responsive.is-medium {
font-size: 1rem;
}
.button.is-responsive.is-large {
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 {
display: block;
}
.navbar-brand .navbar-item,
.navbar-tabs .navbar-item {
align-items: center;
display: flex;
}
.navbar-link::after {
display: none;
}
.navbar-menu {
background-color: hsl(0deg, 0%, 100%);
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 {
display: block;
}
.navbar.is-fixed-bottom-touch, .navbar.is-fixed-top-touch {
left: 0;
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);
overflow: auto;
}
html.has-navbar-fixed-top-touch,
body.has-navbar-fixed-top-touch {
padding-top: 3.25rem;
@ -5308,7 +5296,6 @@ body.has-navbar-fixed-bottom-touch {
align-items: stretch;
display: flex;
}
.navbar {
min-height: 3.25rem;
}
@ -5340,17 +5327,14 @@ body.has-navbar-fixed-bottom-touch {
background-color: hsl(0deg, 0%, 96%);
color: hsl(229deg, 53%, 53%);
}
.navbar-burger {
display: none;
}
.navbar-item,
.navbar-link {
align-items: center;
display: flex;
}
.navbar-item.has-dropdown {
align-items: stretch;
}
@ -5373,22 +5357,18 @@ body.has-navbar-fixed-bottom-touch {
pointer-events: auto;
transform: translateY(0);
}
.navbar-menu {
flex-grow: 1;
flex-shrink: 0;
}
.navbar-start {
justify-content: flex-start;
margin-right: auto;
}
.navbar-end {
justify-content: flex-end;
margin-left: auto;
}
.navbar-dropdown {
background-color: hsl(0deg, 0%, 100%);
border-bottom-left-radius: 6px;
@ -5434,11 +5414,9 @@ body.has-navbar-fixed-bottom-touch {
left: auto;
right: 0;
}
.navbar-divider {
display: block;
}
.navbar > .container .navbar-brand,
.container > .navbar .navbar-brand {
margin-left: -0.75rem;
@ -5447,7 +5425,6 @@ body.has-navbar-fixed-bottom-touch {
.container > .navbar .navbar-menu {
margin-right: -0.75rem;
}
.navbar.is-fixed-bottom-desktop, .navbar.is-fixed-top-desktop {
left: 0;
position: fixed;
@ -5463,7 +5440,6 @@ body.has-navbar-fixed-bottom-touch {
.navbar.is-fixed-top-desktop {
top: 0;
}
html.has-navbar-fixed-top-desktop,
body.has-navbar-fixed-top-desktop {
padding-top: 3.25rem;
@ -5480,7 +5456,6 @@ body.has-spaced-navbar-fixed-top {
body.has-spaced-navbar-fixed-bottom {
padding-bottom: 5.25rem;
}
a.navbar-item.is-active,
.navbar-link.is-active {
color: hsl(0deg, 0%, 4%);
@ -5489,7 +5464,6 @@ body.has-spaced-navbar-fixed-bottom {
.navbar-link.is-active:not(:focus):not(:hover) {
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 {
background-color: hsl(0deg, 0%, 98%);
}
@ -5605,13 +5579,11 @@ body.has-spaced-navbar-fixed-bottom {
.pagination {
flex-wrap: wrap;
}
.pagination-previous,
.pagination-next {
flex-grow: 1;
flex-shrink: 1;
}
.pagination-list li {
flex-grow: 1;
flex-shrink: 1;
@ -5624,7 +5596,6 @@ body.has-spaced-navbar-fixed-bottom {
justify-content: flex-start;
order: 1;
}
.pagination-previous,
.pagination-next,
.pagination-link,
@ -5632,15 +5603,12 @@ body.has-spaced-navbar-fixed-bottom {
margin-bottom: 0;
margin-top: 0;
}
.pagination-previous {
order: 2;
}
.pagination-next {
order: 3;
}
.pagination {
justify-content: space-between;
margin-bottom: 0;
@ -8847,27 +8815,21 @@ a.has-text-danger-dark:hover, a.has-text-danger-dark:focus {
.is-size-1-mobile {
font-size: 3rem !important;
}
.is-size-2-mobile {
font-size: 2.5rem !important;
}
.is-size-3-mobile {
font-size: 2rem !important;
}
.is-size-4-mobile {
font-size: 1.5rem !important;
}
.is-size-5-mobile {
font-size: 1.25rem !important;
}
.is-size-6-mobile {
font-size: 1rem !important;
}
.is-size-7-mobile {
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 {
font-size: 3rem !important;
}
.is-size-2-tablet {
font-size: 2.5rem !important;
}
.is-size-3-tablet {
font-size: 2rem !important;
}
.is-size-4-tablet {
font-size: 1.5rem !important;
}
.is-size-5-tablet {
font-size: 1.25rem !important;
}
.is-size-6-tablet {
font-size: 1rem !important;
}
.is-size-7-tablet {
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 {
font-size: 3rem !important;
}
.is-size-2-touch {
font-size: 2.5rem !important;
}
.is-size-3-touch {
font-size: 2rem !important;
}
.is-size-4-touch {
font-size: 1.5rem !important;
}
.is-size-5-touch {
font-size: 1.25rem !important;
}
.is-size-6-touch {
font-size: 1rem !important;
}
.is-size-7-touch {
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 {
font-size: 3rem !important;
}
.is-size-2-desktop {
font-size: 2.5rem !important;
}
.is-size-3-desktop {
font-size: 2rem !important;
}
.is-size-4-desktop {
font-size: 1.5rem !important;
}
.is-size-5-desktop {
font-size: 1.25rem !important;
}
.is-size-6-desktop {
font-size: 1rem !important;
}
.is-size-7-desktop {
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 {
font-size: 3rem !important;
}
.is-size-2-widescreen {
font-size: 2.5rem !important;
}
.is-size-3-widescreen {
font-size: 2rem !important;
}
.is-size-4-widescreen {
font-size: 1.5rem !important;
}
.is-size-5-widescreen {
font-size: 1.25rem !important;
}
.is-size-6-widescreen {
font-size: 1rem !important;
}
.is-size-7-widescreen {
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 {
font-size: 3rem !important;
}
.is-size-2-fullhd {
font-size: 2.5rem !important;
}
.is-size-3-fullhd {
font-size: 2rem !important;
}
.is-size-4-fullhd {
font-size: 1.5rem !important;
}
.is-size-5-fullhd {
font-size: 1.25rem !important;
}
.is-size-6-fullhd {
font-size: 1rem !important;
}
.is-size-7-fullhd {
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;
}
.navbar-item {
color: #fff;
}
/*# 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
"""
# Return if the video was already downloaded by a previous task
video.refresh_from_db()
if video.downloaded:
return
store = storage.Storage()
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):
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 class="navbar-end">
<a class="navbar-item" href="{% url 'download_errors' %}">
Errors
</a>
{% url 'login' as login_url %}
{% url 'logout' as logout_url %}
{% if user.is_authenticated %}
<a class="navbar-item is-hidden-desktop-only" href="{{ logout_url }}">
<a class="navbar-item" href="{{ logout_url }}">
Logout
</a>
{% else %}
<a class="navbar-item is-hidden-desktop-only" href="{{ login_url }}">
<a class="navbar-item" href="{{ login_url }}">
Login
</a>
{% 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>
</div>
<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="control">
<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' %}
{% block title %}ucast - Videos{% endblock %}
{% block title %}ucast - {{ channel.name }}{% endblock %}
{% block content %}
<div class="level">
@ -39,13 +39,16 @@
</button>
{% endif %}
</div>
<!--
<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>
</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 class="control">
<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?">

View file

@ -1,11 +1,33 @@
from django.contrib.staticfiles.storage import staticfiles_storage
from django.urls import path
from django.views.generic.base import RedirectView
from ucast import views
urlpatterns = [
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("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/cover/<str:channel>/<str:video>", views.cover),
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]):
store = storage.Storage()