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 = """ YouTube Media Submit

Submit YouTube Media

Paste a YouTube video or playlist link and choose a download type.

{% with messages = get_flashed_messages() %} {% if messages %} {% for m in messages %}
{{ m }}
{% endfor %} {% endif %} {% endwith %}
Download as:

Playlists are kept for roughly 48 hours after they’re downloaded. Utility exports are kept until an operator removes them.

{% if export_jobs %}
Recent utility exports {% for job in export_jobs %}
{{ download_mode_labels[job.mode] }} — {{ export_status_labels[job.status] }} (job {{ job.job_id[:8] }})
{% for path in job.output_paths %}
{{ path }}
{% endfor %} {% if job.error %}
{{ job.error }}
{% endif %}
{% endfor %}
{% endif %}
""" 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()