Compare commits

..
1 Commits
Author SHA1 Message Date
thomas 5917e4e5f4 nwe repo 2026-03-23 19:38:31 +01:00
35 changed files with 0 additions and 1304 deletions
@@ -1,26 +0,0 @@
SECRET_KEY=change-me-super-secret
TZ=Europe/Copenhagen
PUBLIC_BASE_URL=https://rtmp.djgulvbass.dk
MEDIA_BASE_URL=http://mediamtx:8888
MEDIA_PUBLIC_HLS_BASE=https://rtmp.djgulvbass.dk/hls
MEDIAMTX_API_URL=http://mediamtx:9997
POSTGRES_DB=streamhub
POSTGRES_USER=streamhub
POSTGRES_PASSWORD=change-me-db
DATABASE_URL=postgresql+psycopg://streamhub:change-me-db@postgres:5432/streamhub
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@tuxinet.dk
ADMIN_PASSWORD=change-me-admin
SMTP_HOST=mail.smtp2go.com
SMTP_PORT=2525
SMTP_USERNAME=noreplay@tuxinet.dk
SMTP_PASSWORD=change-me-smtp
MAIL_FROM=noreplay@tuxinet.dk
MAIL_ENABLED=true
RESET_TOKEN_TTL_MINUTES=60
FORCE_HTTPS_URLS=true
@@ -1,83 +0,0 @@
# TuxiNet StreamHub v2.1 Enterprise (Clean Build)
Første enterprise-udgave af StreamHub med:
- PostgreSQL
- Flask backend
- MediaMTX auth via backend
- Admin + streamer roller
- 1 streamer = 1 stream
- SMTP2GO mailflow
- Force password change ved første login
- Password reset via mail
- Branding med `bg.png`, `logo.png`, `videooffline.png`
## Status
Denne første udgave er lavet som en **ren base**, så du kan starte rent uden de gamle v2-filer.
### Implementeret
- Datamodel låst til PostgreSQL
- Admin-login og streamer-login
- Admin kan oprette streamer + stream i samme flow
- Streamer ser kun egen stream
- MediaMTX auth-route
- Status fra MediaMTX API
- Mail helper til onboarding og reset
- Force password change flow
- Forgot/reset password flow
- Offline player image i UI
- Enterprise login design
### Delvist scaffoldet / klar til næste iteration
- Action/bot layer (`kick publisher`, `restart service`) er scaffoldet som placeholder
- Mere avancerede live-metrics kan udvides senere
- Fuldt migrationsflow med Alembic kan tilføjes senere
## Hurtig opstart
```bash
cp .env.example .env
nano .env
docker compose build --no-cache
docker compose up -d
```
Åbn derefter:
- Web: `http://IP` eller dit reverse proxied domæne
- MediaMTX API: internt på `http://mediamtx:9997`
## Første admin-bruger
Backend opretter automatisk admin-brugeren ud fra `.env`:
- `ADMIN_USERNAME`
- `ADMIN_EMAIL`
- `ADMIN_PASSWORD`
## SMTP2GO
Sæt i `.env`:
```env
SMTP_HOST=mail.smtp2go.com
SMTP_PORT=2525
SMTP_USERNAME=noreplay@tuxinet.dk
SMTP_PASSWORD=DIN_SMTP2GO_ADGANGSKODE
MAIL_FROM=noreplay@tuxinet.dk
```
## MediaMTX ingest-formater
### OBS
- Server: `rtmp://dit-domæne:1935/<slug>`
- Key: `<token>`
### Larix / Streamlabs fallback
- URL: `rtmp://dit-domæne:1935`
- Key: `<slug>/<token>`
### Query-token
- `rtmp://dit-domæne:1935/<slug>?token=<token>`
@@ -1,15 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
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/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "wsgi:app"]
@@ -1,34 +0,0 @@
import os
from flask import Flask
from .models import db
from .routes import bp
from .services.bootstrap import bootstrap_system
def create_app():
app = Flask(__name__, instance_relative_config=True)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'change-me')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['PUBLIC_BASE_URL'] = os.getenv('PUBLIC_BASE_URL', 'http://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', 'http://localhost/hls')
app.config['MEDIAMTX_API_URL'] = os.getenv('MEDIAMTX_API_URL', 'http://mediamtx:9997')
app.config['SMTP_HOST'] = os.getenv('SMTP_HOST', '')
app.config['SMTP_PORT'] = int(os.getenv('SMTP_PORT', '2525'))
app.config['SMTP_USERNAME'] = os.getenv('SMTP_USERNAME', '')
app.config['SMTP_PASSWORD'] = os.getenv('SMTP_PASSWORD', '')
app.config['MAIL_FROM'] = os.getenv('MAIL_FROM', os.getenv('SMTP_USERNAME', 'noreply@example.com'))
app.config['MAIL_ENABLED'] = os.getenv('MAIL_ENABLED', 'true').lower() == 'true'
app.config['RESET_TOKEN_TTL_MINUTES'] = int(os.getenv('RESET_TOKEN_TTL_MINUTES', '60'))
db.init_app(app)
app.register_blueprint(bp)
with app.app_context():
db.create_all()
bootstrap_system(app)
return app
@@ -1,85 +0,0 @@
from datetime import datetime, timezone
from werkzeug.security import check_password_hash, generate_password_hash
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
def utcnow():
return datetime.now(timezone.utc)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(120), unique=True, nullable=False)
email = db.Column(db.String(255), unique=True, nullable=False)
display_name = db.Column(db.String(255), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), nullable=False, default='streamer')
active = db.Column(db.Boolean, nullable=False, default=True)
force_password_change = db.Column(db.Boolean, nullable=False, default=True)
reset_token = db.Column(db.String(255), nullable=True)
reset_token_expires_at = db.Column(db.DateTime(timezone=True), nullable=True)
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
stream = db.relationship('Stream', back_populates='user', uselist=False)
def set_password(self, raw: str):
self.password_hash = generate_password_hash(raw)
def check_password(self, raw: str) -> bool:
return check_password_hash(self.password_hash, raw)
class Stream(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True)
name = db.Column(db.String(255), nullable=False)
slug = db.Column(db.String(120), unique=True, nullable=False)
token = db.Column(db.String(255), nullable=False)
chat_type = db.Column(db.String(30), nullable=False, default='none')
chat_url = db.Column(db.Text, nullable=True)
notes = db.Column(db.Text, nullable=True)
active = db.Column(db.Boolean, nullable=False, default=True)
is_live = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow)
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow)
user = db.relationship('User', back_populates='stream')
sessions = db.relationship('StreamSession', back_populates='stream', cascade='all, delete-orphan')
logs = db.relationship('EventLog', back_populates='stream', 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)
started_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow)
ended_at = db.Column(db.DateTime(timezone=True), nullable=True)
status = db.Column(db.String(50), nullable=False, default='offline')
source_type = db.Column(db.String(50), nullable=True)
source_id = db.Column(db.String(120), nullable=True)
publisher_ip = db.Column(db.String(64), nullable=True)
video_codec = db.Column(db.String(64), nullable=True)
audio_codec = db.Column(db.String(64), nullable=True)
audio_channels = db.Column(db.String(32), nullable=True)
audio_sample_rate = db.Column(db.String(32), nullable=True)
width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True)
fps = db.Column(db.String(32), nullable=True)
bitrate = db.Column(db.String(64), nullable=True)
stream = db.relationship('Stream', back_populates='sessions')
class EventLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
stream_id = db.Column(db.Integer, db.ForeignKey('stream.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
level = db.Column(db.String(20), nullable=False, default='info')
message = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=utcnow)
stream = db.relationship('Stream', back_populates='logs')
@@ -1,412 +0,0 @@
from datetime import datetime, timedelta, timezone
from functools import wraps
import secrets
import requests
from flask import (
Blueprint, current_app, flash, jsonify, redirect, render_template,
request, session, url_for
)
from .models import EventLog, Stream, StreamSession, User, db
from .services.mailer import send_reset_email, send_welcome_email
from .utils import ffprobe_stream, generate_password, generate_token, hls_url, ingest_url_with_query
bp = Blueprint('main', __name__)
def current_user():
uid = session.get('user_id')
if not uid:
return None
return db.session.get(User, uid)
def log_event(stream_id, message, level='info', user_id=None):
db.session.add(EventLog(stream_id=stream_id, message=message, level=level, user_id=user_id))
db.session.commit()
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:
return redirect(url_for('main.index'))
return fn(*args, **kwargs)
return wrapper
return deco
def stream_for_current_user_or_403(stream_id):
user = current_user()
stream = db.session.get(Stream, stream_id)
if not stream:
return None
if user.role == 'admin':
return stream
if stream.user_id != user.id:
return None
return stream
def media_api_paths():
try:
res = requests.get(f"{current_app.config['MEDIAMTX_API_URL'].rstrip('/')}/v3/paths/list", timeout=2)
res.raise_for_status()
return res.json().get('items', [])
except Exception as exc:
current_app.logger.warning(f'MediaMTX API unavailable: {exc}')
return []
def path_info_for(slug):
for item in media_api_paths():
if item.get('name') == slug:
return item
return None
def extract_slug_token_from_auth(data: dict):
path = (data.get('path') or '').strip().strip('/')
query = (data.get('query') or '').strip()
user = (data.get('user') or '').strip()
password = (data.get('password') or '').strip()
slug = path
token = None
if '/' in path:
slug, token = path.split('/', 1)
slug = slug.strip()
token = token.strip()
if not token and query:
from urllib.parse import parse_qs
token = parse_qs(query).get('token', [None])[0]
if not token and password:
token = password
if not token and user and user.lower() != 'any':
token = user
return slug, token
def update_live_state(stream: Stream):
info = path_info_for(stream.slug)
stream.is_live = bool(info and (info.get('ready') or info.get('online')))
db.session.commit()
return info
def stats_for(stream: Stream):
if not stream.is_live:
return {}
return ffprobe_stream(hls_url(current_app.config['MEDIA_BASE_URL'], stream.slug))
@bp.app_context_processor
def inject_globals():
return {'current_user': current_user()}
@bp.get('/')
def index():
user = current_user()
if not user:
return redirect(url_for('main.login'))
if user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
if user.stream:
return redirect(url_for('main.streamer_panel', stream_id=user.stream.id))
flash('Din konto har ingen stream knyttet endnu.', 'error')
return redirect(url_for('main.logout'))
@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.clear()
session['user_id'] = user.id
if user.force_password_change:
return redirect(url_for('main.force_password_change'))
if user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
return redirect(url_for('main.index'))
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.route('/force-password-change', methods=['GET', 'POST'])
@login_required()
def force_password_change():
user = current_user()
if request.method == 'POST':
p1 = request.form.get('password', '')
p2 = request.form.get('password_confirm', '')
if len(p1) < 10:
flash('Kode skal være mindst 10 tegn.', 'error')
elif p1 != p2:
flash('Koderne matcher ikke.', 'error')
else:
user.set_password(p1)
user.force_password_change = False
db.session.commit()
flash('Din kode er ændret.', 'success')
return redirect(url_for('main.index'))
return render_template('force_password_change.html')
@bp.route('/forgot-password', methods=['GET', 'POST'])
def forgot_password():
if request.method == 'POST':
identity = request.form.get('identity', '').strip()
user = User.query.filter((User.username == identity) | (User.email == identity)).first()
if user:
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires_at = datetime.now(timezone.utc) + timedelta(minutes=current_app.config['RESET_TOKEN_TTL_MINUTES'])
db.session.commit()
reset_url = url_for('main.reset_password', token=token, _external=True)
send_reset_email(user, reset_url)
flash('Hvis brugeren findes, er der sendt et reset-link.', 'success')
return render_template('forgot_password.html')
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
user = User.query.filter_by(reset_token=token).first()
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.now(timezone.utc):
flash('Reset-link er ugyldigt eller udløbet.', 'error')
return redirect(url_for('main.login'))
if request.method == 'POST':
p1 = request.form.get('password', '')
p2 = request.form.get('password_confirm', '')
if len(p1) < 10:
flash('Kode skal være mindst 10 tegn.', 'error')
elif p1 != p2:
flash('Koderne matcher ikke.', 'error')
else:
user.set_password(p1)
user.force_password_change = False
user.reset_token = None
user.reset_token_expires_at = None
db.session.commit()
flash('Din kode er nulstillet. Log ind nu.', 'success')
return redirect(url_for('main.login'))
return render_template('reset_password.html')
@bp.get('/admin/dashboard')
@login_required('admin')
def admin_dashboard():
users = User.query.order_by(User.created_at.desc()).all()
streams = Stream.query.order_by(Stream.created_at.desc()).all()
path_map = {item.get('name'): item for item in media_api_paths()}
return render_template('admin_dashboard.html', users=users, streams=streams, path_map=path_map)
@bp.route('/admin/streamers/new', methods=['GET', 'POST'])
@login_required('admin')
def create_streamer():
if request.method == 'POST':
display_name = request.form.get('display_name', '').strip()
username = request.form.get('username', '').strip().lower()
email = request.form.get('email', '').strip().lower()
stream_name = request.form.get('stream_name', '').strip()
slug = request.form.get('slug', '').strip().lower().replace(' ', '-').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 all([display_name, username, email, stream_name, slug]):
flash('Alle obligatoriske felter skal udfyldes.', 'error')
return render_template('create_streamer.html')
if User.query.filter((User.username == username) | (User.email == email)).first():
flash('Brugernavn eller e-mail findes allerede.', 'error')
return render_template('create_streamer.html')
if Stream.query.filter_by(slug=slug).first():
flash('Slug findes allerede.', 'error')
return render_template('create_streamer.html')
temp_password = generate_password()
user = User(
username=username,
email=email,
display_name=display_name,
role='streamer',
active=True,
force_password_change=True,
)
user.set_password(temp_password)
db.session.add(user)
db.session.flush()
stream = Stream(
user_id=user.id,
name=stream_name,
slug=slug,
token=generate_token(),
chat_type=chat_type,
chat_url=chat_url,
notes=notes,
active=True,
)
db.session.add(stream)
db.session.commit()
send_welcome_email(user, temp_password)
log_event(stream.id, 'Streamer og stream oprettet', user_id=current_user().id)
flash('Streamer og stream oprettet. Mail er sendt.', 'success')
return redirect(url_for('main.stream_detail_admin', stream_id=stream.id))
return render_template('create_streamer.html')
@bp.get('/admin/streams/<int:stream_id>')
@login_required('admin')
def stream_detail_admin(stream_id):
stream = db.session.get(Stream, stream_id)
if not stream:
return redirect(url_for('main.admin_dashboard'))
path_info = update_live_state(stream)
stats = stats_for(stream)
sessions = StreamSession.query.filter_by(stream_id=stream.id).order_by(StreamSession.started_at.desc()).limit(20).all()
logs = EventLog.query.filter_by(stream_id=stream.id).order_by(EventLog.created_at.desc()).limit(50).all()
host = current_app.config['PUBLIC_BASE_URL'].replace('https://', '').replace('http://', '').rstrip('/')
return render_template(
'stream_detail_admin.html',
stream=stream,
stats=stats,
sessions=sessions,
logs=logs,
path_info=path_info,
public_hls=hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], stream.slug),
ingest_full=ingest_url_with_query(host, stream.slug, stream.token),
ingest_server=f'rtmp://{host}:1935/{stream.slug}',
ingest_key=stream.token,
ingest_alt_server=f'rtmp://{host}:1935',
ingest_alt_key=f'{stream.slug}/{stream.token}',
)
@bp.post('/admin/streams/<int:stream_id>/rotate-token')
@login_required('admin')
def rotate_stream_token(stream_id):
stream = db.session.get(Stream, stream_id)
if not stream:
return redirect(url_for('main.admin_dashboard'))
stream.token = generate_token()
db.session.commit()
log_event(stream.id, 'Token roteret', 'warning', current_user().id)
flash('Token er roteret.', 'success')
return redirect(url_for('main.stream_detail_admin', stream_id=stream.id))
@bp.post('/admin/streams/<int:stream_id>/toggle')
@login_required('admin')
def toggle_stream(stream_id):
stream = db.session.get(Stream, stream_id)
if not stream:
return redirect(url_for('main.admin_dashboard'))
stream.active = not stream.active
db.session.commit()
log_event(stream.id, f"Stream {'aktiveret' if stream.active else 'deaktiveret'}", 'warning', current_user().id)
flash('Stream-status er ændret.', 'success')
return redirect(url_for('main.stream_detail_admin', stream_id=stream.id))
@bp.post('/admin/users/<int:user_id>/send-reset')
@login_required('admin')
def admin_send_reset(user_id):
user = db.session.get(User, user_id)
if not user:
return redirect(url_for('main.admin_dashboard'))
token = secrets.token_urlsafe(32)
user.reset_token = token
user.reset_token_expires_at = datetime.now(timezone.utc) + timedelta(minutes=current_app.config['RESET_TOKEN_TTL_MINUTES'])
db.session.commit()
reset_url = url_for('main.reset_password', token=token, _external=True)
send_reset_email(user, reset_url)
flash('Reset-mail sendt.', 'success')
return redirect(url_for('main.admin_dashboard'))
@bp.get('/streamer/panel/<int:stream_id>')
@login_required()
def streamer_panel(stream_id):
stream = stream_for_current_user_or_403(stream_id)
if not stream:
return redirect(url_for('main.index'))
path_info = update_live_state(stream)
stats = stats_for(stream)
sessions = StreamSession.query.filter_by(stream_id=stream.id).order_by(StreamSession.started_at.desc()).limit(10).all()
return render_template(
'streamer_panel.html',
stream=stream,
stats=stats,
sessions=sessions,
path_info=path_info,
public_hls=hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], stream.slug),
)
@bp.get('/api/streams/<int:stream_id>/status')
@login_required()
def stream_status(stream_id):
stream = stream_for_current_user_or_403(stream_id)
if not stream:
return jsonify({'error': 'not found'}), 404
path_info = update_live_state(stream)
stats = stats_for(stream)
return jsonify({
'id': stream.id,
'slug': stream.slug,
'is_live': stream.is_live,
'path_info': path_info,
'stats': stats,
'preview_url': hls_url(current_app.config['MEDIA_PUBLIC_HLS_BASE'], stream.slug),
'chat_url': stream.chat_url,
})
@bp.route('/api/mediamtx/auth', methods=['POST'])
def mediamtx_auth():
data = request.get_json(silent=True) or request.form.to_dict() or {}
action = (data.get('action') or '').strip()
slug, token = extract_slug_token_from_auth(data)
if action == 'read':
return 'ok', 200
if action != 'publish':
return 'denied', 401
if not slug or not token:
return 'denied', 401
stream = Stream.query.filter_by(slug=slug, active=True).first()
if not stream or stream.token != token:
return 'denied', 401
ip = data.get('ip')
session_row = StreamSession(stream_id=stream.id, status='live', publisher_ip=ip, source_type=data.get('protocol'), source_id=data.get('id'))
db.session.add(session_row)
stream.is_live = True
db.session.commit()
return 'ok', 200
@bp.get('/healthz')
def healthz():
return jsonify({'ok': True})
@@ -1,28 +0,0 @@
import os
from flask import current_app
from ..models import User, db
def bootstrap_system(app):
admin_username = os.getenv('ADMIN_USERNAME', 'admin').strip()
admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com').strip()
admin_password = os.getenv('ADMIN_PASSWORD', 'change-me-admin').strip()
existing = User.query.filter_by(username=admin_username).first()
if existing:
return
admin = User(
username=admin_username,
email=admin_email,
display_name='Administrator',
role='admin',
active=True,
force_password_change=False,
)
admin.set_password(admin_password)
db.session.add(admin)
db.session.commit()
current_app.logger.info(f'Bootstrap admin created: {admin_username}')
@@ -1,55 +0,0 @@
import smtplib
from email.message import EmailMessage
from flask import current_app, url_for
def _send_mail(to_email: str, subject: str, body: str):
if not current_app.config['MAIL_ENABLED']:
current_app.logger.info(f"MAIL DISABLED -> To: {to_email} | Subject: {subject}\n{body}")
return
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = current_app.config['MAIL_FROM']
msg['To'] = to_email
msg.set_content(body)
with smtplib.SMTP(current_app.config['SMTP_HOST'], current_app.config['SMTP_PORT'], timeout=20) as smtp:
smtp.starttls()
smtp.login(current_app.config['SMTP_USERNAME'], current_app.config['SMTP_PASSWORD'])
smtp.send_message(msg)
def send_welcome_email(user, temporary_password: str):
login_url = url_for('main.login', _external=True)
body = f"""Hej {user.display_name},
Din TuxiNet StreamHub-konto er blevet oprettet.
Brugernavn: {user.username}
Midlertidig kode: {temporary_password}
Login: {login_url}
Du skal ændre din kode ved første login.
Hilsen
TuxiNet StreamHub
"""
_send_mail(user.email, 'Velkommen til TuxiNet StreamHub', body)
def send_reset_email(user, reset_url: str):
body = f"""Hej {user.display_name},
Du har bedt om at nulstille din adgangskode.
Reset-link:
{reset_url}
Linket udløber automatisk.
Hvis du ikke har bedt om dette, kan du ignorere mailen.
Hilsen
TuxiNet StreamHub
"""
_send_mail(user.email, 'Nulstil din adgangskode', body)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 KiB

@@ -1,60 +0,0 @@
:root {
--bg-dark: #08111b;
--bg-panel: rgba(12, 20, 32, 0.88);
--bg-panel-2: rgba(9, 16, 27, 0.95);
--accent: #00d8ff;
--accent-2: #21b8ff;
--text-main: #eef7ff;
--text-soft: #9fb8cc;
--border: rgba(0, 216, 255, 0.18);
--ok: #78f5b0;
--bad: #ff7f96;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, Arial, sans-serif; background: radial-gradient(circle at top, #0b1c2f, #08111b 55%); color: var(--text-main); }
body.login-page { background: url('/static/bg.png') center center / cover no-repeat fixed; }
a { color: var(--accent); text-decoration: none; }
.container { max-width: 1360px; margin: 0 auto; padding: 2rem; }
.topbar { display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; background: rgba(4,10,18,.9); border-bottom:1px solid var(--border); position:sticky; top:0; z-index:20; }
.topbar nav { display:flex; gap:1rem; }
.brand { display:flex; align-items:center; gap:.75rem; color:var(--text-main); font-weight:700; }
.brand img { height:38px; }
.card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: 20px; padding: 1.2rem; box-shadow: 0 0 40px rgba(0,0,0,.18); }
.narrow { max-width: 520px; margin: 2rem auto; }
.page-header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1.25rem; }
.button, button { border:0; border-radius:14px; padding:.85rem 1.15rem; cursor:pointer; font-weight:700; background: linear-gradient(135deg, var(--accent), var(--accent-2)); color:#03111a; }
.button.secondary, button.secondary { background: rgba(255,255,255,.06); color: var(--text-main); border:1px solid var(--border); }
.form-grid { display:grid; gap:1rem; }
label { display:grid; gap:.35rem; color:var(--text-soft); }
input, textarea, select { width:100%; padding:.9rem 1rem; border-radius:14px; border:1px solid var(--border); background: rgba(0,0,0,.25); color:var(--text-main); }
textarea { min-height: 120px; }
.flash { padding: .9rem 1rem; border-radius: 12px; margin-bottom: 1rem; }
.flash.success { background:#173726; color:#a9f3c7; }
.flash.error { background:#3c1920; color:#ffc1cf; }
.login-wrap { min-height: calc(100vh - 96px); display:grid; place-items:center; }
.login-card { width:min(480px, 92vw); padding:2rem; border-radius:26px; background: rgba(8, 17, 27, .74); border:1px solid var(--border); backdrop-filter: blur(14px); }
.login-logo { width: 100%; max-width: 240px; display:block; margin:0 auto 1rem; }
.login-links { margin-top: 1rem; text-align:center; }
.muted { color:var(--text-soft); }
.stream-table { width:100%; border-collapse: collapse; }
.stream-table th, .stream-table td { padding:.85rem .75rem; border-bottom:1px solid rgba(255,255,255,.08); text-align:left; vertical-align:middle; }
.space-top { margin-top: 1rem; }
.detail-layout { display:grid; grid-template-columns:1.45fr .75fr; gap:1rem; }
.lower-gap { margin-top: 1rem; }
.streamer-layout-fixed { display:grid; grid-template-columns:1.45fr .75fr; gap:1rem; }
.panel-head { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1rem; }
.video-frame { background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08); border-radius:16px; padding:.5rem; overflow:hidden; }
.video-frame video { width:100%; display:block; border-radius:12px; min-height:320px; background:#000; }
.video-frame.large video { min-height:430px; }
.stats-grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.75rem; }
.compact-top { margin-top:1rem; }
.mini-stat { background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.06); border-radius:14px; padding:.8rem; }
.mini-stat span { display:block; opacity:.72; font-size:.85rem; margin-bottom:.2rem; }
.badge { display:inline-flex; align-items:center; justify-content:center; padding:.35rem .75rem; border-radius:999px; font-weight:700; }
.badge.live { background:#183d27; color:var(--ok); }
.badge.offline { background:#3c1d24; color:var(--bad); }
.stack-actions { display:grid; gap:.75rem; margin-top:1rem; }
.chat-frame { width:100%; min-height:720px; border:none; border-radius:12px; background:#0b0f16; }
@media (max-width:1100px) { .detail-layout, .streamer-layout-fixed { grid-template-columns:1fr; } .stats-grid { grid-template-columns:1fr 1fr; } }
@media (max-width:700px) { .container { padding: 1rem; } .page-header, .topbar { display:grid; grid-template-columns:1fr; } .stats-grid { grid-template-columns:1fr; } }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

@@ -1,56 +0,0 @@
{% extends 'base.html' %}
{% block title %}Admin Dashboard{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Admin Dashboard</h1>
<p>Streams og brugere</p>
</div>
<a class="button" href="{{ url_for('main.create_streamer') }}">+ Opret streamer</a>
</div>
<section class="card">
<h2>Streams</h2>
<table class="stream-table">
<thead><tr><th>Navn</th><th>Slug</th><th>Ejer</th><th>Status</th><th>Aktiv</th><th>Handlinger</th></tr></thead>
<tbody>
{% for s in streams %}
{% set p = path_map.get(s.slug) %}
{% set live = p and (p.ready or p.online) %}
<tr>
<td>{{ s.name }}</td>
<td>/{{ s.slug }}</td>
<td>{{ s.user.display_name }}</td>
<td><span class="badge {{ 'live' if live else 'offline' }}">{{ 'LIVE' if live else 'OFFLINE' }}</span></td>
<td>{{ 'Ja' if s.active else 'Nej' }}</td>
<td><a class="button secondary" href="{{ url_for('main.stream_detail_admin', stream_id=s.id) }}">Detaljer</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
<section class="card space-top">
<h2>Brugere</h2>
<table class="stream-table">
<thead><tr><th>Navn</th><th>Brugernavn</th><th>E-mail</th><th>Rolle</th><th>Status</th><th>Handlinger</th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.display_name }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>{{ u.role }}</td>
<td>{{ 'Aktiv' if u.active else 'Deaktiv' }}</td>
<td>
{% if u.role == 'streamer' %}
<form method="post" action="{{ url_for('main.admin_send_reset', user_id=u.id) }}">
<button type="submit" class="secondary">Send reset</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
@@ -1,38 +0,0 @@
<!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('static', filename='style.css') }}">
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">
<a class="brand" href="{{ url_for('main.index') }}">
<img src="{{ url_for('static', filename='logo.png') }}" alt="TuxiNet StreamHub">
<span>TuxiNet StreamHub</span>
</a>
{% if current_user %}
<nav>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('main.admin_dashboard') }}">Dashboard</a>
<a href="{{ url_for('main.create_streamer') }}">Ny streamer</a>
{% endif %}
<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>
@@ -1,29 +0,0 @@
{% extends 'base.html' %}
{% block title %}Ny streamer{% endblock %}
{% block content %}
<div class="page-header"><h1>Opret streamer + stream</h1></div>
<div class="detail-layout">
<section class="card main-panel">
<h2>Streamer</h2>
<form method="post" class="form-grid">
<label>Visningsnavn<input type="text" name="display_name" required></label>
<label>Brugernavn<input type="text" name="username" required></label>
<label>E-mail<input type="email" name="email" required></label>
<h2>Stream</h2>
<label>Stream navn<input type="text" name="stream_name" required></label>
<label>Slug<input type="text" name="slug" required></label>
<label>Chat type
<select name="chat_type">
<option value="none">Ingen</option>
<option value="twitch">Twitch</option>
<option value="youtube">YouTube</option>
</select>
</label>
<label>Chat URL<input type="text" name="chat_url"></label>
<label>Noter<textarea name="notes"></textarea></label>
<button type="submit">Opret streamer</button>
</form>
</section>
</div>
{% endblock %}
@@ -1,14 +0,0 @@
{% extends 'base.html' %}
{% block title %}Skift kode{% endblock %}
{% block content %}
<div class="card narrow">
<h1>Skift din kode</h1>
<p class="muted">Du skal ændre din midlertidige kode, før du kan fortsætte.</p>
<form method="post" class="form-grid">
<label>Nyt kodeord<input type="password" name="password" required></label>
<label>Gentag kodeord<input type="password" name="password_confirm" required></label>
<button type="submit">Gem ny kode</button>
</form>
</div>
{% endblock %}
@@ -1,12 +0,0 @@
{% extends 'base.html' %}
{% block title %}Glemt kodeord{% endblock %}
{% block content %}
<div class="card narrow">
<h1>Glemt kodeord</h1>
<form method="post" class="form-grid">
<label>Brugernavn eller e-mail<input type="text" name="identity" required></label>
<button type="submit">Send reset-link</button>
</form>
</div>
{% endblock %}
@@ -1,19 +0,0 @@
{% extends 'base.html' %}
{% block title %}Login{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<img class="login-logo" src="{{ url_for('static', filename='logo.png') }}" alt="logo">
<h1>Log ind</h1>
<p class="muted">Enterprise RTMP control platform</p>
<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 class="login-links"><a href="{{ url_for('main.forgot_password') }}">Glemt kodeord?</a></div>
</div>
</div>
{% endblock %}
@@ -1,13 +0,0 @@
{% extends 'base.html' %}
{% block title %}Reset kodeord{% endblock %}
{% block content %}
<div class="card narrow">
<h1>Nulstil kodeord</h1>
<form method="post" class="form-grid">
<label>Nyt kodeord<input type="password" name="password" required></label>
<label>Gentag kodeord<input type="password" name="password_confirm" required></label>
<button type="submit">Gem kodeord</button>
</form>
</div>
{% endblock %}
@@ -1,71 +0,0 @@
{% extends 'base.html' %}
{% block title %}{{ stream.name }}{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ stream.name }}</h1>
<p>{{ stream.user.display_name }} · /{{ stream.slug }}</p>
</div>
<div class="badge {{ 'live' if stream.is_live else 'offline' }}">{{ 'LIVE' if stream.is_live else 'OFFLINE' }}</div>
</div>
<div class="detail-layout">
<section class="card main-panel">
<h2>Preview</h2>
<div class="video-frame large"><video id="previewVideo" controls playsinline muted poster="{{ url_for('static', filename='videooffline.png') }}"></video></div>
<div class="muted">{{ public_hls }}</div>
<div class="stats-grid compact-top">
<div class="mini-stat"><span>Bitrate</span><strong>{{ stats.get('bitrate', '-') }}</strong></div>
<div class="mini-stat"><span>FPS</span><strong>{{ stats.get('fps', '-') }}</strong></div>
<div class="mini-stat"><span>Video</span><strong>{{ stats.get('video_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Lyd</span><strong>{{ stats.get('audio_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Kanaler</span><strong>{{ stats.get('audio_channels', '-') }}</strong></div>
<div class="mini-stat"><span>Sample rate</span><strong>{{ stats.get('audio_sample_rate', '-') }}</strong></div>
</div>
</section>
<aside class="card side-panel">
<h2>Ingest</h2>
<label>OBS Server</label><input readonly value="{{ ingest_server }}">
<label>OBS Key</label><input readonly value="{{ ingest_key }}">
<label>Larix / Streamlabs URL</label><input readonly value="{{ ingest_alt_server }}">
<label>Larix / Streamlabs Key</label><input readonly value="{{ ingest_alt_key }}">
<label>Fuldt link</label><input readonly value="{{ ingest_full }}">
<div class="stack-actions">
<form method="post" action="{{ url_for('main.toggle_stream', stream_id=stream.id) }}"><button type="submit">{{ 'Deaktiver' if stream.active else 'Aktiver' }}</button></form>
<form method="post" action="{{ url_for('main.rotate_stream_token', stream_id=stream.id) }}"><button type="submit" class="secondary">Rotér token</button></form>
</div>
</aside>
</div>
<div class="detail-layout lower-gap">
<section class="card main-panel">
<h2>Forbindelser / sessioner</h2>
<table class="stream-table">
<thead><tr><th>Start</th><th>Status</th><th>IP</th><th>Kilde</th></tr></thead>
<tbody>
{% for row in sessions %}
<tr><td>{{ row.started_at }}</td><td>{{ row.status }}</td><td>{{ row.publisher_ip or '-' }}</td><td>{{ row.source_type or '-' }}</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<aside class="card side-panel">
<h2>Chat</h2>
{% if stream.chat_url %}
<iframe src="{{ stream.chat_url }}" class="chat-frame"></iframe>
{% else %}
<div class="muted">Ingen chat sat op endnu.</div>
{% endif %}
</aside>
</div>
<script>
const previewUrl = {{ public_hls|tojson }};
const video = document.getElementById('previewVideo');
if (window.Hls && Hls.isSupported()) {
const hls = new Hls({lowLatencyMode: true});
hls.loadSource(previewUrl);
hls.attachMedia(video);
} else {
video.src = previewUrl;
}
</script>
{% endblock %}
@@ -1,85 +0,0 @@
{% extends 'base.html' %}
{% block title %}Streamer Panel{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>Streamer Panel</h1>
<p>{{ stream.name }} · /{{ stream.slug }}</p>
</div>
<div id="liveBadge" class="badge {{ 'live' if stream.is_live else 'offline' }}">{{ 'LIVE' if stream.is_live else 'OFFLINE' }}</div>
</div>
<div class="streamer-layout-fixed">
<section class="card preview-column">
<div class="panel-head"><h2>Preview</h2><div class="muted">{{ public_hls }}</div></div>
<div class="video-frame large"><video id="streamerPreview" controls playsinline muted poster="{{ url_for('static', filename='videooffline.png') }}"></video></div>
<div class="stats-grid compact-top">
<div class="mini-stat"><span>Bitrate</span><strong id="spBitrate">{{ stats.get('bitrate', '-') }}</strong></div>
<div class="mini-stat"><span>FPS</span><strong id="spFps">{{ stats.get('fps', '-') }}</strong></div>
<div class="mini-stat"><span>Video</span><strong id="spVideo">{{ stats.get('video_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Lyd</span><strong id="spAudio">{{ stats.get('audio_codec', '-') }}</strong></div>
<div class="mini-stat"><span>Kanaler</span><strong id="spChannels">{{ stats.get('audio_channels', '-') }}</strong></div>
<div class="mini-stat"><span>Sample rate</span><strong id="spSample">{{ stats.get('audio_sample_rate', '-') }}</strong></div>
<div class="mini-stat"><span>Ready</span><strong id="spReady">{{ 'true' if path_info and path_info.ready else 'false' }}</strong></div>
<div class="mini-stat"><span>Online</span><strong id="spOnline">{{ 'true' if path_info and path_info.online else 'false' }}</strong></div>
</div>
<div class="ingest-box">
<h2>Dine forbindelser</h2>
<table class="stream-table">
<thead><tr><th>Start</th><th>Status</th><th>IP</th></tr></thead>
<tbody>
{% for row in sessions %}
<tr><td>{{ row.started_at }}</td><td>{{ row.status }}</td><td>{{ row.publisher_ip or '-' }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<aside class="card chat-column">
<h2>Chat</h2>
{% if stream.chat_url %}
<iframe src="{{ stream.chat_url }}" class="chat-frame"></iframe>
{% else %}
<div class="muted">Ingen chat er sat op endnu for denne kanal.</div>
{% endif %}
</aside>
</div>
<script>
const previewUrl = {{ public_hls|tojson }};
const statusUrl = {{ url_for('main.stream_status', stream_id=stream.id)|tojson }};
const player = document.getElementById('streamerPreview');
let hlsInstance = null;
function attachPlayer(url) {
if (hlsInstance) { hlsInstance.destroy(); hlsInstance = null; }
if (window.Hls && Hls.isSupported()) {
hlsInstance = new Hls({lowLatencyMode: true});
hlsInstance.loadSource(url);
hlsInstance.attachMedia(player);
} else { player.src = url; }
}
function fillStats(stats) {
document.getElementById('spBitrate').textContent = stats.bitrate || '-';
document.getElementById('spFps').textContent = stats.fps || '-';
document.getElementById('spVideo').textContent = stats.video_codec || '-';
document.getElementById('spAudio').textContent = stats.audio_codec || '-';
document.getElementById('spChannels').textContent = stats.audio_channels || '-';
document.getElementById('spSample').textContent = stats.audio_sample_rate || '-';
}
attachPlayer(previewUrl);
fillStats({{ stats|tojson }});
async function refreshStatus() {
const res = await fetch(statusUrl);
const data = await res.json();
const live = !!data.is_live;
const badge = document.getElementById('liveBadge');
badge.textContent = live ? 'LIVE' : 'OFFLINE';
badge.classList.toggle('live', live);
badge.classList.toggle('offline', !live);
document.getElementById('spReady').textContent = data.path_info?.ready ? 'true' : 'false';
document.getElementById('spOnline').textContent = data.path_info?.online ? 'true' : 'false';
fillStats(data.stats || {});
if (!live) { player.poster = "{{ url_for('static', filename='videooffline.png') }}"; }
}
setInterval(refreshStatus, 3000);
</script>
{% endblock %}
@@ -1,50 +0,0 @@
import secrets
import subprocess
from urllib.parse import urlencode
def generate_token(length: int = 32) -> str:
return secrets.token_urlsafe(length)[:length]
def generate_password(length: int = 14) -> str:
alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%'
return ''.join(secrets.choice(alphabet) for _ in range(length))
def hls_url(base: str, slug: str) -> str:
return f"{base.rstrip('/')}/{slug}/index.m3u8"
def ingest_url_with_query(host: str, slug: str, token: str) -> str:
return f"rtmp://{host}:1935/{slug}?{urlencode({'token': token})}"
def ffprobe_stream(url: str) -> dict:
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', url]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=8, check=True)
except Exception:
return {}
import json
try:
payload = json.loads(result.stdout or '{}')
except Exception:
return {}
out = {}
for stream in payload.get('streams', []):
if stream.get('codec_type') == 'video':
out['video_codec'] = stream.get('codec_name')
out['width'] = stream.get('width')
out['height'] = stream.get('height')
out['fps'] = stream.get('avg_frame_rate')
elif stream.get('codec_type') == 'audio':
out['audio_codec'] = stream.get('codec_name')
out['audio_channels'] = stream.get('channels')
out['audio_sample_rate'] = stream.get('sample_rate')
if payload.get('format', {}).get('bit_rate'):
out['bitrate'] = payload['format']['bit_rate']
return out
@@ -1,9 +0,0 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
gunicorn==23.0.0
psycopg[binary]==3.2.1
requests==2.32.3
Werkzeug==3.0.3
itsdangerous==2.2.0
@@ -1,3 +0,0 @@
from app import create_app
app = create_app()
@@ -1,3 +0,0 @@
:80 {
reverse_proxy backend:5000
}
@@ -1,53 +0,0 @@
services:
postgres:
image: postgres:16
container_name: streamhub-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${TZ}
volumes:
- ./data/postgres:/var/lib/postgresql/data
mediamtx:
image: bluenviron/mediamtx:1.17.0
container_name: streamhub-mediamtx
restart: unless-stopped
ports:
- "1935:1935"
- "8888:8888"
- "8889:8889"
- "8189:8189/udp"
- "127.0.0.1:9997:9997"
- "127.0.0.1:9998:9998"
volumes:
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
backend:
build: ./backend
container_name: streamhub-backend
restart: unless-stopped
env_file:
- .env
depends_on:
- postgres
- mediamtx
volumes:
- ./data/logs:/app/logs
caddy:
image: caddy:2.8
container_name: streamhub-caddy
restart: unless-stopped
depends_on:
- backend
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy:/data
- ./data/caddy_config:/config
@@ -1,47 +0,0 @@
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
srt: true
srtAddress: :8890
authMethod: http
authHTTPAddress: http://backend:5000/api/mediamtx/auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
pathDefaults:
source: publisher
overridePublisher: true
paths:
all_others:
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cp -n .env.example .env || true
echo "Redigér .env før første start"