Upload
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from .models import db, User
|
||||
from .routes import bp
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, instance_relative_config=False)
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'change-me')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////app/data/streamhub.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['ADMIN_USERNAME'] = os.getenv('ADMIN_USERNAME', 'admin')
|
||||
app.config['ADMIN_PASSWORD'] = os.getenv('ADMIN_PASSWORD', 'ChangeMe123!')
|
||||
app.config['PUBLIC_BASE_URL'] = os.getenv('PUBLIC_BASE_URL', 'https://localhost')
|
||||
app.config['MEDIA_BASE_URL'] = os.getenv('MEDIA_BASE_URL', 'http://mediamtx:8888')
|
||||
app.config['MEDIA_PUBLIC_HLS_BASE'] = os.getenv('MEDIA_PUBLIC_HLS_BASE', app.config['PUBLIC_BASE_URL'].rstrip('/') + '/hls')
|
||||
app.config['MEDIAMTX_API_URL'] = os.getenv('MEDIAMTX_API_URL', 'http://mediamtx:9997')
|
||||
|
||||
db.init_app(app)
|
||||
app.register_blueprint(bp)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
if not User.query.filter_by(username=app.config['ADMIN_USERNAME']).first():
|
||||
admin = User(username=app.config['ADMIN_USERNAME'], role='admin', active=True)
|
||||
admin.set_password(app.config['ADMIN_PASSWORD'])
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
||||
return app
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(20), default='streamer', nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
|
||||
class Stream(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
slug = db.Column(db.String(120), unique=True, nullable=False)
|
||||
token = db.Column(db.String(120), nullable=False)
|
||||
active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
chat_type = db.Column(db.String(20), default='none', nullable=False)
|
||||
chat_url = db.Column(db.String(500), default='', nullable=False)
|
||||
notes = db.Column(db.Text, default='', nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
is_live = db.Column(db.Boolean, default=False, nullable=False)
|
||||
last_ready_at = db.Column(db.DateTime)
|
||||
last_not_ready_at = db.Column(db.DateTime)
|
||||
|
||||
sessions = db.relationship('StreamSession', backref='stream', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
|
||||
class StreamSession(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
stream_id = db.Column(db.Integer, db.ForeignKey('stream.id'), nullable=False)
|
||||
status = db.Column(db.String(30), default='offline', nullable=False)
|
||||
publisher_ip = db.Column(db.String(80), default='', nullable=False)
|
||||
source_type = db.Column(db.String(50), default='', nullable=False)
|
||||
source_id = db.Column(db.String(120), default='', nullable=False)
|
||||
video_codec = db.Column(db.String(50), default='', nullable=False)
|
||||
audio_codec = db.Column(db.String(50), default='', nullable=False)
|
||||
width = db.Column(db.Integer)
|
||||
height = db.Column(db.Integer)
|
||||
fps = db.Column(db.Float)
|
||||
bitrate = db.Column(db.Integer)
|
||||
audio_channels = db.Column(db.Integer)
|
||||
audio_sample_rate = db.Column(db.Integer)
|
||||
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
ended_at = db.Column(db.DateTime)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class EventLog(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
stream_id = db.Column(db.Integer, db.ForeignKey('stream.id'))
|
||||
level = db.Column(db.String(20), default='info', nullable=False)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
@@ -0,0 +1,274 @@
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import requests
|
||||
from flask import (
|
||||
Blueprint, current_app, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for
|
||||
)
|
||||
|
||||
from .models import db, EventLog, Stream, StreamSession, User
|
||||
from .utils import ffprobe_stream, generate_token, hls_url, rtmp_url
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
def current_user():
|
||||
uid = session.get('user_id')
|
||||
if not uid:
|
||||
return None
|
||||
return db.session.get(User, uid)
|
||||
|
||||
|
||||
def login_required(role=None):
|
||||
def deco(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user or not user.active:
|
||||
return redirect(url_for('main.login'))
|
||||
if role and user.role != role and user.role != 'admin':
|
||||
flash('Ingen adgang til denne side.', 'error')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return fn(*args, **kwargs)
|
||||
return wrapper
|
||||
return deco
|
||||
|
||||
|
||||
def log_event(stream_id, message, level='info'):
|
||||
db.session.add(EventLog(stream_id=stream_id, message=message, level=level))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def get_stats(stream: Stream) -> dict:
|
||||
url = hls_url(current_app.config['MEDIA_BASE_URL'], stream.slug)
|
||||
return ffprobe_stream(url) if stream.is_live else {}
|
||||
|
||||
|
||||
@bp.app_context_processor
|
||||
def inject_globals():
|
||||
return {'current_user': current_user()}
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
def index():
|
||||
if current_user():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return redirect(url_for('main.login'))
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and user.active and user.check_password(password):
|
||||
session['user_id'] = user.id
|
||||
flash('Du er nu logget ind.', 'success')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
flash('Forkert brugernavn eller kodeord.', 'error')
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@bp.get('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('main.login'))
|
||||
|
||||
|
||||
@bp.get('/dashboard')
|
||||
@login_required()
|
||||
def dashboard():
|
||||
streams = Stream.query.order_by(Stream.name.asc()).all()
|
||||
return render_template('dashboard.html', streams=streams)
|
||||
|
||||
|
||||
@bp.route('/streams/new', methods=['GET', 'POST'])
|
||||
@login_required('admin')
|
||||
def stream_new():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
slug = request.form.get('slug', '').strip().lower().replace(' ', '-')
|
||||
chat_type = request.form.get('chat_type', 'none')
|
||||
chat_url = request.form.get('chat_url', '').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
if not name or not slug:
|
||||
flash('Navn og slug er påkrævet.', 'error')
|
||||
return render_template('stream_form.html', stream=None)
|
||||
if Stream.query.filter_by(slug=slug).first():
|
||||
flash('Slug findes allerede.', 'error')
|
||||
return render_template('stream_form.html', stream=None)
|
||||
s = Stream(name=name, slug=slug, token=generate_token(), chat_type=chat_type, chat_url=chat_url, notes=notes)
|
||||
db.session.add(s)
|
||||
db.session.commit()
|
||||
log_event(s.id, 'Stream oprettet')
|
||||
flash('Stream oprettet.', 'success')
|
||||
return redirect(url_for('main.stream_detail', stream_id=s.id))
|
||||
return render_template('stream_form.html', stream=None)
|
||||
|
||||
|
||||
@bp.route('/streams/<int:stream_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required('admin')
|
||||
def stream_edit(stream_id):
|
||||
s = db.session.get(Stream, stream_id)
|
||||
if not s:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
if request.method == 'POST':
|
||||
s.name = request.form.get('name', s.name).strip()
|
||||
s.slug = request.form.get('slug', s.slug).strip().lower().replace(' ', '-')
|
||||
s.chat_type = request.form.get('chat_type', s.chat_type)
|
||||
s.chat_url = request.form.get('chat_url', s.chat_url).strip()
|
||||
s.notes = request.form.get('notes', s.notes).strip()
|
||||
s.active = bool(request.form.get('active'))
|
||||
db.session.commit()
|
||||
log_event(s.id, 'Stream opdateret')
|
||||
flash('Stream gemt.', 'success')
|
||||
return redirect(url_for('main.stream_detail', stream_id=s.id))
|
||||
return render_template('stream_form.html', stream=s)
|
||||
|
||||
|
||||
@bp.post('/streams/<int:stream_id>/rotate-token')
|
||||
@login_required('admin')
|
||||
def rotate_token(stream_id):
|
||||
s = db.session.get(Stream, stream_id)
|
||||
s.token = generate_token()
|
||||
db.session.commit()
|
||||
log_event(s.id, 'Token roteret', 'warning')
|
||||
flash('Token er fornyet.', 'success')
|
||||
return redirect(url_for('main.stream_detail', stream_id=stream_id))
|
||||
|
||||
|
||||
@bp.post('/streams/<int:stream_id>/toggle')
|
||||
@login_required('admin')
|
||||
def toggle_stream(stream_id):
|
||||
s = db.session.get(Stream, stream_id)
|
||||
s.active = not s.active
|
||||
db.session.commit()
|
||||
log_event(s.id, f"Stream {'aktiveret' if s.active else 'deaktiveret'}")
|
||||
flash('Streamstatus ændret.', 'success')
|
||||
return redirect(url_for('main.stream_detail', stream_id=stream_id))
|
||||
|
||||
|
||||
@bp.get('/streams/<int:stream_id>')
|
||||
@login_required()
|
||||
def stream_detail(stream_id):
|
||||
s = db.session.get(Stream, stream_id)
|
||||
if not s:
|
||||
return redirect(url_for('main.dashboard'))
|
||||
stats = get_stats(s)
|
||||
active_session = StreamSession.query.filter_by(stream_id=s.id, status='live').order_by(StreamSession.started_at.desc()).first()
|
||||
logs = EventLog.query.filter_by(stream_id=s.id).order_by(EventLog.created_at.desc()).limit(50).all()
|
||||
public_hls = hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], s.slug)
|
||||
ingest = rtmp_url(current_app.config['PUBLIC_BASE_URL'], s.slug, s.token)
|
||||
return render_template('stream_detail.html', stream=s, stats=stats, active_session=active_session, logs=logs, public_hls=public_hls, ingest=ingest)
|
||||
|
||||
|
||||
@bp.get('/streamer/<slug>')
|
||||
@login_required()
|
||||
def streamer_panel(slug):
|
||||
s = Stream.query.filter_by(slug=slug).first_or_404()
|
||||
stats = get_stats(s)
|
||||
public_hls = hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], s.slug)
|
||||
ingest = rtmp_url(current_app.config['PUBLIC_BASE_URL'], s.slug, s.token)
|
||||
return render_template('streamer_panel.html', stream=s, stats=stats, public_hls=public_hls, ingest=ingest)
|
||||
|
||||
|
||||
@bp.get('/api/streams/<int:stream_id>/status')
|
||||
@login_required()
|
||||
def stream_status(stream_id):
|
||||
s = db.session.get(Stream, stream_id)
|
||||
stats = get_stats(s)
|
||||
payload = {
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'slug': s.slug,
|
||||
'active': s.active,
|
||||
'is_live': s.is_live,
|
||||
'stats': stats,
|
||||
'chat_url': s.chat_url,
|
||||
}
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.post('/api/mediamtx/auth')
|
||||
def mediamtx_auth():
|
||||
data = request.get_json(silent=True) or {}
|
||||
action = data.get('action', '')
|
||||
path = data.get('path', '')
|
||||
token = data.get('token', '')
|
||||
stream = Stream.query.filter_by(slug=path).first()
|
||||
|
||||
# API/metrics m.m. er ekskluderet i mediamtx.yml og rammer normalt ikke her.
|
||||
if action in {'api', 'metrics', 'pprof'}:
|
||||
return ('ok', 200)
|
||||
|
||||
if action == 'publish':
|
||||
if not stream or not stream.active:
|
||||
return ('denied', 401)
|
||||
if token != stream.token:
|
||||
log_event(stream.id, f'Publish auth failed for path {path}', 'warning')
|
||||
return ('denied', 401)
|
||||
return ('ok', 200)
|
||||
|
||||
if action in {'read', 'playback'}:
|
||||
# I v1 er læsning åben, så preview virker uden ekstra token.
|
||||
if not stream:
|
||||
return ('denied', 401)
|
||||
return ('ok', 200)
|
||||
|
||||
return ('denied', 401)
|
||||
|
||||
|
||||
@bp.post('/api/mediamtx/hook/ready')
|
||||
def mediamtx_hook_ready():
|
||||
path = request.form.get('path', '')
|
||||
source_type = request.form.get('source_type', '')
|
||||
source_id = request.form.get('source_id', '')
|
||||
query = request.form.get('query', '')
|
||||
stream = Stream.query.filter_by(slug=path).first()
|
||||
if not stream:
|
||||
return ('missing', 404)
|
||||
|
||||
stream.is_live = True
|
||||
stream.last_ready_at = datetime.utcnow()
|
||||
sess = StreamSession.query.filter_by(stream_id=stream.id, status='live').order_by(StreamSession.started_at.desc()).first()
|
||||
if not sess:
|
||||
sess = StreamSession(stream_id=stream.id, status='live', source_type=source_type, source_id=source_id)
|
||||
db.session.add(sess)
|
||||
else:
|
||||
sess.status = 'live'
|
||||
sess.source_type = source_type
|
||||
sess.source_id = source_id
|
||||
|
||||
# Forsøg at hente metadata fra HLS-preview kort efter streamen er live.
|
||||
stats = get_stats(stream)
|
||||
for key, value in stats.items():
|
||||
setattr(sess, key, value)
|
||||
|
||||
db.session.commit()
|
||||
log_event(stream.id, f'Stream ready ({source_type})')
|
||||
return ('ok', 200)
|
||||
|
||||
|
||||
@bp.post('/api/mediamtx/hook/not-ready')
|
||||
def mediamtx_hook_not_ready():
|
||||
path = request.form.get('path', '')
|
||||
stream = Stream.query.filter_by(slug=path).first()
|
||||
if not stream:
|
||||
return ('missing', 404)
|
||||
stream.is_live = False
|
||||
stream.last_not_ready_at = datetime.utcnow()
|
||||
sess = StreamSession.query.filter_by(stream_id=stream.id, status='live').order_by(StreamSession.started_at.desc()).first()
|
||||
if sess:
|
||||
sess.status = 'offline'
|
||||
sess.ended_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
log_event(stream.id, 'Stream stopped', 'warning')
|
||||
return ('ok', 200)
|
||||
|
||||
|
||||
@bp.get('/healthz')
|
||||
def healthz():
|
||||
return jsonify({'ok': True})
|
||||
@@ -0,0 +1,54 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--card: #181b22;
|
||||
--muted: #9ca3af;
|
||||
--text: #f3f4f6;
|
||||
--accent: #7c3aed;
|
||||
--accent-2: #a855f7;
|
||||
--ok: #10b981;
|
||||
--warn: #f59e0b;
|
||||
--err: #ef4444;
|
||||
--border: #2a2f3a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font-family: Arial, Helvetica, sans-serif; }
|
||||
.topbar { display:flex; justify-content:space-between; align-items:center; padding:16px 24px; border-bottom:1px solid var(--border); background:#12151d; }
|
||||
.topbar a { color:var(--text); text-decoration:none; margin-left:16px; }
|
||||
.brand { font-weight:700; }
|
||||
.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
||||
.card { background: var(--card); border:1px solid var(--border); border-radius: 18px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.2); }
|
||||
.narrow { max-width: 480px; margin: 48px auto; }
|
||||
.grid { display:grid; gap:20px; }
|
||||
.grid.cards { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
|
||||
.grid.two { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
|
||||
.streamer-layout { grid-template-columns: 2fr 1fr; align-items:start; }
|
||||
.page-head { display:flex; justify-content:space-between; align-items:center; gap:20px; margin-bottom:20px; }
|
||||
.form-grid { display:grid; gap:14px; }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
label { display:grid; gap:8px; color:var(--muted); }
|
||||
input, select, textarea { width:100%; border-radius:12px; border:1px solid var(--border); background:#0d1016; color:var(--text); padding:12px; }
|
||||
button, .button { display:inline-block; padding:12px 16px; border-radius:12px; border:none; background:linear-gradient(135deg, var(--accent), var(--accent-2)); color:white; text-decoration:none; cursor:pointer; }
|
||||
button.secondary, .button.secondary { background:#232835; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.badge { padding:6px 10px; border-radius:999px; font-size:12px; font-weight:700; }
|
||||
.badge.live { background:rgba(16,185,129,.15); color:#34d399; }
|
||||
.badge.off { background:rgba(239,68,68,.15); color:#f87171; }
|
||||
.status-row { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.video { width:100%; border-radius:14px; background:#000; min-height:300px; }
|
||||
.chatframe { width:100%; height:340px; border:1px solid var(--border); border-radius:14px; background:#0d1016; }
|
||||
.chatframe.tall { height:620px; }
|
||||
.flash { padding:12px 14px; border-radius:12px; margin-bottom:16px; }
|
||||
.flash.success { background:rgba(16,185,129,.12); }
|
||||
.flash.error { background:rgba(239,68,68,.12); }
|
||||
.small { color:var(--muted); font-size:13px; }
|
||||
.warn { color: var(--warn); font-weight:700; }
|
||||
.loglist { max-height:340px; overflow:auto; display:grid; gap:8px; }
|
||||
.log { padding:10px; background:#11151c; border-radius:10px; border:1px solid var(--border); font-size:14px; }
|
||||
.stats-grid { display:grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap:12px; margin-top:14px; }
|
||||
.codebox { margin-top:16px; display:grid; gap:10px; }
|
||||
code { display:block; overflow-wrap:anywhere; padding:10px; background:#0d1016; border-radius:10px; border:1px solid var(--border); color:#d1d5db; }
|
||||
.check { display:flex; align-items:center; gap:10px; }
|
||||
@media (max-width: 900px) {
|
||||
.streamer-layout { grid-template-columns: 1fr; }
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}TuxiNet StreamHub{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('main.static', filename='style.css') }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">🎛️ TuxiNet StreamHub</div>
|
||||
{% if current_user %}
|
||||
<nav>
|
||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('main.logout') }}">Log ud</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>Dashboard</h1>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<a class="button" href="{{ url_for('main.stream_new') }}">+ Opret stream</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="grid cards">
|
||||
{% for s in streams %}
|
||||
<div class="card">
|
||||
<div class="status-row">
|
||||
<h2>{{ s.name }}</h2>
|
||||
<span class="badge {{ 'live' if s.is_live else 'off' }}">{{ 'LIVE' if s.is_live else 'OFFLINE' }}</span>
|
||||
</div>
|
||||
<p><strong>Slug:</strong> {{ s.slug }}</p>
|
||||
<p><strong>Publish:</strong> {{ 'Åben' if s.active else 'Lukket' }}</p>
|
||||
<div class="actions">
|
||||
<a class="button secondary" href="{{ url_for('main.stream_detail', stream_id=s.id) }}">Detaljer</a>
|
||||
<a class="button secondary" href="{{ url_for('main.streamer_panel', slug=s.slug) }}">Streamer panel</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card"><p>Ingen streams endnu.</p></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card narrow">
|
||||
<h1>Login</h1>
|
||||
<form method="post" class="form-grid">
|
||||
<label>Brugernavn<input type="text" name="username" required></label>
|
||||
<label>Kodeord<input type="password" name="password" required></label>
|
||||
<button type="submit">Log ind</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ stream.name }}</h1>
|
||||
<div class="actions">
|
||||
<a class="button secondary" href="{{ url_for('main.streamer_panel', slug=stream.slug) }}">Åbn streamer-panel</a>
|
||||
<a class="button secondary" href="{{ url_for('main.stream_edit', stream_id=stream.id) }}">Rediger</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<h2>Preview</h2>
|
||||
<video id="preview" class="video" controls autoplay muted playsinline></video>
|
||||
<p class="small">HLS: <code>{{ public_hls }}</code></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Status</h2>
|
||||
<p><strong>LIVE:</strong> {{ 'Ja' if stream.is_live else 'Nej' }}</p>
|
||||
<p><strong>Publish:</strong> {{ 'Åben' if stream.active else 'Lukket' }}</p>
|
||||
<p><strong>Video:</strong> {{ stats.video_codec or '-' }} {{ stats.width or '-' }}x{{ stats.height or '-' }} / {{ stats.fps or '-' }} FPS</p>
|
||||
<p><strong>Lyd:</strong> {{ stats.audio_codec or '-' }} / {{ stats.audio_sample_rate or '-' }} Hz / {% if stats.audio_channels == 1 %}<span class="warn">MONO ⚠️</span>{% elif stats.audio_channels %}{{ stats.audio_channels }} ch{% else %}-{% endif %}</p>
|
||||
<p><strong>Bitrate:</strong> {% if stats.bitrate %}{{ (stats.bitrate / 1000)|round(1) }} kbps{% else %}-{% endif %}</p>
|
||||
<p><strong>RTMP ingest:</strong><br><code>{{ ingest }}</code></p>
|
||||
<form method="post" action="{{ url_for('main.toggle_stream', stream_id=stream.id) }}">
|
||||
<button type="submit">{{ 'Stop publish' if stream.active else 'Start publish' }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.rotate_token', stream_id=stream.id) }}">
|
||||
<button type="submit" class="secondary">Rotér token</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid two">
|
||||
<div class="card">
|
||||
<h2>Chat</h2>
|
||||
{% if stream.chat_url %}
|
||||
<iframe class="chatframe" src="{{ stream.chat_url }}"></iframe>
|
||||
{% else %}
|
||||
<p>Ingen chat sat op.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Hændelseslog</h2>
|
||||
<div class="loglist">
|
||||
{% for log in logs %}
|
||||
<div class="log {{ log.level }}">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }} — {{ log.message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const video = document.getElementById('preview');
|
||||
const src = {{ public_hls|tojson }};
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({ lowLatencyMode: true, backBufferLength: 90 });
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h1>{{ 'Rediger stream' if stream else 'Ny stream' }}</h1>
|
||||
<form method="post" class="form-grid">
|
||||
<label>Navn<input type="text" name="name" value="{{ stream.name if stream else '' }}" required></label>
|
||||
<label>Slug<input type="text" name="slug" value="{{ stream.slug if stream else '' }}" required></label>
|
||||
<label>Chat type
|
||||
<select name="chat_type">
|
||||
{% set chat_type = stream.chat_type if stream else 'none' %}
|
||||
<option value="none" {{ 'selected' if chat_type == 'none' else '' }}>Ingen</option>
|
||||
<option value="twitch" {{ 'selected' if chat_type == 'twitch' else '' }}>Twitch</option>
|
||||
<option value="youtube" {{ 'selected' if chat_type == 'youtube' else '' }}>YouTube</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Chat URL / embed URL<input type="text" name="chat_url" value="{{ stream.chat_url if stream else '' }}"></label>
|
||||
<label class="full">Noter<textarea name="notes" rows="5">{{ stream.notes if stream else '' }}</textarea></label>
|
||||
{% if stream %}
|
||||
<label class="check"><input type="checkbox" name="active" value="1" {{ 'checked' if stream.active else '' }}> Tillad publish</label>
|
||||
{% endif %}
|
||||
<button type="submit">Gem</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>Streamer-panel — {{ stream.name }}</h1>
|
||||
<span class="badge {{ 'live' if stream.is_live else 'off' }}">{{ 'LIVE' if stream.is_live else 'OFFLINE' }}</span>
|
||||
</div>
|
||||
<div class="grid streamer-layout">
|
||||
<div class="card">
|
||||
<h2>Server preview</h2>
|
||||
<video id="preview" class="video" controls autoplay muted playsinline></video>
|
||||
<div class="stats-grid">
|
||||
<div><strong>Video</strong><br>{{ stats.video_codec or '-' }}</div>
|
||||
<div><strong>Opløsning</strong><br>{{ stats.width or '-' }}x{{ stats.height or '-' }}</div>
|
||||
<div><strong>FPS</strong><br>{{ stats.fps or '-' }}</div>
|
||||
<div><strong>Bitrate</strong><br>{% if stats.bitrate %}{{ (stats.bitrate/1000)|round(1) }} kbps{% else %}-{% endif %}</div>
|
||||
<div><strong>Lyd</strong><br>{{ stats.audio_codec or '-' }}</div>
|
||||
<div><strong>Kanaler</strong><br>{% if stats.audio_channels == 1 %}<span class="warn">MONO ⚠️</span>{% elif stats.audio_channels %}{{ stats.audio_channels }} ch{% else %}-{% endif %}</div>
|
||||
</div>
|
||||
<div class="codebox">
|
||||
<div><strong>RTMP URL</strong><br><code>{{ ingest }}</code></div>
|
||||
<div><strong>HLS preview</strong><br><code>{{ public_hls }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Chat</h2>
|
||||
{% if stream.chat_url %}
|
||||
<iframe class="chatframe tall" src="{{ stream.chat_url }}"></iframe>
|
||||
{% else %}
|
||||
<p>Ingen chat sat op.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const video = document.getElementById('preview');
|
||||
const src = {{ public_hls|tojson }};
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({ lowLatencyMode: true, backBufferLength: 90 });
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
import secrets
|
||||
import string
|
||||
import subprocess
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def generate_token(length: int = 32) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def hls_url(base: str, slug: str) -> str:
|
||||
return f"{base.rstrip('/')}/{quote(slug)}/index.m3u8"
|
||||
|
||||
|
||||
def rtmp_url(public_base: str, slug: str, token: str) -> str:
|
||||
base = public_base.replace('https://', '').replace('http://', '').rstrip('/')
|
||||
return f"rtmp://{base}:1935/{slug}?token={token}"
|
||||
|
||||
|
||||
def ffprobe_stream(url: str) -> dict:
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error', '-show_streams', '-of', 'json',
|
||||
'-rw_timeout', '5000000', '-timeout', '5000000', url
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=8, check=True)
|
||||
data = json.loads(result.stdout)
|
||||
out = {
|
||||
'video_codec': '', 'audio_codec': '', 'width': None, 'height': None,
|
||||
'fps': None, 'bitrate': None, 'audio_channels': None, 'audio_sample_rate': None
|
||||
}
|
||||
for stream in data.get('streams', []):
|
||||
ctype = stream.get('codec_type')
|
||||
if ctype == 'video':
|
||||
out['video_codec'] = stream.get('codec_name', '')
|
||||
out['width'] = stream.get('width')
|
||||
out['height'] = stream.get('height')
|
||||
rate = stream.get('avg_frame_rate') or stream.get('r_frame_rate')
|
||||
if rate and rate != '0/0' and '/' in rate:
|
||||
a, b = rate.split('/')
|
||||
if float(b) != 0:
|
||||
out['fps'] = round(float(a) / float(b), 2)
|
||||
br = stream.get('bit_rate')
|
||||
if br and str(br).isdigit():
|
||||
out['bitrate'] = int(br)
|
||||
elif ctype == 'audio':
|
||||
out['audio_codec'] = stream.get('codec_name', '')
|
||||
out['audio_channels'] = stream.get('channels')
|
||||
sr = stream.get('sample_rate')
|
||||
if sr and str(sr).isdigit():
|
||||
out['audio_sample_rate'] = int(sr)
|
||||
br = stream.get('bit_rate')
|
||||
if br and str(br).isdigit() and not out['bitrate']:
|
||||
out['bitrate'] = int(br)
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
Reference in New Issue
Block a user