v2.1-enterprise
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
|
||||
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
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
# 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>`
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
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"]
|
||||
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
|
||||
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,85 @@
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,412 @@
|
||||
|
||||
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})
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,28 @@
|
||||
|
||||
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}')
|
||||
@@ -0,0 +1,55 @@
|
||||
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.
|
After Width: | Height: | Size: 4.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 955 KiB |
@@ -0,0 +1,60 @@
|
||||
|
||||
: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.
|
After Width: | Height: | Size: 495 KiB |
@@ -0,0 +1,56 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
<!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>
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,85 @@
|
||||
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,3 @@
|
||||
:80 {
|
||||
reverse_proxy backend:5000
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
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:
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cp -n .env.example .env || true
|
||||
echo "Redigér .env før første start"
|
||||
Reference in New Issue
Block a user