Initial commit
This commit is contained in:
commit
6aa9bf85f3
10 changed files with 660 additions and 0 deletions
26
app/__init__.py
Normal file
26
app/__init__.py
Normal 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
89
app/routes.py
Normal 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
76
app/state.py
Normal 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
146
app/templates/index.html
Normal 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…</div></div>
|
||||
<section id="history-section">
|
||||
<h2>Recent streams</h2>
|
||||
<div id="history"><p class="empty">Loading…</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 → 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 "<url you were given>"</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 }}/<path></pre>
|
||||
<p>Replace <code><path></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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue