Compare commits
14 Commits
6166b8111e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 787b7a0555 | |||
| 3addc882d5 | |||
| 949f07d420 | |||
| 5c21131f97 | |||
| b2e7515327 | |||
| 73286479cb | |||
| a39e2aaade | |||
| 3fd8408605 | |||
| 9faa53e149 | |||
| 0df6795da2 | |||
| f62d7e62c5 | |||
| 6882453b6a | |||
| 70a328ef4d | |||
| ca9cbce083 |
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -35,6 +36,149 @@ var (
|
|||||||
tpls *template.Template
|
tpls *template.Template
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ----- SSE Broker -----
|
||||||
|
type sseClient chan []byte
|
||||||
|
|
||||||
|
type sseBroker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[sseClient]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSSEBroker() *sseBroker {
|
||||||
|
return &sseBroker{clients: make(map[sseClient]struct{})}
|
||||||
|
}
|
||||||
|
func (b *sseBroker) add(c sseClient) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.clients[c] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
func (b *sseBroker) del(c sseClient) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.clients, c)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
func (b *sseBroker) broadcast(msg []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
for c := range b.clients {
|
||||||
|
select {
|
||||||
|
case c <- msg:
|
||||||
|
default: /* slow client, drop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
streamsJSONCache []byte
|
||||||
|
streamsJSONETag string
|
||||||
|
streamsCacheMu sync.RWMutex
|
||||||
|
sseB = newSSEBroker()
|
||||||
|
)
|
||||||
|
|
||||||
|
func eq(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type hlsMuxersListResp struct {
|
||||||
|
Items []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Clients []interface{} `json:"clients"` // echte HLS-Viewer (HTTP-Clients)
|
||||||
|
} `json:"items"`
|
||||||
|
// manche Builds liefern "items" flach; das fangen wir ab:
|
||||||
|
Name string `json:"name"`
|
||||||
|
Clients []interface{} `json:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHLSClientMap(ctx context.Context, base, user, pass string) (map[string]int, error) {
|
||||||
|
u := strings.TrimRight(base, "/") + "/v3/hlsmuxers/list"
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user != "" || pass != "" {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Wenn noch kein einziger HLS-Client existiert, liefert MediaMTX oft 200 mit leerer Liste
|
||||||
|
// (oder 200 mit items=[]). 404 sollte hier nicht auftreten – falls doch, behandeln wir es als "leer".
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return map[string]int{}, nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("hlsmuxers/list: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out hlsMuxersListResp
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m := make(map[string]int)
|
||||||
|
|
||||||
|
if len(out.Items) > 0 {
|
||||||
|
for _, it := range out.Items {
|
||||||
|
m[it.Name] = len(it.Clients)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: flache Struktur (ein einzelner Muxer)
|
||||||
|
if out.Name != "" {
|
||||||
|
m[out.Name] = len(out.Clients)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mtxHLSPayload struct {
|
||||||
|
// v1.15.0 liefert einen Muxer mit Clients-Liste (Feldname kann "clients" heißen)
|
||||||
|
Item struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Clients []interface{} `json:"clients"` // Anzahl = echte HLS-Zuschauer
|
||||||
|
} `json:"item"`
|
||||||
|
// manche Builds liefern flach:
|
||||||
|
Name string `json:"name"`
|
||||||
|
Clients []interface{} `json:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func hlsViewers(ctx context.Context, base, user, pass, name string) (int, error) {
|
||||||
|
u := strings.TrimRight(base, "/") + "/v3/hlsmuxers/get/" + url.PathEscape(name)
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if user != "" || pass != "" {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return 0, fmt.Errorf("hlsmuxers/get %s: %s", name, res.Status)
|
||||||
|
}
|
||||||
|
var out mtxHLSPayload
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if out.Item.Clients != nil {
|
||||||
|
return len(out.Item.Clients), nil
|
||||||
|
}
|
||||||
|
return len(out.Clients), nil
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_ = mime.AddExtensionType(".css", "text/css")
|
_ = mime.AddExtensionType(".css", "text/css")
|
||||||
_ = mime.AddExtensionType(".js", "application/javascript")
|
_ = mime.AddExtensionType(".js", "application/javascript")
|
||||||
@@ -75,6 +219,8 @@ func main() {
|
|||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"script-src 'self'",
|
"script-src 'self'",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
|
"media-src 'self' blob:", // <— HIER blob: zulassen
|
||||||
|
"worker-src 'self' blob:",
|
||||||
}, "; "),
|
}, "; "),
|
||||||
)
|
)
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
@@ -84,12 +230,15 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// API
|
// Rate Limit nur auf REST, nicht auf SSE:
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(httprate.LimitByIP(30, time.Minute))
|
r.Use(httprate.LimitByIP(240, time.Minute)) // fertig
|
||||||
r.Get("/api/streams", apiStreams)
|
r.Get("/api/streams", apiStreams)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// SSE ohne Rate-Limit (1 Verbindung pro Browser):
|
||||||
|
r.Get("/api/streams/events", sseHandler)
|
||||||
|
|
||||||
// Optional Basic Auth für Seiten
|
// Optional Basic Auth für Seiten
|
||||||
if basicUser != "" {
|
if basicUser != "" {
|
||||||
creds := basicUser + ":" + basicPass
|
creds := basicUser + ":" + basicPass
|
||||||
@@ -138,21 +287,164 @@ func main() {
|
|||||||
// Health
|
// Health
|
||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(1 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
// gleiche Logik wie in apiStreams, nur ohne Response
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
c := &mtx.Client{BaseURL: mtxAPI, User: os.Getenv("MTX_API_USER"), Pass: os.Getenv("MTX_API_PASS")}
|
||||||
|
pl, err := c.Paths(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
// optional: bei Fehler nichts senden
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
if streamsCSV != "" {
|
||||||
|
for _, s := range strings.Split(streamsCSV, ",") {
|
||||||
|
allowed[strings.TrimSpace(s)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type item struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Live bool `json:"live"`
|
||||||
|
Viewers int `json:"viewers"`
|
||||||
|
}
|
||||||
|
out := struct {
|
||||||
|
Items []item `json:"items"`
|
||||||
|
}{}
|
||||||
|
for _, p := range pl.Items {
|
||||||
|
if len(allowed) > 0 && !allowed[p.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// echte HLS-Viewerzahlen einmalig holen (Batch)
|
||||||
|
hlsMap := map[string]int{}
|
||||||
|
{
|
||||||
|
ctxH, cancelH := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
tmp, err := fetchHLSClientMap(ctxH, mtxAPI, os.Getenv("MTX_API_USER"), os.Getenv("MTX_API_PASS"))
|
||||||
|
cancelH()
|
||||||
|
if err == nil && tmp != nil {
|
||||||
|
hlsMap = tmp
|
||||||
|
} else {
|
||||||
|
log.Printf("warn:a: hlsmuxers/list: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewers := 0
|
||||||
|
|
||||||
|
// 1) Echte HLS-Zuschauer aus der Batch-Map
|
||||||
|
if v, ok := hlsMap[p.Name]; ok {
|
||||||
|
viewers = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: Nicht-HLS-Reader (oder wenn Map leer ist)
|
||||||
|
// Bei HLS ist p.Viewers() meist 1 (der Muxer), aber das wollen wir nur nutzen,
|
||||||
|
// wenn wir keinen HLS-Wert haben.
|
||||||
|
if viewers == 0 {
|
||||||
|
if v := p.Viewers(); v > 0 {
|
||||||
|
viewers = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Items = append(out.Items, item{
|
||||||
|
Name: p.Name,
|
||||||
|
Live: p.Live(),
|
||||||
|
Viewers: viewers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, _ := json.Marshal(out)
|
||||||
|
|
||||||
|
streamsCacheMu.RLock()
|
||||||
|
same := eq(buf, streamsJSONCache)
|
||||||
|
streamsCacheMu.RUnlock()
|
||||||
|
if same {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
streamsCacheMu.Lock()
|
||||||
|
streamsJSONCache = buf
|
||||||
|
streamsJSONETag = fmt.Sprintf("%x", len(buf))
|
||||||
|
streamsCacheMu.Unlock()
|
||||||
|
|
||||||
|
// an alle SSE-Clients senden
|
||||||
|
sseB.broadcast(buf)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
log.Printf("Dashboard listening on %s (API=%s HLS=%s, WEB_ROOT=%s)\n", listen, mtxAPI, mtxHLS, webRoot)
|
log.Printf("Dashboard listening on %s (API=%s HLS=%s, WEB_ROOT=%s)\n", listen, mtxAPI, mtxHLS, webRoot)
|
||||||
if err := http.ListenAndServe(listen, r); err != nil {
|
if err := http.ListenAndServe(listen, r); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sseHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// SSE-Header
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
// CORS brauchst du nicht, gleiche Origin
|
||||||
|
|
||||||
|
// Flush unterstützen
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "stream unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-Kanal registrieren
|
||||||
|
ch := make(sseClient, 8)
|
||||||
|
sseB.add(ch)
|
||||||
|
defer sseB.del(ch)
|
||||||
|
|
||||||
|
// Beim Connect sofort den aktuellen Snapshot schicken (falls vorhanden)
|
||||||
|
streamsCacheMu.RLock()
|
||||||
|
snap := streamsJSONCache
|
||||||
|
streamsCacheMu.RUnlock()
|
||||||
|
if len(snap) > 0 {
|
||||||
|
fmt.Fprintf(w, "event: update\ndata: %s\n\n", string(snap))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat (hält Proxies freundlich)
|
||||||
|
hb := time.NewTicker(15 * time.Second)
|
||||||
|
defer hb.Stop()
|
||||||
|
|
||||||
|
// Abbruch, wenn Client trennt
|
||||||
|
notify := r.Context().Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-notify:
|
||||||
|
return
|
||||||
|
case msg := <-ch:
|
||||||
|
// JSON als "update" senden
|
||||||
|
fmt.Fprintf(w, "event: update\ndata: %s\n\n", string(msg))
|
||||||
|
flusher.Flush()
|
||||||
|
case <-hb.C:
|
||||||
|
// Kommentar als Ping
|
||||||
|
fmt.Fprintf(w, ": ping\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
func apiStreams(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
c := &mtx.Client{BaseURL: mtxAPI, User: os.Getenv("MTX_API_USER"), Pass: os.Getenv("MTX_API_PASS")}
|
c := &mtx.Client{BaseURL: mtxAPI, User: os.Getenv("MTX_API_USER"), Pass: os.Getenv("MTX_API_PASS")}
|
||||||
pl, err := c.Paths(ctx)
|
pl, err := c.Paths(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed := map[string]bool{}
|
allowed := map[string]bool{}
|
||||||
if streamsCSV != "" {
|
if streamsCSV != "" {
|
||||||
for _, s := range strings.Split(streamsCSV, ",") {
|
for _, s := range strings.Split(streamsCSV, ",") {
|
||||||
@@ -171,10 +463,43 @@ func apiStreams(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(allowed) > 0 && !allowed[p.Name] {
|
if len(allowed) > 0 && !allowed[p.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.Items = append(out.Items, item{Name: p.Name, Live: p.Live(), Viewers: p.Viewers()})
|
// echte HLS-Viewerzahlen einmalig holen (Batch)
|
||||||
|
hlsMap := map[string]int{}
|
||||||
|
{
|
||||||
|
ctxH, cancelH := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
tmp, err := fetchHLSClientMap(ctxH, mtxAPI, os.Getenv("MTX_API_USER"), os.Getenv("MTX_API_PASS"))
|
||||||
|
cancelH()
|
||||||
|
if err == nil && tmp != nil {
|
||||||
|
hlsMap = tmp
|
||||||
|
} else {
|
||||||
|
log.Printf("warn:b: hlsmuxers/list: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewers := 0
|
||||||
|
|
||||||
|
// 1) Echte HLS-Zuschauer aus der Batch-Map
|
||||||
|
if v, ok := hlsMap[p.Name]; ok {
|
||||||
|
viewers = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: Nicht-HLS-Reader (oder wenn Map leer ist)
|
||||||
|
// Bei HLS ist p.Viewers() meist 1 (der Muxer), aber das wollen wir nur nutzen,
|
||||||
|
// wenn wir keinen HLS-Wert haben.
|
||||||
|
if viewers == 0 {
|
||||||
|
if v := p.Viewers(); v > 0 {
|
||||||
|
viewers = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Items = append(out.Items, item{
|
||||||
|
Name: p.Name,
|
||||||
|
Live: p.Live(),
|
||||||
|
Viewers: viewers,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(out)
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pageIndex(w http.ResponseWriter, r *http.Request) {
|
func pageIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
58
compose.yml
58
compose.yml
@@ -1,45 +1,63 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mediamtx:
|
mediamtx:
|
||||||
image: bluenviron/mediamtx:1.9.2
|
image: bluenviron/mediamtx:1.15.0
|
||||||
container_name: mediamtx
|
container_name: mediamtx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935" # RTMP ingest
|
- 1935:1935 # RTMP ingest
|
||||||
- "8888:8888" # HLS/HTTP (optional extern, Dashboard proxyt ohnehin)
|
- 8888:8888 # HLS
|
||||||
|
- 9997:9997 # API
|
||||||
|
- 9998:9998 # metrics (optional)
|
||||||
volumes:
|
volumes:
|
||||||
- ./mediamtx.yml:/mediamtx.yml:ro
|
- /docker/streaming/mediamtx.yml:/mediamtx.yml:ro
|
||||||
command: ["/mediamtx", "/mediamtx.yml"]
|
command:
|
||||||
|
- /mediamtx.yml
|
||||||
|
networks:
|
||||||
|
- traefik-net
|
||||||
dashboard:
|
dashboard:
|
||||||
build:
|
image: git.send.nrw/sendnrw/nginx-stream-server:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: go-stream-dashboard
|
container_name: go-stream-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.streaming.rule=Host(`streaming.domain.tld`)
|
||||||
|
- traefik.http.routers.streaming.service=streaming
|
||||||
|
- traefik.http.services.streaming.loadbalancer.server.port=8080
|
||||||
|
- traefik.http.routers.streaming.entrypoints=websecure
|
||||||
|
- traefik.http.routers.streaming.tls=true
|
||||||
|
- traefik.http.routers.streaming.tls.certresolver=letsencrypt
|
||||||
|
- traefik.protocol=http
|
||||||
environment:
|
environment:
|
||||||
# Dashboard
|
# Dashboard
|
||||||
- LISTEN=:8080
|
- LISTEN=:8080
|
||||||
- BASIC_AUTH_USER=${BASIC_AUTH_USER:-}
|
- BASIC_AUTH_USER=${BASIC_AUTH_USER:-}
|
||||||
- BASIC_AUTH_PASS=${BASIC_AUTH_PASS:-}
|
- BASIC_AUTH_PASS=${BASIC_AUTH_PASS:-}
|
||||||
- STREAMS=${STREAMS:-}
|
- STREAMS=${STREAMS:-}
|
||||||
# MediaMTX Endpunkte (im Compose‑Netzwerk erreichbar)
|
|
||||||
- MTX_API=http://mediamtx:9997
|
- MTX_API=http://mediamtx:9997
|
||||||
- MTX_HLS=http://mediamtx:8888
|
- MTX_HLS=http://mediamtx:8888
|
||||||
- MTX_API_USER=${MTX_API_USER:-admin}
|
- MTX_API_USER=admin
|
||||||
- MTX_API_PASS=${MTX_API_PASS:-starkes-passwort}
|
- MTX_API_PASS=admin
|
||||||
ports:
|
#ports:
|
||||||
- "8080:8080"
|
#- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- mediamtx
|
- mediamtx
|
||||||
healthcheck:
|
#healthcheck:
|
||||||
test: ["CMD", "/app/dashboard", "-healthcheck"] # optionaler Schalter, siehe unten
|
#test:
|
||||||
interval: 15s
|
#- CMD
|
||||||
timeout: 3s
|
#- /app/dashboard
|
||||||
retries: 5
|
#- -healthcheck
|
||||||
|
# optionaler Schalter, siehe unten
|
||||||
|
#interval: 15s
|
||||||
|
#timeout: 3s
|
||||||
|
#retries: 5
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
|
networks:
|
||||||
|
- traefik-net
|
||||||
|
networks:
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
4
go.mod
4
go.mod
@@ -9,7 +9,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -4,13 +4,13 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
|||||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
|||||||
75
mediamtx.yml
75
mediamtx.yml
@@ -1,29 +1,64 @@
|
|||||||
# lauscht auf den Standard-Ports
|
# --- Logging ---
|
||||||
rtmp: yes
|
logLevel: info
|
||||||
hls: yes
|
|
||||||
# optional: enable ll-hls: yes
|
|
||||||
httpAddress: :8888
|
|
||||||
rtmpAddress: :1935
|
|
||||||
|
|
||||||
|
# --- Control API (geschützt) ---
|
||||||
# Control API (für Dashboard‑Status)
|
|
||||||
api: yes
|
api: yes
|
||||||
apiAddress: :9997
|
apiAddress: :9997
|
||||||
# Auth für API empfehlenswert
|
apiEncryption: no
|
||||||
apiUser: admin
|
apiAllowOrigin: "*"
|
||||||
apiPass: starkes-passwort
|
|
||||||
|
|
||||||
|
# --- Metrics (optional) ---
|
||||||
# Metriken (optional)
|
|
||||||
metrics: yes
|
metrics: yes
|
||||||
metricsAddress: :9998
|
metricsAddress: :9998
|
||||||
|
metricsEncryption: no
|
||||||
|
metricsAllowOrigin: "*"
|
||||||
|
|
||||||
|
# --- RTMP (Ingest) ---
|
||||||
|
rtmp: yes
|
||||||
|
rtmpAddress: :1935
|
||||||
|
rtmpEncryption: "no"
|
||||||
|
|
||||||
# Allgemeine Pfadeinstellungen (alle Streams)
|
# --- HLS (Playback) ---
|
||||||
|
hls: yes
|
||||||
|
hlsAddress: :8888
|
||||||
|
hlsEncryption: no
|
||||||
|
hlsVariant: lowLatency # <— statt mpegts
|
||||||
|
hlsSegmentDuration: 1s # 1–2s (Segment = GOP-Vielfaches, s.u.)
|
||||||
|
hlsPartDuration: 200ms # 200–350ms
|
||||||
|
hlsSegmentCount: 7 # hat keinen Einfluss auf Latenz, aber genügt
|
||||||
|
hlsAlwaysRemux: yes # schneller Join / weniger Anlaufzeit
|
||||||
|
|
||||||
|
# Nicht benötigt
|
||||||
|
rtsp: no
|
||||||
|
webrtc: no
|
||||||
|
srt: no
|
||||||
|
pprof: no
|
||||||
|
playback: no
|
||||||
|
|
||||||
|
# --- AUTH: Intern (API + Publish) ---
|
||||||
|
# authMethod: internal
|
||||||
|
authInternalUsers:
|
||||||
|
# Admin für API (und optional metrics)
|
||||||
|
- user: sha256:jGl25bVBBBW96Qi9Te4V37Fnqchz/Eu4qB9vKrRIqRg=
|
||||||
|
pass: sha256:jGl25bVBBBW96Qi9Te4V37Fnqchz/Eu4qB9vKrRIqRg=
|
||||||
|
permissions:
|
||||||
|
- action: api
|
||||||
|
- action: metrics
|
||||||
|
|
||||||
|
# Publisher für stream1
|
||||||
|
- user: sha256:CCgj5KGMqEZgxOc5o4drwj9BVDg46eOLxuPMHhSRKAc=
|
||||||
|
pass: sha256:XohImNooBHFR0OVvjcYpJ3NgPQ1qq73WKhHvch0VQtg=
|
||||||
|
permissions:
|
||||||
|
- action: publish
|
||||||
|
path: stream1
|
||||||
|
|
||||||
|
# NEU: jeder darf lesen (HLS/RTMP/RTSP)
|
||||||
|
- user: any
|
||||||
|
permissions:
|
||||||
|
- action: read
|
||||||
|
path:
|
||||||
|
|
||||||
|
# --- Paths ---
|
||||||
paths:
|
paths:
|
||||||
stream1:
|
# keine Legacy-Creds mehr hier!
|
||||||
publishUser: ingest1
|
all: {}
|
||||||
publishPass: supersecret1
|
|
||||||
stream2:
|
|
||||||
publishUser: ingest2
|
|
||||||
publishPass: supersecret2
|
|
||||||
@@ -1,25 +1,112 @@
|
|||||||
async function load(){
|
(function () {
|
||||||
const r = await fetch('/api/streams');
|
|
||||||
const data = await r.json();
|
|
||||||
const q = (document.getElementById('filter').value||'').toLowerCase();
|
|
||||||
const list = document.getElementById('list');
|
const list = document.getElementById('list');
|
||||||
list.innerHTML = '';
|
const filter = document.getElementById('filter');
|
||||||
data.items
|
|
||||||
.filter(it => !q || it.name.toLowerCase().includes(q))
|
// --- wie in stream.js: mögliche Manifest-Dateien ausprobieren ---
|
||||||
.forEach(it => {
|
async function chooseManifest(name) {
|
||||||
const a = document.createElement('a');
|
const enc = encodeURIComponent(name);
|
||||||
a.href = '/' + encodeURIComponent(it.name);
|
const candidates = [
|
||||||
a.className = 'card';
|
`/hls/${enc}/index.m3u8`,
|
||||||
a.innerHTML = `
|
`/hls/${enc}/main_stream.m3u8`,
|
||||||
<div class="row space-between">
|
];
|
||||||
<div>
|
for (const url of candidates) {
|
||||||
<div class="title-strong">${it.name}</div>
|
try {
|
||||||
<div class="muted">Zuschauer: ${it.viewers}</div>
|
const r = await fetch(url, { cache: 'no-store' });
|
||||||
</div>
|
if (r.ok) return url;
|
||||||
<div class="pill ${it.live ? 'live':'off'}">${it.live ? 'LIVE' : 'Offline'}</div>
|
} catch (_) {}
|
||||||
</div>`;
|
}
|
||||||
list.appendChild(a);
|
return null;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
document.getElementById('filter').addEventListener('input', load);
|
// Kleinster gemeinsamer Nenner: API-Live ODER Manifest erreichbar
|
||||||
load(); setInterval(load, 3000);
|
const probeCache = new Map(); // vermeidet Doppel-Requests pro Render
|
||||||
|
|
||||||
|
async function probeLive(name) {
|
||||||
|
if (probeCache.has(name)) return probeCache.get(name);
|
||||||
|
const p = (async () => {
|
||||||
|
const url = await chooseManifest(name);
|
||||||
|
return !!url;
|
||||||
|
})();
|
||||||
|
probeCache.set(name, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const q = (filter.value || '').toLowerCase();
|
||||||
|
list.innerHTML = '';
|
||||||
|
data.items
|
||||||
|
.filter((it) => !q || it.name.toLowerCase().includes(q))
|
||||||
|
.forEach((it) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/' + encodeURIComponent(it.name);
|
||||||
|
a.className = 'card';
|
||||||
|
a.dataset.stream = it.name;
|
||||||
|
|
||||||
|
// Grundzustand: was die API sagt
|
||||||
|
const apiLive = !!it.live;
|
||||||
|
const pillClass = apiLive ? 'live' : 'off';
|
||||||
|
const pillText = apiLive ? 'LIVE' : 'Offline';
|
||||||
|
const viewers = (()=>{
|
||||||
|
const cands = [it.viewers, it.viewerCount, it.viewCount, it.watchers, it.clients, it.hls_viewers, it.stats && it.stats.viewers];
|
||||||
|
for (const v of cands){
|
||||||
|
const n = typeof v === 'string' ? parseInt(v,10) : v;
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
})();
|
||||||
|
a.innerHTML = `
|
||||||
|
<div class="row space-between">
|
||||||
|
<div>
|
||||||
|
<div class="title-strong">${it.name}</div>
|
||||||
|
<div class="muted">Zuschauer: ${viewers}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pill ${pillClass}" data-role="live-pill">${pillText}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
list.appendChild(a);
|
||||||
|
|
||||||
|
// Sofort im Hintergrund „echtes“ Live prüfen und ggf. überschreiben
|
||||||
|
probeLive(it.name).then((isLive) => {
|
||||||
|
if (isLive || apiLive) {
|
||||||
|
const pill = a.querySelector('[data-role="live-pill"]');
|
||||||
|
if (pill) {
|
||||||
|
pill.className = 'pill live';
|
||||||
|
pill.textContent = 'LIVE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter rendert jetzt wirklich neu (vorher: leerer Handler)
|
||||||
|
filter.addEventListener('input', () => render(last));
|
||||||
|
|
||||||
|
let last = { items: [] };
|
||||||
|
|
||||||
|
// Erst-Load (falls Server SSE erst später schickt)
|
||||||
|
(async function initialFetch() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/streams', { cache: 'no-store' });
|
||||||
|
if (r.ok) {
|
||||||
|
last = await r.json();
|
||||||
|
render(last);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Live-Updates via SSE (wie gehabt)
|
||||||
|
const es = new EventSource('/api/streams/events', { withCredentials: false });
|
||||||
|
es.addEventListener('update', (ev) => {
|
||||||
|
try {
|
||||||
|
last = JSON.parse(ev.data);
|
||||||
|
render(last);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sse parse', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.onerror = (e) => console.warn('sse error', e);
|
||||||
|
|
||||||
|
// Optionaler Reload-Button:
|
||||||
|
const btn = document.getElementById('reload');
|
||||||
|
if (btn) btn.addEventListener('click', () => render(last));
|
||||||
|
})();
|
||||||
|
|||||||
@@ -1,33 +1,145 @@
|
|||||||
(function(){
|
(function () {
|
||||||
const name = document.documentElement.dataset.stream;
|
const name = document.documentElement.dataset.stream;
|
||||||
const v = document.getElementById('v');
|
const v = document.getElementById('v');
|
||||||
const liveEl = document.getElementById('live');
|
const liveEl = document.getElementById('live');
|
||||||
const viewersEl = document.getElementById('viewers');
|
const viewersEl = document.getElementById('viewers');
|
||||||
const srcEl = document.getElementById('hlssrc');
|
const srcEl = document.getElementById('hlssrc');
|
||||||
|
|
||||||
function updateLive(live){
|
let playerLive = false; // <- neue Quelle der Wahrheit
|
||||||
liveEl.className = 'pill ' + (live ? 'live' : 'off');
|
|
||||||
liveEl.textContent = live ? 'LIVE' : 'Offline';
|
function setLive(on){
|
||||||
|
liveEl.className = 'pill ' + (on ? 'live' : 'off');
|
||||||
|
liveEl.textContent = on ? 'LIVE' : 'Offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(){
|
async function chooseManifest() {
|
||||||
const r = await fetch('/api/streams');
|
const enc = encodeURIComponent(name);
|
||||||
const d = await r.json();
|
const candidates = [
|
||||||
const it = d.items.find(x=>x.name===name);
|
`/hls/${enc}/index.m3u8`,
|
||||||
const live = !!(it && it.live);
|
`/hls/${enc}/main_stream.m3u8`,
|
||||||
updateLive(live);
|
];
|
||||||
viewersEl.textContent = 'Zuschauer: ' + (it ? it.viewers : 0);
|
for (const url of candidates) {
|
||||||
if(live){
|
try { const r = await fetch(url, { cache: 'no-store' }); if (r.ok) return url; } catch {}
|
||||||
const src = '/hls/'+encodeURIComponent(name);
|
}
|
||||||
srcEl.textContent = src;
|
return null;
|
||||||
if (window.Hls && Hls.isSupported()){
|
}
|
||||||
if(!window._hls){ window._hls = new Hls(); window._hls.attachMedia(v); }
|
|
||||||
window._hls.loadSource(src);
|
async function initPlayer() {
|
||||||
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
const url = await chooseManifest();
|
||||||
v.src = src;
|
if (!url) { playerLive = false; setLive(false); return false; }
|
||||||
|
|
||||||
|
srcEl.textContent = url;
|
||||||
|
|
||||||
|
try { v.muted = true; v.playsInline = true; } catch {}
|
||||||
|
|
||||||
|
if (window.Hls && Hls.isSupported()) {
|
||||||
|
if (!window._hls) {
|
||||||
|
window._hls = new Hls({ liveDurationInfinity: true });
|
||||||
|
window._hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
// Manifest geladen → sehr gutes Live-Signal
|
||||||
|
playerLive = true;
|
||||||
|
setLive(true);
|
||||||
|
});
|
||||||
|
window._hls.on(Hls.Events.ERROR, (_e, data) => {
|
||||||
|
console.warn('hls.js error', data);
|
||||||
|
// fatale Fehler → als offline markieren
|
||||||
|
if (data?.fatal) { playerLive = false; setLive(false); }
|
||||||
|
});
|
||||||
|
window._hls.attachMedia(v);
|
||||||
}
|
}
|
||||||
|
window._hls.loadSource(url);
|
||||||
|
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
v.src = url; // Safari/iOS
|
||||||
|
} else {
|
||||||
|
console.warn('HLS nicht unterstützt');
|
||||||
|
playerLive = false; setLive(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await v.play(); } catch(e){ /* Autoplay blockiert ist ok */ }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusätzliche Player-Signale
|
||||||
|
v.addEventListener('loadedmetadata', () => { playerLive = true; setLive(true); });
|
||||||
|
v.addEventListener('playing', () => { playerLive = true; setLive(true); });
|
||||||
|
v.addEventListener('error', () => { playerLive = false; setLive(false); });
|
||||||
|
|
||||||
|
function pickViewers(it){
|
||||||
|
// Häufige Feldnamen aus diversen Backends
|
||||||
|
const candidates = [
|
||||||
|
it.viewers,
|
||||||
|
it.viewerCount,
|
||||||
|
it.viewCount,
|
||||||
|
it.watchers,
|
||||||
|
it.clients,
|
||||||
|
it.hls_viewers,
|
||||||
|
it.stats && it.stats.viewers,
|
||||||
|
];
|
||||||
|
for (const v of candidates) {
|
||||||
|
const n = typeof v === 'string' ? parseInt(v, 10) : v;
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMeta(){
|
||||||
|
// API nur als Zusatz – überschreibt NIE ein „echtes“ playerLive=true
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/streams', { cache:'no-store' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
const it = d.items.find(x => x.name === name);
|
||||||
|
if (!it) return;
|
||||||
|
|
||||||
|
const apiLive = !!it.live;
|
||||||
|
const combinedLive = playerLive || apiLive;
|
||||||
|
setLive(combinedLive);
|
||||||
|
|
||||||
|
let viewers = pickViewers(it);
|
||||||
|
|
||||||
|
// Nur wenn API wirklich 0/fehlend meldet, aber der Player sicher läuft,
|
||||||
|
// zeigen wir "≥1" als sinnvollen Fallback:
|
||||||
|
if (playerLive && viewers === 0) {
|
||||||
|
viewersEl.textContent = 'Zuschauer: ≥1';
|
||||||
|
} else {
|
||||||
|
viewersEl.textContent = 'Zuschauer: ' + viewers;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Bei API-Fehler nichts überschreiben
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(); setInterval(refresh, 2500);
|
(async function boot(){
|
||||||
|
for (let i=0; i<10; i++){
|
||||||
|
const ok = await initPlayer();
|
||||||
|
if (ok) break;
|
||||||
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
|
}
|
||||||
|
refreshMeta();
|
||||||
|
//setInterval(refreshMeta, 2500);
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
const es = new EventSource('/api/streams/events');
|
||||||
|
es.addEventListener('update', (ev)=>{
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data);
|
||||||
|
const it = data.items.find(x=>x.name===name);
|
||||||
|
if (!it) return;
|
||||||
|
const apiLive = !!it.live;
|
||||||
|
const combinedLive = playerLive || apiLive;
|
||||||
|
setLive(combinedLive);
|
||||||
|
|
||||||
|
let viewers = pickViewers(it);
|
||||||
|
|
||||||
|
// Nur wenn API wirklich 0/fehlend meldet, aber der Player sicher läuft,
|
||||||
|
// zeigen wir "≥1" als sinnvollen Fallback:
|
||||||
|
if (playerLive && viewers === 0) {
|
||||||
|
viewersEl.textContent = 'Zuschauer: ≥1';
|
||||||
|
} else {
|
||||||
|
viewersEl.textContent = 'Zuschauer: ' + viewers;
|
||||||
|
}
|
||||||
|
} catch(e){ /* ignore */ }
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<h1 class="title-no-margin">🎬 Streams</h1>
|
<h1 class="title-no-margin">🎬 Streams</h1>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input id="filter" placeholder="Filter (z. B. stream1)">
|
<input id="filter" placeholder="Filter (z. B. stream1)">
|
||||||
<a href="/refresh" class="pill">Neu laden</a>
|
<button id="reload" class="pill" type="button">Neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">RTMP Ingest: <code>rtmp://HOST/<name></code> · HLS: <code>http(s)://HOST/hls/<name></code></p>
|
<p class="muted">RTMP Ingest: <code>rtmp://HOST/<name></code> · HLS: <code>http(s)://HOST/hls/<name></code></p>
|
||||||
|
|||||||
Reference in New Issue
Block a user