Initial implementation of youtube-automate
A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel as a show and each video as an episode. Cron-driven, idempotent, with a public admin UI for a non-operator. Verified end to end on susan against three real channels: PO tokens, h264 downloads, Jellyfin resolution from local NFOs with all providers disabled, retention and tombstones. Corrections to the original design handover (specs.md documents each with the evidence, and specs.handover-original.md preserves the original): - The format sort selected 360p. Ranking acodec above res makes `bv*` prefer the combined 360p stream, which carries AAC, over the 720p video-only stream whose acodec is none. vcodec now leads, so a video without h264 at 720p yields h264 lower down rather than VP9 this hardware cannot transcode. - yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which only ship with the [default] extra. Without them the n challenge fails and the mweb formats disappear entirely. - --flat-playlist carries no upload dates, so the specced client-side date filter for backfill was impossible. Backfill is RSS-first. - skipped_old was terminal, so raising a channel's retention appeared to do nothing. Added an explicit rescan. - is_upcoming premieres now defer and retry instead of being skipped forever. - TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost were both reclaimed; the latter still pointed at its dead port. 240 offline tests, no network and no real yt-dlp invocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Admin web UI."""
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Password hashing, session cookies and CSRF tokens.
|
||||
|
||||
The admin UI is publicly reachable over HTTPS, so this has to be real. The design
|
||||
goal from specs.md §11 is "log in once per device, effectively never again",
|
||||
which means a long-lived signed cookie rather than HTTP basic auth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
SCRYPT_N = 2**14
|
||||
SCRYPT_R = 8
|
||||
SCRYPT_P = 1
|
||||
DKLEN = 32
|
||||
|
||||
SESSION_MAX_AGE = 365 * 24 * 3600 # one year
|
||||
COOKIE_NAME = "yta_session"
|
||||
|
||||
# Login throttling: after this many consecutive failures from one address, refuse
|
||||
# for LOCKOUT_SECONDS regardless of whether the password is right.
|
||||
MAX_FAILURES = 5
|
||||
LOCKOUT_SECONDS = 60
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _unb64(text: str) -> bytes:
|
||||
padding = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + padding)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# passwords
|
||||
|
||||
|
||||
def hash_password(password: str, *, salt: bytes | None = None) -> str:
|
||||
salt = salt if salt is not None else secrets.token_bytes(16)
|
||||
derived = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=SCRYPT_N,
|
||||
r=SCRYPT_R,
|
||||
p=SCRYPT_P,
|
||||
dklen=DKLEN,
|
||||
)
|
||||
return f"scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${_b64(salt)}${_b64(derived)}"
|
||||
|
||||
|
||||
def verify_password(stored: str, password: str) -> bool:
|
||||
if not stored:
|
||||
return False
|
||||
try:
|
||||
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
derived = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=_unb64(salt_b64),
|
||||
n=int(n),
|
||||
r=int(r),
|
||||
p=int(p),
|
||||
dklen=len(_unb64(hash_b64)),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(derived, _unb64(hash_b64))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# sessions
|
||||
|
||||
|
||||
def new_secret() -> str:
|
||||
return _b64(secrets.token_bytes(32))
|
||||
|
||||
|
||||
def _sign(secret: str, payload: bytes) -> str:
|
||||
return _b64(hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest())
|
||||
|
||||
|
||||
def issue_session(secret: str, *, issued_at: float | None = None) -> str:
|
||||
payload = json.dumps(
|
||||
{"iat": int(issued_at if issued_at is not None else time.time())},
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return f"{_b64(payload)}.{_sign(secret, payload)}"
|
||||
|
||||
|
||||
def verify_session(secret: str, token: str, *, now: float | None = None) -> bool:
|
||||
if not token or not secret:
|
||||
return False
|
||||
try:
|
||||
payload_b64, signature = token.split(".", 1)
|
||||
payload = _unb64(payload_b64)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if not hmac.compare_digest(_sign(secret, payload), signature):
|
||||
return False
|
||||
try:
|
||||
issued_at = int(json.loads(payload)["iat"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return False
|
||||
age = (now if now is not None else time.time()) - issued_at
|
||||
return 0 <= age <= SESSION_MAX_AGE
|
||||
|
||||
|
||||
def cookie_header(token: str, *, secure: bool = True) -> str:
|
||||
parts = [
|
||||
f"{COOKIE_NAME}={token}",
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
f"Max-Age={SESSION_MAX_AGE}",
|
||||
]
|
||||
if secure:
|
||||
parts.insert(2, "Secure")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def clear_cookie_header() -> str:
|
||||
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CSRF
|
||||
|
||||
|
||||
def csrf_token(secret: str, session_token: str) -> str:
|
||||
return _sign(secret, b"csrf:" + session_token.encode("utf-8"))
|
||||
|
||||
|
||||
def verify_csrf(secret: str, session_token: str, submitted: str) -> bool:
|
||||
if not submitted:
|
||||
return False
|
||||
return hmac.compare_digest(csrf_token(secret, session_token), submitted)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# throttling
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""In-memory consecutive-failure tracker keyed by remote address."""
|
||||
|
||||
def __init__(self, max_failures: int = MAX_FAILURES, lockout: int = LOCKOUT_SECONDS):
|
||||
self.max_failures = max_failures
|
||||
self.lockout = lockout
|
||||
self._state: dict[str, tuple[int, float]] = {}
|
||||
|
||||
def locked(self, key: str, *, now: float | None = None) -> bool:
|
||||
failures, last = self._state.get(key, (0, 0.0))
|
||||
if failures < self.max_failures:
|
||||
return False
|
||||
elapsed = (now if now is not None else time.time()) - last
|
||||
if elapsed >= self.lockout:
|
||||
self._state.pop(key, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def record_failure(self, key: str, *, now: float | None = None) -> None:
|
||||
failures, _ = self._state.get(key, (0, 0.0))
|
||||
self._state[key] = (failures + 1, now if now is not None else time.time())
|
||||
|
||||
def record_success(self, key: str) -> None:
|
||||
self._state.pop(key, None)
|
||||
@@ -0,0 +1,400 @@
|
||||
"""The admin HTTP server.
|
||||
|
||||
Stdlib only. Binds to localhost; nginx terminates TLS in front of it at
|
||||
tube.jihakuz.xyz. Because that hostname is public, this carries real
|
||||
authentication, CSRF tokens on every state-changing request, and login
|
||||
throttling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.cookies
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from .. import channels, config, db, discovery, jellyfin, util, videos, ytdlp
|
||||
from ..settings import EDITABLE, MASKED_KEYS, Settings, validate_all
|
||||
from . import auth, templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_BODY = 64 * 1024
|
||||
|
||||
|
||||
class AdminServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address, handler, *, secure_cookies: bool = True):
|
||||
super().__init__(address, handler)
|
||||
self.throttle = auth.LoginThrottle()
|
||||
self.secure_cookies = secure_cookies
|
||||
self.db_lock = threading.Lock()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "youtube-automate"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
# ---------------------------------------------------------------- utils
|
||||
|
||||
def log_message(self, fmt, *args): # noqa: A003 - stdlib signature
|
||||
log.debug("%s - %s", self.client_address[0], fmt % args)
|
||||
|
||||
def _client_key(self) -> str:
|
||||
"""Real client address, since we always sit behind nginx."""
|
||||
forwarded = self.headers.get("X-Forwarded-For", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return self.client_address[0]
|
||||
|
||||
def _send(self, status: int, body: bytes, headers: dict | None = None) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "same-origin")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
for key, value in (headers or {}).items():
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _redirect(self, location: str, headers: dict | None = None) -> None:
|
||||
combined = {"Location": location}
|
||||
combined.update(headers or {})
|
||||
self._send(303, b"", combined)
|
||||
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
body = json.dumps(payload, indent=2).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _form(self) -> dict[str, str]:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if length <= 0 or length > MAX_BODY:
|
||||
return {}
|
||||
raw = self.rfile.read(length).decode("utf-8", "replace")
|
||||
return {
|
||||
key: values[-1]
|
||||
for key, values in urllib.parse.parse_qs(raw, keep_blank_values=True).items()
|
||||
}
|
||||
|
||||
def _cookie_token(self) -> str:
|
||||
header = self.headers.get("Cookie")
|
||||
if not header:
|
||||
return ""
|
||||
jar = http.cookies.SimpleCookie()
|
||||
try:
|
||||
jar.load(header)
|
||||
except http.cookies.CookieError:
|
||||
return ""
|
||||
morsel = jar.get(auth.COOKIE_NAME)
|
||||
return morsel.value if morsel else ""
|
||||
|
||||
# ------------------------------------------------------------- session
|
||||
|
||||
def _open(self):
|
||||
conn = db.connect()
|
||||
return conn, Settings(conn)
|
||||
|
||||
def _secret(self, settings: Settings) -> str:
|
||||
secret = settings.raw("session_secret")
|
||||
if not secret:
|
||||
secret = auth.new_secret()
|
||||
settings.set("session_secret", secret)
|
||||
return secret
|
||||
|
||||
def _authenticated(self, settings: Settings) -> str | None:
|
||||
"""Return the session token if the request is signed in, else None."""
|
||||
token = self._cookie_token()
|
||||
if token and auth.verify_session(self._secret(settings), token):
|
||||
return token
|
||||
return None
|
||||
|
||||
def _check_csrf(self, settings: Settings, token: str, form: dict) -> bool:
|
||||
return auth.verify_csrf(self._secret(settings), token, form.get("csrf", ""))
|
||||
|
||||
# ---------------------------------------------------------------- GET
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - stdlib signature
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
conn, settings = self._open()
|
||||
try:
|
||||
token = self._authenticated(settings)
|
||||
|
||||
# /health was specced as unauthenticated back when the UI was going
|
||||
# to be tailnet-only. On a public hostname it is gratuitous
|
||||
# fingerprinting surface (yt-dlp version, queue depth), so it needs
|
||||
# a session like everything else.
|
||||
if path == "/health":
|
||||
if not token:
|
||||
return self._json(401, {"error": "authentication required"})
|
||||
return self._health(conn, settings)
|
||||
|
||||
if path == "/login":
|
||||
if token:
|
||||
return self._redirect("/")
|
||||
return self._send(200, templates.login_page(self._login_hint(settings)))
|
||||
|
||||
if not token:
|
||||
return self._redirect("/login")
|
||||
|
||||
if path == "/":
|
||||
return self._send(200, self._render_index(conn, settings, token))
|
||||
|
||||
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _login_hint(self, settings: Settings) -> str | None:
|
||||
if not settings.raw("admin_password_hash"):
|
||||
return "No password is set yet. Run `youtube-automate set-password` on susan."
|
||||
return None
|
||||
|
||||
def _health(self, conn, settings: Settings) -> None:
|
||||
try:
|
||||
ytdlp_version = ytdlp.version()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ytdlp_version = f"error: {exc}"
|
||||
try:
|
||||
ytdlp.pot_provider_ping(settings.get_str("pot_provider_url"))
|
||||
pot_up = True
|
||||
except Exception: # noqa: BLE001
|
||||
pot_up = False
|
||||
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"yt_dlp_version": ytdlp_version,
|
||||
"pot_provider_up": pot_up,
|
||||
"last_run_at": settings.raw("last_run_at") or None,
|
||||
"queue_depth": videos.queue_depth(conn),
|
||||
"channels": len(channels.all_channels(conn)),
|
||||
},
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- POST
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - stdlib signature
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
conn, settings = self._open()
|
||||
try:
|
||||
form = self._form()
|
||||
|
||||
if path == "/login":
|
||||
return self._login(settings, form)
|
||||
|
||||
token = self._authenticated(settings)
|
||||
if not token:
|
||||
return self._redirect("/login")
|
||||
|
||||
if not self._check_csrf(settings, token, form):
|
||||
log.warning("CSRF check failed for %s from %s", path, self._client_key())
|
||||
return self._send(
|
||||
400,
|
||||
templates.page(
|
||||
"Bad request",
|
||||
"<h1>Bad request</h1><p>Invalid form token. "
|
||||
'<a href="/">Go back</a> and try again.</p>',
|
||||
),
|
||||
)
|
||||
|
||||
if path == "/logout":
|
||||
return self._redirect("/login", {"Set-Cookie": auth.clear_cookie_header()})
|
||||
if path == "/channels":
|
||||
return self._add_channel(conn, settings, token, form)
|
||||
if path == "/settings":
|
||||
return self._save_settings(conn, settings, token, form)
|
||||
|
||||
parts = path.strip("/").split("/")
|
||||
if len(parts) == 3 and parts[0] == "channels" and parts[1].isdigit():
|
||||
pk = int(parts[1])
|
||||
if parts[2] == "delete":
|
||||
return self._delete_channel(conn, settings, pk)
|
||||
if parts[2] == "retention":
|
||||
return self._set_retention(conn, pk, form)
|
||||
if parts[2] == "rescan":
|
||||
return self._rescan(conn, settings, pk)
|
||||
|
||||
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _login(self, settings: Settings, form: dict) -> None:
|
||||
key = self._client_key()
|
||||
if self.server.throttle.locked(key):
|
||||
return self._send(
|
||||
429, templates.login_page("Too many attempts. Wait a minute.")
|
||||
)
|
||||
|
||||
stored = settings.raw("admin_password_hash")
|
||||
if stored and auth.verify_password(stored, form.get("password", "")):
|
||||
self.server.throttle.record_success(key)
|
||||
token = auth.issue_session(self._secret(settings))
|
||||
return self._redirect(
|
||||
"/",
|
||||
{
|
||||
"Set-Cookie": auth.cookie_header(
|
||||
token, secure=self.server.secure_cookies
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
self.server.throttle.record_failure(key)
|
||||
log.warning("failed login from %s", key)
|
||||
return self._send(
|
||||
401, templates.login_page(self._login_hint(settings) or "Wrong password.")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ actions
|
||||
|
||||
def _add_channel(self, conn, settings: Settings, token: str, form: dict) -> None:
|
||||
url = (form.get("url") or "").strip()
|
||||
try:
|
||||
row = channels.subscribe(conn, settings, url)
|
||||
except channels.ResolutionError as exc:
|
||||
body = self._render_index(conn, settings, token, add_error=str(exc))
|
||||
return self._send(400, body)
|
||||
|
||||
self._spawn_backfill(row["id"])
|
||||
return self._redirect("/")
|
||||
|
||||
def _spawn_backfill(self, channel_pk: int) -> None:
|
||||
"""Kick off discovery immediately rather than waiting for the hourly cron."""
|
||||
try:
|
||||
subprocess.Popen( # noqa: S603
|
||||
[sys.executable, "-m", "youtube_automate", "run", "--channel",
|
||||
str(channel_pk)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as exc: # pragma: no cover
|
||||
log.warning("could not spawn backfill for channel %d: %s", channel_pk, exc)
|
||||
|
||||
def _delete_channel(self, conn, settings: Settings, pk: int) -> None:
|
||||
try:
|
||||
channels.unsubscribe(conn, pk)
|
||||
except LookupError:
|
||||
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
|
||||
jellyfin.from_settings(settings).refresh()
|
||||
return self._redirect("/")
|
||||
|
||||
def _set_retention(self, conn, pk: int, form: dict) -> None:
|
||||
raw = (form.get("days") or "").strip()
|
||||
value: int | None
|
||||
if not raw:
|
||||
value = None
|
||||
else:
|
||||
try:
|
||||
value = max(1, int(raw))
|
||||
except ValueError:
|
||||
return self._redirect("/")
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE channel SET retention_days = ? WHERE id = ?", (value, pk)
|
||||
)
|
||||
return self._redirect("/")
|
||||
|
||||
def _rescan(self, conn, settings: Settings, pk: int) -> None:
|
||||
channel = channels.get(conn, pk)
|
||||
if channel is None:
|
||||
return self._send(404, templates.page("Not found", "<h1>Not found</h1>"))
|
||||
discovery.rescan_channel(conn, settings, channel)
|
||||
self._spawn_backfill(pk)
|
||||
return self._redirect("/")
|
||||
|
||||
def _save_settings(self, conn, settings: Settings, token: str, form: dict) -> None:
|
||||
submitted = {key: form.get(key, "") for key in EDITABLE if key in form}
|
||||
|
||||
# A blank masked field means "keep what is stored", not "clear it".
|
||||
for key in MASKED_KEYS:
|
||||
if key in submitted and not submitted[key].strip():
|
||||
submitted.pop(key)
|
||||
|
||||
errors = validate_all(submitted)
|
||||
if errors:
|
||||
body = self._render_index(
|
||||
conn, settings, token, settings_errors=errors, submitted=submitted
|
||||
)
|
||||
return self._send(400, body)
|
||||
|
||||
for key, value in submitted.items():
|
||||
settings.set(key, value.strip())
|
||||
return self._redirect("/")
|
||||
|
||||
# ------------------------------------------------------------- render
|
||||
|
||||
def _render_index(
|
||||
self,
|
||||
conn,
|
||||
settings: Settings,
|
||||
token: str,
|
||||
*,
|
||||
add_error: str | None = None,
|
||||
settings_errors: dict | None = None,
|
||||
submitted: dict | None = None,
|
||||
) -> bytes:
|
||||
global_retention = settings.get_int("retention_days")
|
||||
rows = []
|
||||
for channel in channels.all_channels(conn):
|
||||
stats = conn.execute(
|
||||
"SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes, "
|
||||
"MAX(upload_date) AS latest FROM video "
|
||||
"WHERE channel_pk = ? AND state = ?",
|
||||
(channel["id"], videos.DOWNLOADED),
|
||||
).fetchone()
|
||||
latest_any = conn.execute(
|
||||
"SELECT MAX(upload_date) AS latest FROM video WHERE channel_pk = ?",
|
||||
(channel["id"],),
|
||||
).fetchone()
|
||||
rows.append(
|
||||
{
|
||||
"id": channel["id"],
|
||||
"title": channel["title"],
|
||||
"handle": channel["handle"],
|
||||
"channel_id": channel["channel_id"],
|
||||
"retention_days": channel["retention_days"],
|
||||
"global_retention": global_retention,
|
||||
"last_polled_at": channel["last_polled_at"],
|
||||
"last_poll_ok": channel["last_poll_ok"],
|
||||
"consecutive_poll_failures": channel["consecutive_poll_failures"],
|
||||
"downloaded": stats["n"],
|
||||
"bytes": stats["bytes"],
|
||||
"latest": latest_any["latest"],
|
||||
}
|
||||
)
|
||||
|
||||
values = settings.all_editable()
|
||||
if submitted:
|
||||
values.update(submitted)
|
||||
|
||||
return templates.index_page(
|
||||
channels=rows,
|
||||
settings_values=values,
|
||||
settings_errors=settings_errors or {},
|
||||
csrf=auth.csrf_token(self._secret(settings), token),
|
||||
add_error=add_error,
|
||||
queue_depth=videos.queue_depth(conn),
|
||||
)
|
||||
|
||||
|
||||
def serve(host: str = "127.0.0.1", port: int = 8085, *, secure_cookies: bool = True) -> None:
|
||||
util.apply_umask()
|
||||
server = AdminServer((host, port), Handler, secure_cookies=secure_cookies)
|
||||
log.info("admin server listening on http://%s:%d", host, port)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt: # pragma: no cover
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Server-rendered HTML. One embedded stylesheet, no JavaScript beyond a
|
||||
confirm() on the destructive buttons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import date
|
||||
|
||||
from .. import util
|
||||
from ..settings import DEFAULTS, EDITABLE, MASKED_KEYS
|
||||
|
||||
STYLE = """
|
||||
:root {
|
||||
--bg: #14161a; --panel: #1c1f26; --line: #2c313b; --text: #e6e8ec;
|
||||
--muted: #99a0ae; --accent: #6aa9ff; --warn: #ffb454; --bad: #ff6b6b;
|
||||
--good: #6ade9b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||
main { max-width: 62rem; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||||
h1 { font-size: 1.4rem; margin: 0; }
|
||||
h2 { font-size: 1.05rem; margin: 2rem 0 .75rem; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: .06em; }
|
||||
header { display: flex; align-items: baseline; justify-content: space-between;
|
||||
gap: 1rem; border-bottom: 1px solid var(--line); padding-bottom: .75rem; }
|
||||
header .sub { color: var(--muted); font-size: .85rem; }
|
||||
a { color: var(--accent); }
|
||||
.panel { background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: .55rem .5rem; border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: .78rem;
|
||||
text-transform: uppercase; letter-spacing: .05em; }
|
||||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted); }
|
||||
.badge { display: inline-block; padding: .1rem .45rem; border-radius: 999px;
|
||||
font-size: .75rem; border: 1px solid currentColor; }
|
||||
.badge.warn { color: var(--warn); }
|
||||
.badge.good { color: var(--good); }
|
||||
.badge.bad { color: var(--bad); }
|
||||
input[type=text], input[type=password], input[type=number], select {
|
||||
background: #12141a; color: var(--text); border: 1px solid var(--line);
|
||||
border-radius: 7px; padding: .45rem .55rem; font: inherit; width: 100%; }
|
||||
button { background: var(--accent); color: #0b1017; border: 0; border-radius: 7px;
|
||||
padding: .5rem .9rem; font: inherit; font-weight: 600; cursor: pointer; }
|
||||
button.secondary { background: #2b3140; color: var(--text); }
|
||||
button.danger { background: transparent; color: var(--bad);
|
||||
border: 1px solid var(--bad); font-weight: 500; padding: .3rem .6rem; }
|
||||
button.link { background: transparent; color: var(--accent); border: 0;
|
||||
padding: .3rem .4rem; font-weight: 500; }
|
||||
form.inline { display: inline; }
|
||||
.row { display: flex; gap: .6rem; align-items: center; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: .85rem; }
|
||||
label { display: block; font-size: .82rem; color: var(--muted);
|
||||
margin-bottom: .25rem; }
|
||||
.field { margin-bottom: .3rem; }
|
||||
.error { color: var(--bad); font-size: .8rem; margin-top: .2rem; }
|
||||
.flash { border-radius: 8px; padding: .6rem .8rem; margin-bottom: 1rem;
|
||||
border: 1px solid; }
|
||||
.flash.ok { color: var(--good); border-color: var(--good); }
|
||||
.flash.bad { color: var(--bad); border-color: var(--bad); }
|
||||
.login { max-width: 21rem; margin: 6rem auto; }
|
||||
footer { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; }
|
||||
@media (max-width: 40rem) {
|
||||
th.hide, td.hide { display: none; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _e(value) -> str:
|
||||
return html.escape("" if value is None else str(value), quote=True)
|
||||
|
||||
|
||||
def page(title: str, body: str) -> bytes:
|
||||
return f"""<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{_e(title)}</title>
|
||||
<style>{STYLE}</style>
|
||||
</head><body><main>{body}</main></body></html>""".encode("utf-8")
|
||||
|
||||
|
||||
def login_page(error: str | None = None) -> bytes:
|
||||
alert = f'<div class="flash bad">{_e(error)}</div>' if error else ""
|
||||
body = f"""
|
||||
<div class="login">
|
||||
<h1>youtube-automate</h1>
|
||||
<p class="muted">Sign in to manage subscriptions.</p>
|
||||
{alert}
|
||||
<form method="post" action="/login" class="panel">
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autofocus
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
<div style="margin-top:.8rem"><button type="submit">Sign in</button></div>
|
||||
</form>
|
||||
<footer>You will stay signed in on this device for a year.</footer>
|
||||
</div>"""
|
||||
return page("Sign in — youtube-automate", body)
|
||||
|
||||
|
||||
def _channel_row(channel: dict, csrf: str) -> str:
|
||||
failures = channel["consecutive_poll_failures"]
|
||||
if failures > 2:
|
||||
badge = f'<span class="badge bad">{failures} failed polls</span>'
|
||||
elif channel["last_poll_ok"] == 0:
|
||||
badge = '<span class="badge warn">last poll failed</span>'
|
||||
else:
|
||||
badge = ""
|
||||
|
||||
retention = channel["retention_days"]
|
||||
retention_value = "" if retention is None else str(retention)
|
||||
placeholder = f"default ({channel['global_retention']})"
|
||||
|
||||
return f"""
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{_e(channel['title'])}</strong> {badge}<br>
|
||||
<span class="muted">{_e(channel['handle'] or channel['channel_id'])}</span>
|
||||
</td>
|
||||
<td class="num">{channel['downloaded']}</td>
|
||||
<td class="num hide">{_e(util.human_bytes(channel['bytes']))}</td>
|
||||
<td class="hide muted">{_e(channel['latest'] or '—')}</td>
|
||||
<td class="hide muted">{_e(channel['last_polled_at'] or 'never')}</td>
|
||||
<td>
|
||||
<form method="post" action="/channels/{channel['id']}/retention" class="row">
|
||||
<input type="hidden" name="csrf" value="{_e(csrf)}">
|
||||
<input type="number" name="days" min="1" style="width:6.5rem"
|
||||
value="{_e(retention_value)}" placeholder="{_e(placeholder)}">
|
||||
<button class="link" type="submit">save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/channels/{channel['id']}/rescan" class="inline">
|
||||
<input type="hidden" name="csrf" value="{_e(csrf)}">
|
||||
<button class="link" type="submit"
|
||||
title="Re-queue videos previously skipped as too old that the current
|
||||
retention window now covers">rescan</button>
|
||||
</form>
|
||||
<form method="post" action="/channels/{channel['id']}/delete" class="inline"
|
||||
onsubmit="return confirm('Permanently delete {_e(channel['title'])} and every video downloaded for it? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf" value="{_e(csrf)}">
|
||||
<button class="danger" type="submit">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
|
||||
def _settings_form(values: dict, errors: dict, csrf: str) -> str:
|
||||
fields = []
|
||||
for key in EDITABLE:
|
||||
value = values.get(key, DEFAULTS[key])
|
||||
error = errors.get(key)
|
||||
if key in MASKED_KEYS and value:
|
||||
shown, placeholder = "", "stored — leave blank to keep"
|
||||
else:
|
||||
shown, placeholder = value, ""
|
||||
input_type = "password" if key in MASKED_KEYS else "text"
|
||||
fields.append(
|
||||
f"""<div class="field">
|
||||
<label for="{_e(key)}">{_e(key.replace('_', ' '))}</label>
|
||||
<input type="{input_type}" id="{_e(key)}" name="{_e(key)}"
|
||||
value="{_e(shown)}" placeholder="{_e(placeholder)}" autocomplete="off">
|
||||
{f'<div class="error">{_e(error)}</div>' if error else ''}
|
||||
</div>"""
|
||||
)
|
||||
|
||||
return f"""
|
||||
<form method="post" action="/settings" class="panel">
|
||||
<input type="hidden" name="csrf" value="{_e(csrf)}">
|
||||
<div class="grid">{''.join(fields)}</div>
|
||||
<div style="margin-top:1rem"><button type="submit">Save settings</button></div>
|
||||
</form>"""
|
||||
|
||||
|
||||
def index_page(
|
||||
*,
|
||||
channels: list[dict],
|
||||
settings_values: dict,
|
||||
settings_errors: dict,
|
||||
csrf: str,
|
||||
flash: tuple[str, str] | None = None,
|
||||
add_error: str | None = None,
|
||||
queue_depth: int = 0,
|
||||
) -> bytes:
|
||||
flash_html = ""
|
||||
if flash:
|
||||
kind, message = flash
|
||||
flash_html = f'<div class="flash {kind}">{_e(message)}</div>'
|
||||
|
||||
if channels:
|
||||
rows = "".join(_channel_row(channel, csrf) for channel in channels)
|
||||
table = f"""
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Channel</th><th class="num">On disk</th><th class="num hide">Size</th>
|
||||
<th class="hide">Latest upload</th><th class="hide">Last poll</th>
|
||||
<th>Retention (days)</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>"""
|
||||
else:
|
||||
table = '<div class="panel muted">No channels yet. Add one below.</div>'
|
||||
|
||||
add_error_html = f'<div class="error">{_e(add_error)}</div>' if add_error else ""
|
||||
|
||||
body = f"""
|
||||
<header>
|
||||
<h1>youtube-automate</h1>
|
||||
<div class="sub">
|
||||
{len(channels)} channel(s) · {queue_depth} queued
|
||||
· <form method="post" action="/logout" class="inline">
|
||||
<input type="hidden" name="csrf" value="{_e(csrf)}">
|
||||
<button class="link" type="submit">sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{flash_html}
|
||||
|
||||
<h2>Channels</h2>
|
||||
{table}
|
||||
|
||||
<h2>Add a channel</h2>
|
||||
<form method="post" action="/channels" class="panel">
|
||||
<input type="hidden" name="csrf" value="{_e(csrf)}">
|
||||
<div class="row">
|
||||
<input type="text" name="url" placeholder="https://www.youtube.com/@handle, @handle, or UC..." autocomplete="off">
|
||||
<button type="submit">Add</button>
|
||||
</div>
|
||||
{add_error_html}
|
||||
</form>
|
||||
|
||||
<h2>Settings</h2>
|
||||
{_settings_form(settings_values, settings_errors, csrf)}
|
||||
|
||||
<footer>Downloads run hourly. Videos are deleted once they pass the retention
|
||||
window for their channel — this is a DVR, not an archive.</footer>"""
|
||||
return page("youtube-automate", body)
|
||||
Reference in New Issue
Block a user