Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6d4277b
Added additional management page
SebiWrn Oct 8, 2024
f16b1b2
Added livestream and course/lecture name to management page
SebiWrn Oct 9, 2024
e10d5b9
Added Video Stats
SebiWrn Oct 10, 2024
bcafd91
Added stats and parts of the chat
SebiWrn Oct 10, 2024
bfefdc3
Changed text color of stats
SebiWrn Nov 10, 2024
8e57dee
Moved chat to the right
SebiWrn Nov 10, 2024
fff7704
Fixed chat and added Seek to Live Button
SebiWrn Nov 10, 2024
94d51c9
Fixed layout of seek button
SebiWrn Nov 10, 2024
d9a9261
Added function to use highest quality
SebiWrn Nov 10, 2024
3eed1a9
Minor fixes
SebiWrn Nov 10, 2024
c06c2f8
Added highest quality automatically and added Remaining live time
SebiWrn Nov 10, 2024
ec6e2a3
Added current time to lecture live page
SebiWrn Nov 10, 2024
e0d0722
Added reactions to model and added endpoints to add reactions
SebiWrn Nov 10, 2024
878fec4
Added reactions to watch page
SebiWrn Nov 12, 2024
1597514
Removed reactions from lecture-management
SebiWrn Nov 12, 2024
e45b5c4
Renamed stream reactions
SebiWrn Nov 14, 2024
c92b347
Added stream reaction websocket
SebiWrn Nov 14, 2024
e69c80e
Added functionality to send reaction to admin
SebiWrn Nov 14, 2024
07da05b
Added admin check
SebiWrn Nov 14, 2024
d2e10c9
Fixed kernel panic
SebiWrn Nov 14, 2024
7408bbd
Added reactions to typescript
SebiWrn Nov 14, 2024
dabe712
Reactions now show up on admin panel
SebiWrn Nov 14, 2024
d803fb2
Fixed opacity bug
SebiWrn Nov 14, 2024
bec3075
Added percentage to the reactions in admin screen
SebiWrn Apr 8, 2025
7dcd7de
Added todo
SebiWrn Apr 8, 2025
03faf0c
Added button to go to live management
SebiWrn Apr 17, 2025
e602b32
Some minor design changes
SebiWrn Apr 17, 2025
5a25136
Removed debugging information
SebiWrn Apr 17, 2025
1d39bf0
Live lecture management page is only usable if stream is live
SebiWrn Nov 10, 2024
3f52e95
Changed chat layout
SebiWrn Nov 10, 2024
4c02557
Added Restart and Stop button for stream
SebiWrn Nov 12, 2024
b905cdb
Some minor changes
SebiWrn Nov 12, 2024
b3b6957
eslint fix
SebiWrn Apr 17, 2025
f84d02a
Fixed watch.ts
SebiWrn Apr 17, 2025
297bd09
eslint fix 2
SebiWrn Apr 17, 2025
09733ab
eslint fix
SebiWrn Apr 17, 2025
ddaab5b
Gofumpted
SebiWrn Apr 17, 2025
377084f
Some lint fixes
SebiWrn Apr 17, 2025
a6c16d7
Small api change so gocast does not crash
SebiWrn May 11, 2025
c4d58b1
Some fixes
SebiWrn Jun 18, 2025
9d4356f
Minor fixes after merge
SebiWrn Oct 16, 2025
8fbd965
golangci-lint
SebiWrn Oct 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func ConfigRealtimeRouter(router *gin.RouterGroup) {
// Register Channels
RegisterLiveUpdateRealtimeChannel()
RegisterLiveRunnerPageUpdateRealtimeChannel(daoWrapper)
RegisterReactionUpdateRealtimeChannel()
RegisterRealtimeChatChannel()
}

Expand Down
4 changes: 4 additions & 0 deletions api/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (

func configGinStreamRestRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {
routes := streamRoutes{daoWrapper}
reactionRoutes := StreamReactionRoutes{daoWrapper}

stream := router.Group("/api/stream")
{
Expand All @@ -46,6 +47,9 @@ func configGinStreamRestRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {

streamById.GET("/playlist", routes.getStreamPlaylist)

streamById.POST("/reaction", reactionRoutes.addReaction)
streamById.GET("/reaction/allowed", reactionRoutes.allowedReactions)

thumbs := streamById.Group("/thumbs")
{
thumbs.GET(":fid", routes.getThumbs)
Expand Down
356 changes: 356 additions & 0 deletions api/stream_reactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
package api

import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
"sync"
"time"

"github.com/TUM-Dev/gocast/dao"
"github.com/TUM-Dev/gocast/model"
"github.com/TUM-Dev/gocast/tools"
"github.com/TUM-Dev/gocast/tools/realtime"
"github.com/getsentry/sentry-go"
"github.com/gin-gonic/gin"
)

type StreamReactionRoutes struct {
dao.DaoWrapper
}

// TODO: This can be modified to allow different reactions for different streams
func (r StreamReactionRoutes) allowedReactions(c *gin.Context) {
c.JSON(http.StatusOK, tools.Cfg.AllowedReactions)
}

func (r StreamReactionRoutes) addReaction(c *gin.Context) {
tumLiveContext := c.MustGet("TUMLiveContext").(tools.TUMLiveContext)
user := tumLiveContext.User
stream := tumLiveContext.Stream

if stream == nil {
_ = c.Error(tools.RequestError{
Status: http.StatusNotFound,
CustomMessage: "stream not found",
})
return
}

course, err := r.DaoWrapper.CoursesDao.GetCourseById(c, stream.CourseID)

if user == nil || err != nil {
_ = c.Error(tools.RequestError{
Status: http.StatusInternalServerError,
CustomMessage: "user or course not found",
})
return
}

if !user.IsEligibleToWatchCourse(course) {
_ = c.Error(tools.RequestError{
Status: http.StatusForbidden,
CustomMessage: "user not eligible to watch course",
})
return
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: these three conditionals seem like they could be a separate function that could be reused?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will leave them there for now and they can be extracted if we need them somewhere else


type reactionRequest struct {
Reaction string `json:"reaction"`
}

var reaction reactionRequest
if err := c.ShouldBindJSON(&reaction); err != nil {
_ = c.Error(tools.RequestError{
Status: http.StatusBadRequest,
CustomMessage: "can not bind body",
Err: err,
})
return
}

// This can be modified to allow different reactions for different streams
if !slices.Contains(tools.Cfg.AllowedReactions, reaction.Reaction) {
_ = c.Error(tools.RequestError{
Status: http.StatusBadRequest,
CustomMessage: "reaction not allowed",
})
return
}

lastReaction, _ := r.DaoWrapper.StreamReactionDao.GetLastReactionOfUser(c, user.ID)
// This contains the cooldown logic, to change this value change the time.Duration(10) to the desired cooldown time
if lastReaction.Reaction != "" && lastReaction.CreatedAt.Add(time.Duration(10)*time.Second).After(time.Now()) {
_ = c.Error(tools.RequestError{
Status: http.StatusTooManyRequests,
CustomMessage: "cooldown not over",
})
return
}

reactionObj := model.StreamReaction{
Reaction: reaction.Reaction,
StreamID: stream.ID,
UserID: user.ID,
}

err = r.DaoWrapper.StreamReactionDao.Create(c, &reactionObj)
if err != nil {
_ = c.Error(tools.RequestError{
Status: http.StatusInternalServerError,
CustomMessage: "can not create reaction",
Err: err,
})
return
}
NotifyAdminsOnReaction(stream.ID, reaction.Reaction)
c.JSON(http.StatusOK, "")
}

// The part below is used for Realtime Connection to the client

const (
ReactionUpdateRoomName = "reaction-update"
)

var (
liveReactionListenerMutex sync.RWMutex
liveReactionListener = map[uint]*liveReactionAdminSessionsWrapper{}
)

type liveReactionAdminSessionsWrapper struct {
sessions []*realtime.Context
stream uint
}

func RegisterReactionUpdateRealtimeChannel() {
RealtimeInstance.RegisterChannel(ReactionUpdateRoomName, realtime.ChannelHandlers{
OnSubscribe: reactionUpdateOnSubscribe,
OnUnsubscribe: reactionUpdateOnUnsubscribe,
OnMessage: reactionUpdateSetStream,
})

go func() {
// Notify admins every 5 seconds
logger.Info("Starting periodic notification of reaction percentages")
for {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a leaking goroutine? You never end it or cancel it via a context?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be ok as this is only started once at the initialize and it will be stopped when the program stops.

time.Sleep(5 * time.Second)
NotifyAdminsOnReactionPercentages(context.Background())
}
}()
}

func reactionUpdateOnUnsubscribe(psc *realtime.Context) {
logger.Debug("Unsubscribing from reaction Update")
ctx, _ := psc.Client.Get("ctx") // get gin context
foundContext, exists := ctx.(*gin.Context).Get("TUMLiveContext")
if !exists {
sentry.CaptureException(errors.New("context should exist but doesn't"))
return
}

tumLiveContext := foundContext.(tools.TUMLiveContext)

var userId uint
if tumLiveContext.User != nil {
userId = tumLiveContext.User.ID
}

liveReactionListenerMutex.Lock()
defer liveReactionListenerMutex.Unlock()
var newSessions []*realtime.Context
for _, session := range liveReactionListener[userId].sessions {
if session != psc {
newSessions = append(newSessions, session)
}
}
if len(newSessions) == 0 {
delete(liveReactionListener, userId)
} else {
liveReactionListener[userId].sessions = newSessions
}
logger.Debug("Successfully unsubscribed from reaction Update")
}

func reactionUpdateOnSubscribe(psc *realtime.Context) {
ctx, _ := psc.Client.Get("ctx") // get gin context

foundContext, exists := ctx.(*gin.Context).Get("TUMLiveContext")
if !exists {
sentry.CaptureException(errors.New("context should exist but doesn't"))
return
}

tumLiveContext := foundContext.(tools.TUMLiveContext)

var userId uint
var err error

if tumLiveContext.User != nil {
userId = tumLiveContext.User.ID
} else {
logger.Error("could not fetch public courses", "err", err)
return

}

liveReactionListenerMutex.Lock()
defer liveReactionListenerMutex.Unlock()
existing := liveReactionListener[userId]
if existing != nil {
liveReactionListener[userId] = &liveReactionAdminSessionsWrapper{append(existing.sessions, psc), liveReactionListener[userId].stream}
} else {
liveReactionListener[userId] = &liveReactionAdminSessionsWrapper{[]*realtime.Context{psc}, 0}
}
}

func reactionUpdateSetStream(psc *realtime.Context, message *realtime.Message) {
logger.Info("reactionUpdateSetStream", "message", string(message.Payload))
ctx, _ := psc.Client.Get("ctx") // get gin context

foundContext, exists := ctx.(*gin.Context).Get("TUMLiveContext")
if !exists {
sentry.CaptureException(errors.New("context should exist but doesn't"))
return
}

tumLiveContext := foundContext.(tools.TUMLiveContext)

var userId uint
var err error

if tumLiveContext.User != nil {
userId = tumLiveContext.User.ID
} else {
logger.Error("could not get user from request", "err", err)
return
}

type Message struct {
StreamID string `json:"streamId"`
}

var messageObj Message
err = json.Unmarshal(message.Payload, &messageObj)
if err != nil {
logger.Error("could not unmarshal message", "err", err)
return
}

stream, err := daoWrapper.StreamsDao.GetStreamByID(context.TODO(), messageObj.StreamID)
if err != nil {
logger.Error("Cant get stream by id", "err", err)
return
}
course, err := daoWrapper.CoursesDao.GetCourseById(context.TODO(), stream.CourseID)
if err != nil {
logger.Error("Cant get course by id", "err", err)
return
}
if !tumLiveContext.User.IsAdminOfCourse(course) {
logger.Error("User is not admin of course")
reactionUpdateOnUnsubscribe(psc)
return
}

liveReactionListenerMutex.Lock()
defer liveReactionListenerMutex.Unlock()
if liveReactionListener[userId] != nil {
uId, err := strconv.Atoi(messageObj.StreamID)
if err != nil {
logger.Error("could not convert streamID to int", "err", err)
return
}
liveReactionListener[userId].stream = uint(uId)
} else {
logger.Error("User has no live reaction listener")
}
}

func NotifyAdminsOnReaction(streamID uint, reaction string) {
liveReactionListenerMutex.Lock()
defer liveReactionListenerMutex.Unlock()
reactionStruct := struct {
Reaction string `json:"reaction"`
}{
Reaction: reaction,
}
reactionMarshaled, err := json.Marshal(reactionStruct)
if err != nil {
logger.Error("could not marshal reaction", "err", err)
return
}
for _, session := range liveReactionListener {
if session.stream == streamID {
for _, s := range session.sessions {
err := s.Send(reactionMarshaled)
if err != nil {
logger.Error("can't write reaction to session", "err", err)
}
}
}
}
}

func NotifyAdminsOnReactionPercentages(context context.Context) {
liveReactionListenerMutex.Lock()
defer liveReactionListenerMutex.Unlock()
streams := make([]uint, 0)
for _, session := range liveReactionListener {
streams = append(streams, session.stream)
}
liveReactionListenerMutex.Unlock()

streamReactionPercentages := map[uint]map[string]float64{}

for _, stream := range streams {
reactionsRaw, err := daoWrapper.StreamReactionDao.GetByStreamWithinMinutes(context, stream, 2) // TODO: Make this variable for the lecturer
if err != nil {
logger.Error("could not get reactions for stream", "stream", stream, "err", err)
return
}

reactions := make(map[string]int)
for _, reaction := range reactionsRaw {
reactions[reaction.Reaction]++
}

totalReactions := 0
for _, count := range reactions {
totalReactions += count
}
if totalReactions == 0 {
// logger.Debug("no reactions for stream", "stream", stream)
continue
}

streamReactionPercentages[stream] = make(map[string]float64)
for reaction, count := range reactions {
streamReactionPercentages[stream][reaction] = float64(count) / float64(totalReactions)
}
}

// Send the percentages to the admin sessions
liveReactionListenerMutex.Lock()

for _, session := range liveReactionListener {
if session.stream == 0 {
continue
}
reactionPercentages := streamReactionPercentages[session.stream]
reactionPercentagesMarshaled, err := json.Marshal(reactionPercentages)
if err != nil {
logger.Error("could not marshal reaction percentages", "err", err)
return
}
for _, s := range session.sessions {
err := s.Send([]byte("{\"percentages\": " + string(reactionPercentagesMarshaled) + "}"))
if err != nil {
logger.Error("can't write reaction percentages to session", "err", err)
}
}
}
}
1 change: 1 addition & 0 deletions cmd/tumlive/tumlive.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ func main() {
&model.Subtitles{},
&model.TranscodingFailure{},
&model.Email{},
&model.StreamReaction{},
&model.Runner{},
)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,8 @@ meili:
vodURLTemplate: https://stream.lrz.de/vod/_definst_/mp4:tum/RBG/%s.mp4/playlist.m3u8
canonicalURL: https://tum.live
rtmpProxyURL: https://proxy.example.com
allowedReactions:
- 😊
- 👍
- 👎
- 😢
Loading
Loading