From b1500e5d878fd9715116bfbd774ed6641edd48df Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 23 Mar 2026 00:37:22 +0100 Subject: [PATCH] Upload --- backend/Dockerfile | 21 ++ backend/__pycache__/wsgi.cpython-313.pyc | Bin 0 -> 203 bytes backend/app/__init__.py | 30 ++ .../app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 2644 bytes .../app/__pycache__/models.cpython-313.pyc | Bin 0 -> 6285 bytes .../app/__pycache__/routes.cpython-313.pyc | Bin 0 -> 18373 bytes backend/app/__pycache__/utils.cpython-313.pyc | Bin 0 -> 4159 bytes backend/app/models.py | 67 +++++ backend/app/routes.py | 274 ++++++++++++++++++ backend/app/static/style.css | 54 ++++ backend/app/templates/base.html | 31 ++ backend/app/templates/dashboard.html | 27 ++ backend/app/templates/login.html | 12 + backend/app/templates/stream_detail.html | 61 ++++ backend/app/templates/stream_form.html | 24 ++ backend/app/templates/streamer_panel.html | 44 +++ backend/app/utils.py | 59 ++++ backend/requirements.txt | 5 + backend/wsgi.py | 3 + mediamtx/mediamtx.yml | 51 ++++ proxy/Caddyfile | 13 + scripts/install_docker_ubuntu.sh | 21 ++ 22 files changed, 797 insertions(+) create mode 100644 backend/Dockerfile create mode 100644 backend/__pycache__/wsgi.cpython-313.pyc create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/__pycache__/models.cpython-313.pyc create mode 100644 backend/app/__pycache__/routes.cpython-313.pyc create mode 100644 backend/app/__pycache__/utils.cpython-313.pyc create mode 100644 backend/app/models.py create mode 100644 backend/app/routes.py create mode 100644 backend/app/static/style.css create mode 100644 backend/app/templates/base.html create mode 100644 backend/app/templates/dashboard.html create mode 100644 backend/app/templates/login.html create mode 100644 backend/app/templates/stream_detail.html create mode 100644 backend/app/templates/stream_form.html create mode 100644 backend/app/templates/streamer_panel.html create mode 100644 backend/app/utils.py create mode 100644 backend/requirements.txt create mode 100644 backend/wsgi.py create mode 100644 mediamtx/mediamtx.yml create mode 100644 proxy/Caddyfile create mode 100644 scripts/install_docker_ubuntu.sh diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..18f17b9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY app /app/app +COPY wsgi.py /app/wsgi.py + +RUN mkdir -p /app/data /app/logs + +EXPOSE 5000 +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "wsgi:app"] diff --git a/backend/__pycache__/wsgi.cpython-313.pyc b/backend/__pycache__/wsgi.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f89fba6823c913443cce82e3da79e715d8df3e43 GIT binary patch literal 203 zcmey&%ge<81X*PVGWCJH~8KmNtj(%=liGE6ANuqvn2~cTn zMrl%fa(-?>PHIW2eo|s`c4}UVetB_vrd~niEe@O9{FKt1RJ$S$pk_uOF6IXkAD9^# U8SgSkJ!BJT&S_*WVgrf+0HP=_y#N3J literal 0 HcmV?d00001 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..36720ee --- /dev/null +++ b/backend/app/__init__.py @@ -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 diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75f2a0ef14ef820b4f4cf2978d486797e0a047d8 GIT binary patch literal 2644 zcma)8O-vhC5PoZ~f3s#GK%g|ie<6u!h|?sfnC3^0F^z5P&|SL;a9Az&0^9hH-8Bhr z)zDrNs#f&SN@-OoZ7w9eB*z|es+u0!U75;g>!_95OKy=kMCz$;7r$(pikgSeeDl7U zd9yRadu+K}P5{@ZOAq-G3jlv=3RXn|4zOtp;XS@Fsf45|T>YRb*3ct+#|$e3o!S6HPL-QWJo4$0ksEp z%8+!&aC0W;y+d%jMHcP5%A%9*Y7wVXk3*n5s5XQQ=>r*na`Qk;br>8%GkX#Gmt{BF zHRNy;nt%t)80YRYED-VmWD6~O(Js}8kRho921aOy`rEbo)7>GAawAJaqVtY4w{jx< zOLztG{~OUk@1uJ{<|3BCC@=Cbc(Z0$ZHth6-HOBQEB7i5m^V#g@y#94oOwQ4%<|uuz#j1~1Yc z`t6DEMC3v=ImL{H)8X@BI?80InA-mWk*30t1e3ft78{R6!s*z>6s@*}$C9xWlcA$j zDx8d}7B*Mli)ec?Ow*SxQe#yjq9>E&UO#cttCEwM^AoWM(>!IO+OZ%>WfA6=FJ;;M zLP?a=wq$fH7B)<}G!tl1;Bq`$kgko6z@>I?TQ%cdn4onB6$@7J;6h$h+jTyfzRZLt zWAK9}s{e@-3WeT)MYUJpQ&MCLTsmMDx?$^7Tqxx^)yihI3J1Cb?LkrP45Jw_jA|;0 zs&$@|xZ;v(ZPwo^z^d|PK^rvH&KG%!f%R7R3fw#|N}RyV<||xT;ER%A(@ge2R_mR_ zU6WL27B)oU7&f05w5Le5ELJ#SS#`|udEIwewaoGG1eFjui7B(9c(o+tRGY|&B3~-1 zW;U18QYjR82|kc!7+lb97*Tt^JRbE_uuzmB2g8apv=V8!y|MV{$c#)`wG5) z^Jr>YlgT3r9@soNg4CdbA8+^v)rWrgE-x9@aX-(cLDiS;z#Ay?gpT0LOk6ciO zVlwHM$#|WdRLIFXNhu`N=<5B(zdrcwAgqq!JNxjGoS9O(rf)_!iHffRZ_VovI#2V`~Kk9VGe71l?&~9TmblSgN+v9I7lyY2N)CYWfNUcJtB5|TV&-#4a z1F(7k2m#I4ei}Md^4wYBI8@=#08x8;o|-WX`wciAgPz~P{>Nb7mbC{Xg&?H<0f)E- AD*ylh literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f57a668f2d64b4bd58120e7483596a3422a2150 GIT binary patch literal 6285 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 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/routes.cpython-313.pyc b/backend/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..234df559203c0a8f4fe06f59396188da79a0740c GIT binary patch literal 18373 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 XKakO~wS%fl)5{1@#`u;XO|$<1oAYj& literal 0 HcmV?d00001 diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..2f7680a --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routes.py b/backend/app/routes.py new file mode 100644 index 0000000..1c44e6f --- /dev/null +++ b/backend/app/routes.py @@ -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//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}) diff --git a/backend/app/static/style.css b/backend/app/static/style.css new file mode 100644 index 0000000..14536b6 --- /dev/null +++ b/backend/app/static/style.css @@ -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)); } +} diff --git a/backend/app/templates/base.html b/backend/app/templates/base.html new file mode 100644 index 0000000..ae79284 --- /dev/null +++ b/backend/app/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}TuxiNet StreamHub{% endblock %} + + + + +
+
🎛️ TuxiNet StreamHub
+ {% if current_user %} + + {% endif %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/backend/app/templates/dashboard.html b/backend/app/templates/dashboard.html new file mode 100644 index 0000000..695819d --- /dev/null +++ b/backend/app/templates/dashboard.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} +{% block content %} +
+

Dashboard

+ {% if current_user.role == 'admin' %} + + Opret stream + {% endif %} +
+
+ {% for s in streams %} +
+
+

{{ s.name }}

+ {{ 'LIVE' if s.is_live else 'OFFLINE' }} +
+

Slug: {{ s.slug }}

+

Publish: {{ 'Åben' if s.active else 'Lukket' }}

+ +
+ {% else %} +

Ingen streams endnu.

+ {% endfor %} +
+{% endblock %} diff --git a/backend/app/templates/login.html b/backend/app/templates/login.html new file mode 100644 index 0000000..dc69f5c --- /dev/null +++ b/backend/app/templates/login.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Login{% endblock %} +{% block content %} +
+

Login

+
+ + + +
+
+{% endblock %} diff --git a/backend/app/templates/stream_detail.html b/backend/app/templates/stream_detail.html new file mode 100644 index 0000000..b18b2d8 --- /dev/null +++ b/backend/app/templates/stream_detail.html @@ -0,0 +1,61 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ stream.name }}

+ +
+
+
+

Preview

+ +

HLS: {{ public_hls }}

+
+
+

Status

+

LIVE: {{ 'Ja' if stream.is_live else 'Nej' }}

+

Publish: {{ 'Åben' if stream.active else 'Lukket' }}

+

Video: {{ stats.video_codec or '-' }} {{ stats.width or '-' }}x{{ stats.height or '-' }} / {{ stats.fps or '-' }} FPS

+

Lyd: {{ stats.audio_codec or '-' }} / {{ stats.audio_sample_rate or '-' }} Hz / {% if stats.audio_channels == 1 %}MONO ⚠️{% elif stats.audio_channels %}{{ stats.audio_channels }} ch{% else %}-{% endif %}

+

Bitrate: {% if stats.bitrate %}{{ (stats.bitrate / 1000)|round(1) }} kbps{% else %}-{% endif %}

+

RTMP ingest:
{{ ingest }}

+
+ +
+
+ +
+
+
+
+
+

Chat

+ {% if stream.chat_url %} + + {% else %} +

Ingen chat sat op.

+ {% endif %} +
+
+

Hændelseslog

+
+ {% for log in logs %} +
{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }} — {{ log.message }}
+ {% endfor %} +
+
+
+ +{% endblock %} diff --git a/backend/app/templates/stream_form.html b/backend/app/templates/stream_form.html new file mode 100644 index 0000000..6a53669 --- /dev/null +++ b/backend/app/templates/stream_form.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ 'Rediger stream' if stream else 'Ny stream' }}

+
+ + + + + + {% if stream %} + + {% endif %} + +
+
+{% endblock %} diff --git a/backend/app/templates/streamer_panel.html b/backend/app/templates/streamer_panel.html new file mode 100644 index 0000000..96f7eb8 --- /dev/null +++ b/backend/app/templates/streamer_panel.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% block content %} +
+

Streamer-panel — {{ stream.name }}

+ {{ 'LIVE' if stream.is_live else 'OFFLINE' }} +
+
+
+

Server preview

+ +
+
Video
{{ stats.video_codec or '-' }}
+
Opløsning
{{ stats.width or '-' }}x{{ stats.height or '-' }}
+
FPS
{{ stats.fps or '-' }}
+
Bitrate
{% if stats.bitrate %}{{ (stats.bitrate/1000)|round(1) }} kbps{% else %}-{% endif %}
+
Lyd
{{ stats.audio_codec or '-' }}
+
Kanaler
{% if stats.audio_channels == 1 %}MONO ⚠️{% elif stats.audio_channels %}{{ stats.audio_channels }} ch{% else %}-{% endif %}
+
+
+
RTMP URL
{{ ingest }}
+
HLS preview
{{ public_hls }}
+
+
+
+

Chat

+ {% if stream.chat_url %} + + {% else %} +

Ingen chat sat op.

+ {% endif %} +
+
+ +{% endblock %} diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..d37bcbb --- /dev/null +++ b/backend/app/utils.py @@ -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 {} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d839ee1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Werkzeug==3.0.3 +gunicorn==23.0.0 +requests==2.32.3 diff --git a/backend/wsgi.py b/backend/wsgi.py new file mode 100644 index 0000000..0a23b5a --- /dev/null +++ b/backend/wsgi.py @@ -0,0 +1,3 @@ +from app import create_app + +app = create_app() diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml new file mode 100644 index 0000000..9d34edb --- /dev/null +++ b/mediamtx/mediamtx.yml @@ -0,0 +1,51 @@ +logLevel: info +logDestinations: [stdout] + +readTimeout: 10s +writeTimeout: 10s +writeQueueSize: 1024 +udpMaxPayloadSize: 1452 + +api: true +apiAddress: :9997 +metrics: true +metricsAddress: :9998 + +rtmp: true +rtmpAddress: :1935 + +hls: true +hlsAddress: :8888 +hlsAlwaysRemux: true +hlsVariant: lowLatency +hlsSegmentCount: 7 +hlsSegmentDuration: 1s +hlsPartDuration: 250ms +hlsMuxerCloseAfter: 30s + +webrtc: true +webrtcAddress: :8889 +webrtcLocalUDPAddress: :8189 +webrtcIPsFromInterfaces: true + +# Backend bestemmer om publish/read må accepteres. +authMethod: http +authHTTPAddress: http://backend:5000/api/mediamtx/auth +# API/metrics skal backend kunne nå uden auth-loop. +authHTTPExclude: + - action: api + - action: metrics + - action: pprof + +pathDefaults: + source: publisher + overridePublisher: true + + # Hooks tilbage til backend + runOnReady: >- + sh -c 'wget -qO- --post-data "path=$MTX_PATH&source_type=$MTX_SOURCE_TYPE&source_id=$MTX_SOURCE_ID&query=$MTX_QUERY" http://backend:5000/api/mediamtx/hook/ready >/dev/null 2>&1' + runOnNotReady: >- + sh -c 'wget -qO- --post-data "path=$MTX_PATH&source_type=$MTX_SOURCE_TYPE&source_id=$MTX_SOURCE_ID&query=$MTX_QUERY" http://backend:5000/api/mediamtx/hook/not-ready >/dev/null 2>&1' + +paths: + all_others: diff --git a/proxy/Caddyfile b/proxy/Caddyfile new file mode 100644 index 0000000..697128a --- /dev/null +++ b/proxy/Caddyfile @@ -0,0 +1,13 @@ +:80, :443 { + encode zstd gzip + + @hls path /hls/* + handle @hls { + uri strip_prefix /hls + reverse_proxy mediamtx:8888 + } + + handle { + reverse_proxy backend:5000 + } +} diff --git a/scripts/install_docker_ubuntu.sh b/scripts/install_docker_ubuntu.sh new file mode 100644 index 0000000..de1b30f --- /dev/null +++ b/scripts/install_docker_ubuntu.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +apt-get update +apt-get install -y ca-certificates curl gnupg lsb-release +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null + +apt-get update +apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +systemctl enable docker +systemctl restart docker + +docker --version +docker compose version