Initial commit

This commit is contained in:
Ian Keane 2026-09-14 13:45:57 -04:00
commit 6aa9bf85f3
10 changed files with 660 additions and 0 deletions

26
app/__init__.py Normal file
View file

@ -0,0 +1,26 @@
import os
from flask import Flask
from app.state import StreamState
def create_app():
app = Flask(__name__)
app.state = StreamState(
db_path=os.environ.get("STATE_DB_PATH", "/data/zoitestream.db"),
history_limit=int(os.environ.get("HISTORY_LIMIT", "50")),
)
app.hook_token = os.environ.get("HOOK_TOKEN", "")
app.domain = os.environ.get("DOMAIN", "stream.dumpnet.chat")
app.rtmp_port = os.environ.get("RTMP_PORT", "1935")
app.publish_user = os.environ.get("PUBLISH_USER", "streamer")
app.publish_password = os.environ.get("PUBLISH_PASSWORD", "")
app.creds_token = os.environ.get("CREDS_TOKEN", "")
from app.routes import bp
app.register_blueprint(bp)
return app

89
app/routes.py Normal file
View file

@ -0,0 +1,89 @@
from flask import Blueprint, current_app, jsonify, render_template, request
bp = Blueprint("main", __name__)
def _check_hook_auth():
"""MediaMTX hooks authenticate with a simple shared-secret token so
random requests to /hooks/* can't spoof stream start/stop events.
Not meant to be a strong secret just keeps it out of casual reach."""
token = current_app.hook_token
if not token:
return True
provided = request.args.get("token") or request.headers.get("X-Hook-Token")
return provided == token
@bp.route("/")
def index():
return render_template(
"index.html",
domain=current_app.domain,
rtmp_port=current_app.rtmp_port,
)
@bp.route("/api/active")
def api_active():
return jsonify(current_app.state.active())
@bp.route("/api/history")
def api_history():
return jsonify(current_app.state.history())
@bp.route("/health")
def health():
return jsonify({"status": "ok"})
def _check_creds_auth():
"""Separate token from the hook auth so the IRC-bot-facing credential
endpoint can be rotated independently of the MediaMTX hook token."""
token = current_app.creds_token
if not token:
return True
provided = request.args.get("token") or request.headers.get("X-Creds-Token")
return provided == token
@bp.route("/api/creds")
def api_creds():
"""Returns the current publish URL for the IRC bot to hand out.
Not meant to be a strong secret the IRC channel is a high-trust
environment; this just keeps it off the public landing page."""
if not _check_creds_auth():
return jsonify({"error": "unauthorized"}), 401
url = (
f"rtmp://{current_app.domain}:{current_app.rtmp_port}/live"
f"?user={current_app.publish_user}&pass={current_app.publish_password}"
)
return jsonify({
"url": url,
"user": current_app.publish_user,
"password": current_app.publish_password,
})
@bp.route("/hooks/ready", methods=["POST"])
def hook_ready():
if not _check_hook_auth():
return jsonify({"error": "unauthorized"}), 401
path = request.form.get("path") or request.args.get("path")
source_type = request.form.get("source_type") or request.args.get("source_type")
if not path:
return jsonify({"error": "missing path"}), 400
current_app.state.on_ready(path, source_type)
return jsonify({"ok": True})
@bp.route("/hooks/not-ready", methods=["POST"])
def hook_not_ready():
if not _check_hook_auth():
return jsonify({"error": "unauthorized"}), 401
path = request.form.get("path") or request.args.get("path")
if not path:
return jsonify({"error": "missing path"}), 400
current_app.state.on_not_ready(path)
return jsonify({"ok": True})

76
app/state.py Normal file
View file

@ -0,0 +1,76 @@
"""SQLite-backed tracking of active + historical streams.
Backed by a file on the shared `appdata` PV (mounted at STATE_DB_PATH), so
state survives pod restarts/redeploys. "Active" streams are just history
rows with end IS NULL no separate table needed, which also gives free
crash recovery (if the pod dies mid-stream, the row is simply still open).
"""
import os
import sqlite3
import threading
from datetime import datetime, timezone
def _now_iso():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
SCHEMA = """
CREATE TABLE IF NOT EXISTS streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
source_type TEXT,
start TEXT NOT NULL,
end TEXT
);
CREATE INDEX IF NOT EXISTS idx_streams_active ON streams (path) WHERE end IS NULL;
"""
class StreamState:
def __init__(self, db_path, history_limit=50):
self._db_path = db_path
self._history_limit = history_limit
self._lock = threading.Lock()
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
with self._connect() as conn:
conn.executescript(SCHEMA)
def _connect(self):
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
return conn
def on_ready(self, path, source_type=None):
with self._lock, self._connect() as conn:
conn.execute(
"INSERT INTO streams (path, source_type, start, end) VALUES (?, ?, ?, NULL)",
(path, source_type, _now_iso()),
)
def on_not_ready(self, path):
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT id FROM streams WHERE path = ? AND end IS NULL ORDER BY id DESC LIMIT 1",
(path,),
).fetchone()
if row:
conn.execute(
"UPDATE streams SET end = ? WHERE id = ?",
(_now_iso(), row["id"]),
)
def active(self):
with self._connect() as conn:
rows = conn.execute(
"SELECT path, source_type, start FROM streams WHERE end IS NULL ORDER BY id DESC"
).fetchall()
return [dict(r) for r in rows]
def history(self):
with self._connect() as conn:
rows = conn.execute(
"SELECT path, source_type, start, end FROM streams ORDER BY id DESC LIMIT ?",
(self._history_limit,),
).fetchall()
return [dict(r) for r in rows]

146
app/templates/index.html Normal file
View file

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ domain }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #c9d1d9;
min-height: 100vh; padding: 2rem;
max-width: 700px; margin: 0 auto;
}
h1 { color: #e6edf3; margin-bottom: .25rem; font-size: 1.6rem; }
.subtitle { color: #8b949e; margin-bottom: 2rem; font-size: .9rem; }
.live-banner {
background: #1a0e0e; border: 1px solid #da3633;
border-radius: 8px; padding: 1.25rem; margin-bottom: 2rem;
text-align: center;
}
.live-banner .badge {
display: inline-block; background: #da3633; color: #fff;
font-weight: 700; font-size: .75rem; padding: .2rem .6rem;
border-radius: 4px; margin-bottom: .5rem; letter-spacing: .05em;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
.live-banner a {
display: block; color: #f0f6fc; font-size: 1.1rem;
font-weight: 600; text-decoration: none; margin-top: .4rem;
}
.live-banner .note { color: #8b949e; font-size: .85rem; margin-top: .5rem; }
.live-banner a:hover { text-decoration: underline; }
.offline-banner {
background: #161b22; border: 1px solid #30363d;
border-radius: 8px; padding: 1.25rem; margin-bottom: 2rem;
text-align: center; color: #8b949e;
}
section { margin-bottom: 2rem; }
h2 { color: #e6edf3; font-size: 1.1rem; margin-bottom: .75rem;
border-bottom: 1px solid #21262d; padding-bottom: .4rem; }
.stream-list { list-style: none; }
.stream-list li {
background: #161b22; border: 1px solid #21262d;
border-radius: 6px; padding: .75rem 1rem; margin-bottom: .5rem;
display: flex; justify-content: space-between; align-items: center;
}
.stream-path { color: #58a6ff; font-weight: 600; font-family: monospace; }
.stream-time { color: #8b949e; font-size: .85rem; }
.stream-duration { color: #8b949e; font-size: .8rem; font-style: italic; }
.empty { color: #484f58; font-style: italic; }
code {
background: #161b22; border: 1px solid #30363d;
border-radius: 4px; padding: .15rem .4rem;
font-size: .85rem; color: #79c0ff;
}
pre {
background: #161b22; border: 1px solid #30363d;
border-radius: 6px; padding: 1rem; overflow-x: auto;
font-size: .85rem; color: #c9d1d9; margin: .5rem 0;
}
.instructions p { margin-bottom: .6rem; line-height: 1.5; }
.instructions ol { padding-left: 1.5rem; margin-bottom: .6rem; }
.instructions li { margin-bottom: .4rem; line-height: 1.5; }
</style>
</head>
<body>
<h1>{{ domain }}</h1>
<p class="subtitle">Live streaming</p>
<div id="status"><div class="offline-banner">Checking stream status&hellip;</div></div>
<section id="history-section">
<h2>Recent streams</h2>
<div id="history"><p class="empty">Loading&hellip;</p></div>
</section>
<section class="instructions">
<h2>How to stream</h2>
<p>Streaming credentials are shared via IRC — ask there for the current
publish URL/password. This is a single shared stream: whoever is
currently publishing is what everyone sees.</p>
<ol>
<li>Open <strong>OBS Studio</strong> (or use ffmpeg directly).</li>
<li>In OBS, go to <em>Settings &rarr; Stream</em>, set Service to
<strong>Custom...</strong>, and paste the full URL you were given
into the <strong>Server</strong> field, leaving Stream Key empty.</li>
<li>Or with ffmpeg: <code>ffmpeg -re -i input -c:v libx264 -c:a aac -f flv "&lt;url you were given&gt;"</code>
(audio must be AAC and video H.264 — RTMP/FLV doesn't support other codecs)</li>
<li>Click <strong>Start Streaming</strong>.</li>
</ol>
<h2>How to watch</h2>
<p>Open this URL in your browser:</p>
<pre>https://{{ domain }}/&lt;path&gt;</pre>
<p>Replace <code>&lt;path&gt;</code> with the stream name in use (e.g., <code>live</code>).</p>
</section>
<script>
function relTime(iso) {
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 60) return Math.floor(s) + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function duration(start, end) {
const s = (new Date(end) - new Date(start)) / 1000;
if (s < 60) return Math.floor(s) + 's';
if (s < 3600) return Math.floor(s / 60) + 'm ' + Math.floor(s % 60) + 's';
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
}
async function load() {
try {
const ar = await fetch('/api/active').then(r => r.json()).catch(() => []);
const el = document.getElementById('status');
if (ar.length > 0) {
el.innerHTML = ar.map(s =>
'<div class="live-banner"><span class="badge">\u25CF LIVE</span>' +
'<a href="https://{{ domain }}/' + s.path + '">Watch <strong>' + s.path + '</strong></a></div>'
).join("");
} else {
el.innerHTML = '<div class="offline-banner">No active streams</div>';
}
} catch (e) {
document.getElementById('status').innerHTML =
'<div class="offline-banner">No active streams</div>';
}
try {
const hr = await fetch('/api/history').then(r => r.json()).catch(() => []);
const hel = document.getElementById('history');
if (hr.length === 0) {
hel.innerHTML = '<p class="empty">No recent streams.</p>';
} else {
hel.innerHTML = '<ul class="stream-list">' + hr.map(s =>
'<li><div><span class="stream-path">' + s.path + '</span>' +
(s.end ? ' <span class="stream-duration">(' + duration(s.start, s.end) + ')</span>' : "") +
'</div><span class="stream-time">' + relTime(s.start) + '</span></li>'
).join("") + '</ul>';
}
} catch (e) {
document.getElementById('history').innerHTML =
'<p class="empty">No recent streams.</p>';
}
}
load();
setInterval(load, 15000);
</script>
</body>
</html>