515 lines
15 KiB
Python
515 lines
15 KiB
Python
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()
|