This commit is contained in:
@@ -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,57 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_ = mime.AddExtensionType(".css", "text/css")
|
_ = mime.AddExtensionType(".css", "text/css")
|
||||||
_ = mime.AddExtensionType(".js", "application/javascript")
|
_ = mime.AddExtensionType(".js", "application/javascript")
|
||||||
@@ -86,12 +138,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
|
||||||
@@ -140,12 +195,117 @@ 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
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, item{Name: p.Name, Live: p.Live(), Viewers: p.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()
|
||||||
|
|||||||
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=
|
||||||
|
|||||||
@@ -1,35 +1,42 @@
|
|||||||
async function load(){
|
(function(){
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/streams', { cache: 'no-store' });
|
|
||||||
if (!r.ok) throw new Error('api '+r.status);
|
|
||||||
data = await r.json();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('streams api error:', e);
|
|
||||||
// UI freundlich degradieren
|
|
||||||
document.getElementById('list').innerHTML =
|
|
||||||
'<div class="card"><div class="muted">Keine Daten (API-Fehler)</div></div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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)).forEach(it => {
|
|
||||||
const a = document.createElement('a');
|
function render(data){
|
||||||
a.href = '/' + encodeURIComponent(it.name);
|
const q = (filter.value||'').toLowerCase();
|
||||||
a.className = 'card';
|
list.innerHTML = '';
|
||||||
a.innerHTML = `
|
data.items
|
||||||
<div class="row space-between">
|
.filter(it => !q || it.name.toLowerCase().includes(q))
|
||||||
<div>
|
.forEach(it => {
|
||||||
<div class="title-strong">${it.name}</div>
|
const a = document.createElement('a');
|
||||||
<div class="muted">Zuschauer: ${it.viewers}</div>
|
a.href = '/' + encodeURIComponent(it.name);
|
||||||
</div>
|
a.className = 'card';
|
||||||
<div class="pill ${it.live ? 'live':'off'}">${it.live ? 'LIVE' : 'Offline'}</div>
|
a.innerHTML = `
|
||||||
</div>`;
|
<div class="row space-between">
|
||||||
list.appendChild(a);
|
<div>
|
||||||
|
<div class="title-strong">${it.name}</div>
|
||||||
|
<div class="muted">Zuschauer: ${it.viewers ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pill ${it.live ? 'live':'off'}">${it.live ? 'LIVE' : 'Offline'}</div>
|
||||||
|
</div>`;
|
||||||
|
list.appendChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.addEventListener('input', ()=>{/* re-render mit letztem snapshot */}
|
||||||
|
);
|
||||||
|
|
||||||
|
let last = {items:[]};
|
||||||
|
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);
|
||||||
document.getElementById('filter').addEventListener('input', load);
|
|
||||||
document.getElementById('reload').addEventListener('click', load);
|
// Optionaler Reload-Button:
|
||||||
load();
|
const btn = document.getElementById('reload');
|
||||||
setInterval(load, 3000);
|
if (btn) btn.addEventListener('click', ()=>render(last));
|
||||||
|
})();
|
||||||
|
|||||||
@@ -96,6 +96,23 @@
|
|||||||
await new Promise(r => setTimeout(r, 1200));
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
}
|
}
|
||||||
refreshMeta();
|
refreshMeta();
|
||||||
setInterval(refreshMeta, 2500);
|
//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 = it.viewers ?? 0;
|
||||||
|
if (combinedLive && viewers === 0) viewers = '≥1';
|
||||||
|
viewersEl.textContent = 'Zuschauer: ' + viewers;
|
||||||
|
} catch(e){ /* ignore */ }
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user