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 = """
Submit YouTube Playlist
Paste a playlist link below. It’ll be queued for download and assigned to the next free slot.
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for m in messages %}
{{ m }}
{% endfor %}
{% endif %}
{% endwith %}
Playlists are kept for roughly 48 hours after they’re downloaded.
"""
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
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 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():
return render_template_string(HTML_FORM)
@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
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"))
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()