Merge pull request 'v2.1-enterprise' (#1) from V2.1 into main
Reviewed-on: #1
This commit was merged in pull request #1.
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