Files

515 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import logging
import os
from urllib.parse import urlsplit
from flask import Flask, flash, jsonify, redirect, render_template_string, request, url_for
from controller.config import ControllerConfig, load_config
from controller.models import StoreError
from controller.service import ControllerService, ControllerUnavailableError
LOG = logging.getLogger(__name__)
HTML_FORM = """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>YouTube Media Submit</title>
<!-- Make it behave on mobile -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
:root {
color-scheme: dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 0;
background: #020617;
color: #e5e7eb;
}
.page-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.card {
width: 100%;
max-width: 520px;
background: rgba(15, 23, 42, 0.98);
padding: 1.75rem 1.5rem 1.4rem;
border-radius: 1rem;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
}
h2 {
margin: 0 0 0.4rem;
font-size: 1.35rem;
}
.subtitle {
margin: 0 0 1.1rem;
font-size: 0.95rem;
color: #9ca3af;
}
.flash {
margin-bottom: 0.75rem;
padding: 0.55rem 0.75rem;
border-radius: 0.6rem;
background: rgba(30, 64, 175, 0.35);
border: 1px solid rgba(129, 140, 248, 0.7);
font-size: 0.9rem;
}
.form-group {
margin-bottom: 0.85rem;
}
label {
display: block;
margin-bottom: 0.3rem;
font-size: 0.92rem;
}
input,
textarea {
width: 100%;
box-sizing: border-box;
padding: 0.7rem 0.85rem;
border-radius: 0.65rem;
border: 1px solid #4b5563;
background: #020617;
color: #e5e7eb;
font-size: 1rem;
}
input[type="radio"] {
width: auto;
margin: 0;
}
fieldset {
border: 0;
padding: 0;
}
legend {
margin-bottom: 0.35rem;
font-size: 0.92rem;
}
.download-options {
display: grid;
gap: 0.4rem;
}
.download-options label {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 0;
}
input::placeholder,
textarea::placeholder {
color: #6b7280;
}
input:focus,
textarea:focus {
outline: 2px solid #38bdf8;
outline-offset: 2px;
border-color: #38bdf8;
}
textarea {
min-height: 3.2rem;
resize: vertical;
}
button {
width: 100%;
padding: 0.8rem 1.5rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: #fff;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.2rem;
}
button:active {
transform: scale(0.98);
}
.helper {
margin-top: 0.9rem;
font-size: 0.8rem;
color: #9ca3af;
}
.export-status {
margin-top: 1rem;
padding-top: 0.9rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.export-job {
margin-top: 0.65rem;
padding: 0.65rem 0.75rem;
border-radius: 0.55rem;
background: rgba(30, 41, 59, 0.7);
font-size: 0.82rem;
}
.export-path,
.export-error {
overflow-wrap: anywhere;
color: #cbd5e1;
}
@media (max-width: 480px) {
.page-wrapper {
padding: 1rem;
}
.card {
padding: 1.4rem 1.1rem 1.2rem;
border-radius: 0.9rem;
}
h2 {
font-size: 1.2rem;
}
input,
textarea {
font-size: 1.02rem;
}
button {
font-size: 1.02rem;
}
}
</style>
</head>
<body>
<div class="page-wrapper">
<div class="card">
<h2>Submit YouTube Media</h2>
<p class="subtitle">
Paste a YouTube video or playlist link and choose a download type.
</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for m in messages %}
<div class="flash">{{ m }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="{{ url_for('submit') }}">
<div class="form-group">
<label for="url">YouTube URL</label>
<input
type="url"
id="url"
name="url"
required
placeholder="https://www.youtube.com/playlist?list=..."
>
</div>
<div class="form-group">
<label for="submitter">Your name (optional)</label>
<input
type="text"
id="submitter"
name="submitter"
placeholder="Simon Peter"
>
</div>
<div class="form-group">
<label for="note">Note (optional)</label>
<textarea
id="note"
name="note"
rows="3"
placeholder="Service, event, etc."
></textarea>
</div>
<fieldset class="form-group">
<legend>Download as:</legend>
<div class="download-options">
<label><input type="radio" name="download_mode" value="player_slot" checked> Player Slot</label>
<label><input type="radio" name="download_mode" value="mp4"> MP4 Export</label>
<label><input type="radio" name="download_mode" value="m4a"> M4A Audio</label>
<label><input type="radio" name="download_mode" value="mp3"> MP3 Audio</label>
</div>
</fieldset>
<button type="submit">Submit</button>
</form>
<p class="helper">
Playlists are kept for roughly 48 hours after they’re downloaded.
Utility exports are kept until an operator removes them.
</p>
{% if export_jobs %}
<section class="export-status">
<strong>Recent utility exports</strong>
{% for job in export_jobs %}
<div class="export-job">
<div>{{ download_mode_labels[job.mode] }} — {{ export_status_labels[job.status] }} (job {{ job.job_id[:8] }})</div>
{% for path in job.output_paths %}
<div class="export-path">{{ path }}</div>
{% endfor %}
{% if job.error %}
<div class="export-error">{{ job.error }}</div>
{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
</div>
</div>
</body>
</html>
"""
ALLOWED_YOUTUBE_HOSTS = frozenset(
{
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
}
)
MAX_URL_LENGTH = 2048
MAX_SUBMITTER_LENGTH = 200
MAX_NOTE_LENGTH = 1000
MAX_REQUEST_CONTENT_LENGTH = 32 * 1024
DOWNLOAD_MODE_LABELS = {
"player_slot": "Player Slot",
"mp4": "MP4 Export",
"m4a": "M4A Audio",
"mp3": "MP3 Audio",
}
EXPORT_STATUS_LABELS = {
"queued": "queued",
"running": "downloading",
"completed": "completed",
"failed": "failed",
"interrupted": "interrupted",
}
def _valid_media_url(value: str) -> bool:
if not value or len(value) > MAX_URL_LENGTH:
return False
try:
parsed = urlsplit(value)
hostname = parsed.hostname
has_userinfo = parsed.username is not None or parsed.password is not None
except ValueError:
return False
if parsed.scheme.lower() not in {"http", "https"}:
return False
if has_userinfo or not hostname:
return False
return hostname.lower() in ALLOWED_YOUTUBE_HOSTS
def _submission_message(result: dict[str, object]) -> str:
status = result.get("status")
if result.get("kind") == "export":
mode = str(result.get("mode") or "")
label = DOWNLOAD_MODE_LABELS.get(mode, "Utility export")
if status == "queue_full":
return "The utility export queue is full; please try again later."
if status == "shutting_down":
return "The Controller is shutting down; please try again shortly."
if status == "worker_error":
return f"The {label} job was reserved, but its worker could not start."
if status != "queued":
return f"The Controller could not queue that {label} job; please try again."
job_id = str(result.get("job_id") or "")[:8]
identity = f" (job {job_id})" if job_id else ""
return f"{label} queued{identity}. Refresh this page to see its result."
if status == "queue_full":
seconds = max(0, int(result.get("oldest_expires_in_seconds") or 0))
hours = max(1, (seconds + 3599) // 3600)
return f"Queue is full. Oldest playlist expires in about {hours} hour(s)."
if status == "slot_busy":
return f"Slot {result.get('slot')} is already being downloaded."
if status == "slot_protected":
return (
f"Slot {result.get('slot')} is protected by active or uncertain playback; "
"the playlist was not replaced."
)
if status == "shutting_down":
return "The Controller is shutting down; please try again shortly."
if status == "worker_error":
return "The playlist was reserved, but its download worker could not start."
if status != "queued":
return "The Controller could not queue that playlist; please try again."
slot = result.get("slot")
message = f"Playlist queued in slot {slot}."
if result.get("reused_slot"):
message += " (Existing slot – only new videos will be added.)"
else:
message += " It will start downloading shortly."
return message
def create_app(
config: ControllerConfig,
service: ControllerService,
*,
testing: bool = False,
) -> Flask:
"""Create the HTTP surface without starting MQTT, threads, or persistence."""
secret_key = os.environ.get("FLASK_SECRET_KEY")
if not secret_key and not testing:
raise RuntimeError("FLASK_SECRET_KEY must be set for the Controller web service")
web_app = Flask(__name__)
web_app.config.update(
TESTING=testing,
SECRET_KEY=secret_key or "controller-test-only-secret",
MAX_CONTENT_LENGTH=MAX_REQUEST_CONTENT_LENGTH,
)
web_app.extensions["controller_service"] = service
web_app.extensions["controller_config"] = config
@web_app.get("/")
def index():
try:
export_jobs = service.list_export_jobs(
limit=min(100, config.downloads.max_queued_exports + 10)
)
except Exception as exc:
LOG.warning("could not load utility export status: %s", exc)
export_jobs = []
return render_template_string(
HTML_FORM,
export_jobs=export_jobs,
download_mode_labels=DOWNLOAD_MODE_LABELS,
export_status_labels=EXPORT_STATUS_LABELS,
)
@web_app.errorhandler(413)
def _submission_too_large(_error):
flash("The submission was too large.")
return redirect(url_for("index"))
@web_app.post("/submit")
def submit():
url = request.form.get("url", "").strip()
submitter = request.form.get("submitter", "").strip() or None
note = request.form.get("note", "").strip() or None
raw_mode = request.form.get("download_mode", "player_slot")
download_mode = raw_mode.strip() if isinstance(raw_mode, str) else ""
if not _valid_media_url(url):
flash(
"Invalid URL. Only YouTube links are accepted "
"(e.g. https://www.youtube.com/... or https://youtu.be/...)."
)
return redirect(url_for("index"))
if submitter is not None and len(submitter) > MAX_SUBMITTER_LENGTH:
flash(f"Your name must be {MAX_SUBMITTER_LENGTH} characters or fewer.")
return redirect(url_for("index"))
if note is not None and len(note) > MAX_NOTE_LENGTH:
flash(f"The note must be {MAX_NOTE_LENGTH} characters or fewer.")
return redirect(url_for("index"))
if download_mode not in DOWNLOAD_MODE_LABELS:
flash("Invalid download type. Choose one of the available options.")
return redirect(url_for("index"))
try:
if download_mode == "player_slot":
result = service.enqueue_download(url, submitter, note)
else:
result = service.enqueue_export(
url,
download_mode,
submitter,
note,
)
except ControllerUnavailableError as exc:
LOG.warning("playlist submission was rejected: %s", exc)
flash("The Controller is unavailable; please try again shortly.")
except StoreError as exc:
LOG.error("playlist submission hit a storage error: %s", exc)
flash("Controller storage is unavailable; the playlist was not queued.")
except ValueError as exc:
LOG.warning("playlist submission was rejected: %s", exc)
flash(f"The playlist request was rejected: {exc}")
except Exception:
LOG.exception("playlist submission failed unexpectedly")
flash("The Controller could not queue that playlist; please try again.")
else:
flash(_submission_message(result))
return redirect(url_for("index"))
@web_app.get("/healthz")
def healthz():
try:
health = service.health()
except Exception as exc:
LOG.exception("Controller health check failed")
return jsonify(
{
"status": "unavailable",
"started": False,
"coordinator_alive": False,
"error": str(exc),
}
), 503
serving = bool(health.get("started") and health.get("coordinator_alive"))
return jsonify(health), 200 if serving else 503
return web_app
def _run_development_server() -> None:
config = load_config()
service = ControllerService(config)
web_app = create_app(config, service)
service.start()
try:
web_app.run(host=config.web.host, port=config.web.port)
finally:
service.stop()
if __name__ == "__main__":
_run_development_server()