349 lines
9.4 KiB
Python
349 lines
9.4 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 Playlist 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::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;
|
||
}
|
||
|
||
@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 Playlist</h2>
|
||
<p class="subtitle">
|
||
Paste a playlist link below. It’ll be queued for download and assigned to the next free slot.
|
||
</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>
|
||
|
||
<button type="submit">Submit Playlist</button>
|
||
</form>
|
||
|
||
<p class="helper">
|
||
Playlists are kept for roughly 48 hours after they’re downloaded.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
def _valid_media_url(value: str) -> bool:
|
||
try:
|
||
parsed = urlsplit(value)
|
||
except ValueError:
|
||
return False
|
||
return parsed.scheme.lower() in {"http", "https"} and bool(parsed.netloc)
|
||
|
||
|
||
def _submission_message(result: dict[str, object]) -> str:
|
||
status = result.get("status")
|
||
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",
|
||
)
|
||
web_app.extensions["controller_service"] = service
|
||
web_app.extensions["controller_config"] = config
|
||
|
||
@web_app.get("/")
|
||
def index():
|
||
return render_template_string(HTML_FORM)
|
||
|
||
@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
|
||
|
||
if not _valid_media_url(url):
|
||
flash("Invalid URL. Use a complete http:// or https:// address.")
|
||
return redirect(url_for("index"))
|
||
|
||
try:
|
||
result = service.enqueue_download(url, 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()
|