diff --git a/controllers/admin/viewers.go b/controllers/admin/viewers.go index 8aa93aaf8..9133f3da3 100644 --- a/controllers/admin/viewers.go +++ b/controllers/admin/viewers.go @@ -4,7 +4,11 @@ import ( "encoding/json" "net/http" + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core" + "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/metrics" + "github.com/owncast/owncast/models" log "github.com/sirupsen/logrus" ) @@ -17,3 +21,23 @@ func GetViewersOverTime(w http.ResponseWriter, r *http.Request) { log.Errorln(err) } } + +// GetActiveViewers returns currently connected clients. +func GetActiveViewers(w http.ResponseWriter, r *http.Request) { + c := core.GetActiveViewers() + viewers := []models.Viewer{} + for _, v := range c { + viewers = append(viewers, *v) + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(viewers); err != nil { + controllers.InternalErrorHandler(w, err) + } +} + +// ExternalGetActiveViewers returns currently connected clients. +func ExternalGetActiveViewers(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { + GetConnectedChatClients(w, r) +} diff --git a/controllers/hls.go b/controllers/hls.go index 41cbb550c..8b9651677 100644 --- a/controllers/hls.go +++ b/controllers/hls.go @@ -10,6 +10,7 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core" "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/utils" ) @@ -42,8 +43,8 @@ func HandleHLSRequest(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-mpegURL") // Use this as an opportunity to mark this viewer as active. - id := utils.GenerateClientIDFromRequest(r) - core.SetViewerIDActive(id) + viewer := models.GenerateViewerFromRequest(r) + core.SetViewerActive(&viewer) } else { cacheTime := utils.GetCacheDurationSecondsForPath(relativePath) w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(cacheTime)) diff --git a/controllers/ping.go b/controllers/ping.go index 2c125cca2..ae78c48ce 100644 --- a/controllers/ping.go +++ b/controllers/ping.go @@ -4,11 +4,11 @@ import ( "net/http" "github.com/owncast/owncast/core" - "github.com/owncast/owncast/utils" + "github.com/owncast/owncast/models" ) // Ping is fired by a client to show they are still an active viewer. func Ping(w http.ResponseWriter, r *http.Request) { - id := utils.GenerateClientIDFromRequest(r) - core.SetViewerIDActive(id) + viewer := models.GenerateViewerFromRequest(r) + core.SetViewerActive(&viewer) } diff --git a/core/stats.go b/core/stats.go index 4df3dc403..dcefd1852 100644 --- a/core/stats.go +++ b/core/stats.go @@ -8,12 +8,14 @@ import ( log "github.com/sirupsen/logrus" "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/geoip" "github.com/owncast/owncast/models" ) var ( l = &sync.RWMutex{} _activeViewerPurgeTimeout = time.Second * 15 + _geoIPClient = geoip.NewClient() ) func setupStats() error { @@ -63,30 +65,45 @@ func RemoveChatClient(clientID string) { l.Unlock() } -// SetViewerIDActive sets a client as active and connected. -func SetViewerIDActive(id string) { +// SetViewerActive sets a client as active and connected. +func SetViewerActive(viewer *models.Viewer) { + // Don't update viewer counts if a live stream session is not active. + if !_stats.StreamConnected { + return + } + l.Lock() defer l.Unlock() - _stats.Viewers[id] = time.Now() + // Asynchronously, optionally, fetch GeoIP data. + go func(viewer *models.Viewer) { + viewer.Geo = _geoIPClient.GetGeoFromIP(viewer.IPAddress) + }(viewer) - // Don't update viewer counts if a live stream session is not active. - if _stats.StreamConnected { - _stats.SessionMaxViewerCount = int(math.Max(float64(len(_stats.Viewers)), float64(_stats.SessionMaxViewerCount))) - _stats.OverallMaxViewerCount = int(math.Max(float64(_stats.SessionMaxViewerCount), float64(_stats.OverallMaxViewerCount))) + if _, exists := _stats.Viewers[viewer.ClientID]; exists { + _stats.Viewers[viewer.ClientID].LastSeen = time.Now() + } else { + _stats.Viewers[viewer.ClientID] = viewer } + _stats.SessionMaxViewerCount = int(math.Max(float64(len(_stats.Viewers)), float64(_stats.SessionMaxViewerCount))) + _stats.OverallMaxViewerCount = int(math.Max(float64(_stats.SessionMaxViewerCount), float64(_stats.OverallMaxViewerCount))) +} + +// GetActiveViewers will return the active viewers. +func GetActiveViewers() map[string]*models.Viewer { + return _stats.Viewers } func pruneViewerCount() { - viewers := make(map[string]time.Time) + viewers := make(map[string]*models.Viewer) l.Lock() defer l.Unlock() - for viewerID := range _stats.Viewers { - viewerLastSeenTime := _stats.Viewers[viewerID] + for viewerID, viewer := range _stats.Viewers { + viewerLastSeenTime := _stats.Viewers[viewerID].LastSeen if time.Since(viewerLastSeenTime) < _activeViewerPurgeTimeout { - viewers[viewerID] = viewerLastSeenTime + viewers[viewerID] = viewer } } @@ -112,7 +129,7 @@ func getSavedStats() models.Stats { result := models.Stats{ ChatClients: make(map[string]models.Client), - Viewers: make(map[string]time.Time), + Viewers: make(map[string]*models.Viewer), SessionMaxViewerCount: data.GetPeakSessionViewerCount(), OverallMaxViewerCount: data.GetPeakOverallViewerCount(), LastDisconnectTime: savedLastDisconnectTime, diff --git a/models/stats.go b/models/stats.go index bfba4a2f4..ead849d48 100644 --- a/models/stats.go +++ b/models/stats.go @@ -1,8 +1,6 @@ package models import ( - "time" - "github.com/owncast/owncast/utils" ) @@ -12,8 +10,8 @@ type Stats struct { OverallMaxViewerCount int `json:"overallMaxViewerCount"` LastDisconnectTime *utils.NullTime `json:"lastDisconnectTime"` - StreamConnected bool `json:"-"` - LastConnectTime *utils.NullTime `json:"-"` - ChatClients map[string]Client `json:"-"` - Viewers map[string]time.Time `json:"-"` + StreamConnected bool `json:"-"` + LastConnectTime *utils.NullTime `json:"-"` + ChatClients map[string]Client `json:"-"` + Viewers map[string]*Viewer `json:"-"` } diff --git a/models/viewer.go b/models/viewer.go new file mode 100644 index 000000000..49dc7afdc --- /dev/null +++ b/models/viewer.go @@ -0,0 +1,30 @@ +package models + +import ( + "net/http" + "time" + + "github.com/owncast/owncast/geoip" + "github.com/owncast/owncast/utils" +) + +// Viewer represents a single video viewer. +type Viewer struct { + FirstSeen time.Time `json:"firstSeen"` + LastSeen time.Time `json:"-"` + UserAgent string `json:"userAgent"` + IPAddress string `json:"ipAddress"` + ClientID string `json:"clientID"` + Geo *geoip.GeoDetails `json:"geo"` +} + +// GenerateViewerFromRequest will return a chat client from a http request. +func GenerateViewerFromRequest(req *http.Request) Viewer { + return Viewer{ + FirstSeen: time.Now(), + LastSeen: time.Now(), + UserAgent: req.UserAgent(), + IPAddress: utils.GetIPAddressFromRequest(req), + ClientID: utils.GenerateClientIDFromRequest(req), + } +} diff --git a/router/router.go b/router/router.go index b18cf6704..57ff57bf5 100644 --- a/router/router.go +++ b/router/router.go @@ -97,6 +97,9 @@ func Start() error { // Get viewer count over time http.HandleFunc("/api/admin/viewersOverTime", middleware.RequireAdminAuth(admin.GetViewersOverTime)) + // Get active viewers + http.HandleFunc("/api/admin/viewers", middleware.RequireAdminAuth(admin.GetActiveViewers)) + // Get hardware stats http.HandleFunc("/api/admin/hardwarestats", middleware.RequireAdminAuth(admin.GetHardwareStats))