Files
Video-Player-Controller/app.py
T

349 lines
9.4 KiB
Python
Raw 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 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()