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//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//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//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/') @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/') @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//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})