Compare commits
4 commits
778b23ecd4
...
f79220515b
Author | SHA1 | Date | |
---|---|---|---|
f79220515b | |||
69c27d5266 | |||
40bc4d9147 | |||
33212a7963 |
29 changed files with 513 additions and 91 deletions
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
2
assets/icons/icon.svg
Normal 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 |
|
@ -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
47
deploy/Dockerfile
Normal 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
16
deploy/docker-compose.yml
Normal 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
|
|
@ -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
30
deploy/entrypoint.py
Executable 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
61
deploy/nginx.conf
Normal 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/*;
|
||||
}
|
|
@ -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
20
poetry.lock
generated
|
@ -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"},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
2
ucast/static/bulma/css/style.min.css
vendored
2
ucast/static/bulma/css/style.min.css
vendored
File diff suppressed because one or more lines are too long
BIN
ucast/static/ucast/favicon.ico
Normal file
BIN
ucast/static/ucast/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
|
@ -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)
|
||||
|
|
|
@ -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 %}
|
||||
|
|
24
ucast/templates/ucast/channel_download.html
Normal file
24
ucast/templates/ucast/channel_download.html
Normal 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 %}
|
24
ucast/templates/ucast/channel_edit.html
Normal file
24
ucast/templates/ucast/channel_edit.html
Normal 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 %}
|
|
@ -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 }}"
|
||||
|
|
50
ucast/templates/ucast/download_errors.html
Normal file
50
ucast/templates/ucast/download_errors.html
Normal 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 %}
|
27
ucast/templates/ucast/error_details.html
Normal file
27
ucast/templates/ucast/error_details.html
Normal 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 %}
|
|
@ -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?">
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in a new issue