diff --git a/api/router.go b/api/router.go index 707988705..bdcfcc7d9 100755 --- a/api/router.go +++ b/api/router.go @@ -23,6 +23,7 @@ func ConfigRealtimeRouter(router *gin.RouterGroup) { // Register Channels RegisterLiveUpdateRealtimeChannel() RegisterLiveRunnerPageUpdateRealtimeChannel(daoWrapper) + RegisterReactionUpdateRealtimeChannel() RegisterRealtimeChatChannel() } diff --git a/api/stream.go b/api/stream.go index 3a34d28ff..519144417 100644 --- a/api/stream.go +++ b/api/stream.go @@ -32,6 +32,7 @@ const ( func configGinStreamRestRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) { routes := streamRoutes{daoWrapper} + reactionRoutes := StreamReactionRoutes{daoWrapper} stream := router.Group("/api/stream") { @@ -47,6 +48,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) diff --git a/api/stream_reactions.go b/api/stream_reactions.go new file mode 100644 index 000000000..51ff6c1e3 --- /dev/null +++ b/api/stream_reactions.go @@ -0,0 +1,367 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + "sync" + "time" + + "github.com/getsentry/sentry-go" + "github.com/gin-gonic/gin" + + "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" +) + +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 { + _ = c.Error(tools.RequestError{ + Status: http.StatusUnauthorized, + CustomMessage: "user not authenticated", + }) + return + } + + if err != nil { + _ = c.Error(tools.RequestError{ + Status: http.StatusInternalServerError, + CustomMessage: "course lookup failed", + Err: err, + }) + return + } + + if !user.IsEligibleToWatchCourse(course) { + _ = c.Error(tools.RequestError{ + Status: http.StatusForbidden, + CustomMessage: "user not eligible to watch course", + }) + return + } + + 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 { + 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() + + 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() + defer liveReactionListenerMutex.Unlock() + + 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) + } + } + } +} diff --git a/api/worker_grpc.go b/api/worker_grpc.go index d3f73f814..50db81ae8 100644 --- a/api/worker_grpc.go +++ b/api/worker_grpc.go @@ -8,10 +8,8 @@ import ( "fmt" "io" "net" - "net/http" "os" "path/filepath" - "regexp" "strings" "sync" "time" @@ -629,34 +627,6 @@ func (s server) NotifyTranscodingProgress(srv pb.FromWorker_NotifyTranscodingPro } } -// isHlsUrlOk checks if the given HLS URL is valid and accessible -// -//nolint:unused -func isHlsUrlOk(url string) bool { - r, err := http.Get(url) - if err != nil { - return false - } - all, err := io.ReadAll(r.Body) - if err != nil { - return false - } - re := regexp.MustCompile(`chunklist.*\.m3u8`) - x := re.Find(all) - if x == nil { - return false - } - y := strings.ReplaceAll(r.Request.URL.String(), "playlist.m3u8", string(x)) - get, err := http.Get(y) - if err != nil { - return false - } - if get.StatusCode == http.StatusNotFound { - return false - } - return true -} - func CreateStreamRequest(daoWrapper dao.DaoWrapper, stream model.Stream, course model.Course, workers []model.Worker, sourceType string, source string) { if source == "" { return diff --git a/cmd/tumlive/main.go b/cmd/tumlive/main.go index 92d453741..f0a84db54 100644 --- a/cmd/tumlive/main.go +++ b/cmd/tumlive/main.go @@ -111,6 +111,7 @@ func run(ctx context.Context) error { &model.Subtitles{}, &model.TranscodingFailure{}, &model.Email{}, + &model.StreamReaction{}, &model.Runner{}, ) if err != nil { @@ -199,7 +200,7 @@ func serveHttp(ctx context.Context, manager *runner_manager.Manager, camService } router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { - if param.StatusCode >= 400 && VersionTag == "development" { + if param.StatusCode >= 400 && VersionTag == "development" { return fmt.Sprintf("{\"service\": \"GIN\", \"time\": %s, \"status\": %d, \"client\": \"%s\", \"path\": \"%s\", \"agent\": %s}\n", param.TimeStamp.Format(time.DateTime), param.StatusCode, diff --git a/config.yaml b/config.yaml index 977f81b7f..47f757da3 100644 --- a/config.yaml +++ b/config.yaml @@ -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: + - 😊 + - 👍 + - 👎 + - 😢 diff --git a/dao/dao_base.go b/dao/dao_base.go index 6f1888853..35f90ba8c 100644 --- a/dao/dao_base.go +++ b/dao/dao_base.go @@ -36,6 +36,7 @@ type DaoWrapper struct { SubtitlesDao TranscodingFailureDao EmailDao + StreamReactionDao RunnerDao RunnerDao } @@ -64,6 +65,7 @@ func NewDaoWrapper() DaoWrapper { SubtitlesDao: NewSubtitlesDao(), TranscodingFailureDao: NewTranscodingFailureDao(), EmailDao: NewEmailDao(), + StreamReactionDao: NewStreamReactionDao(), RunnerDao: NewRunnerDao(), } } diff --git a/dao/stream-reaction.go b/dao/stream-reaction.go new file mode 100644 index 000000000..66f07bff9 --- /dev/null +++ b/dao/stream-reaction.go @@ -0,0 +1,91 @@ +package dao + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/TUM-Dev/gocast/model" +) + +//go:generate mockgen -source=stream-reaction.go -destination ../mock_dao/stream-reaction.go + +type StreamReactionDao interface { + // Get StreamReaction by ID + Get(context.Context, uint) (model.StreamReaction, error) + + // Create a new StreamReaction for the database + Create(context.Context, *model.StreamReaction) error + + // Delete a StreamReaction by id. + Delete(context.Context, uint) error + + GetByStream(context.Context, uint) ([]model.StreamReaction, error) + + GetByStreamWithinMinutes(context.Context, uint, uint) ([]model.StreamReaction, error) + + GetNumbersOfReactions(context.Context, uint) (map[string]int, error) + + GetLastReactionOfUser(context.Context, uint) (model.StreamReaction, error) +} + +type streamReactionDao struct { + db *gorm.DB +} + +type reactionCount struct { + Reaction string `json:"reaction"` + Count int `json:"count"` +} + +func NewStreamReactionDao() StreamReactionDao { + return streamReactionDao{db: DB} +} + +// Get a StreamReaction by id. +func (d streamReactionDao) Get(c context.Context, id uint) (res model.StreamReaction, err error) { + return res, d.db.WithContext(c).First(&res, id).Error +} + +// Create a StreamReaction. +func (d streamReactionDao) Create(c context.Context, it *model.StreamReaction) error { + return d.db.WithContext(c).Create(it).Error +} + +// Delete a StreamReaction by id. +func (d streamReactionDao) Delete(c context.Context, id uint) error { + return d.db.WithContext(c).Delete(&model.StreamReaction{}, id).Error +} + +// GetByStream gets a StreamReaction by stream. +func (d streamReactionDao) GetByStream(c context.Context, streamID uint) (res []model.StreamReaction, err error) { + return res, d.db.WithContext(c).Where("stream_id = ?", streamID).Find(&res).Error +} + +// GetByStream gets a StreamReaction by stream within the last ... minutes. +func (d streamReactionDao) GetByStreamWithinMinutes(c context.Context, streamID uint, minutes uint) (res []model.StreamReaction, err error) { + time_specified := time.Now().Add(-time.Duration(minutes) * time.Minute) + return res, d.db.WithContext(c).Where("stream_id = ? AND created_at > ?", streamID, time_specified).Find(&res).Error +} + +// GetNumbersOfReactions gets the number of reactions grouped by reactions for a stream. +func (d streamReactionDao) GetNumbersOfReactions(c context.Context, streamID uint) (map[string]int, error) { + var reactionCounts []reactionCount + err := d.db.WithContext(c).Model(&model.StreamReaction{}).Where("stream_id = ?", streamID).Group("reaction").Select("reaction, count(reaction) as count").Scan(&reactionCounts).Error + if err != nil { + return nil, err + } + + reactionMap := make(map[string]int) + for _, rc := range reactionCounts { + reactionMap[rc.Reaction] = rc.Count + } + + return reactionMap, err +} + +// GetLastReactionOfUser gets the last reaction of a user. +func (d streamReactionDao) GetLastReactionOfUser(c context.Context, userID uint) (res model.StreamReaction, err error) { + return res, d.db.WithContext(c).Where("user_id = ?", userID).Last(&res).Error +} diff --git a/go.mod b/go.mod index 0fdaeba4a..16991c6cb 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/TUM-Dev/gocast/worker v0.0.0-20251102182539-4e087e888c3c github.com/asticode/go-astisub v0.34.0 github.com/dgraph-io/ristretto/v2 v2.1.0 + github.com/getsentry/sentry-go v0.31.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/matthiasreumann/gomino v0.0.2 diff --git a/go.sum b/go.sum index ddb37f9ea..24269b7ff 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabstv/melody v1.0.2 h1:wAc9xFmBCBX3jimwtkml5Z5nfyfur5q5uW8DHmW5gK0= github.com/gabstv/melody v1.0.2/go.mod h1:fvJW9enOHGIN1E4nCuBZ3lIZxHiPbnMIIYEXcSKQq9o= +github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4= +github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY= github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI= github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= diff --git a/mock_dao/stream-reaction.go b/mock_dao/stream-reaction.go new file mode 100644 index 000000000..1ca568889 --- /dev/null +++ b/mock_dao/stream-reaction.go @@ -0,0 +1,109 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: streamReaction.go + +// Package mock_dao is a generated GoMock package. +package mock_dao + +import ( + context "context" + reflect "reflect" + + model "github.com/TUM-Dev/gocast/model" + "go.uber.org/mock/gomock" +) + +// MockStreamReactionDao is a mock of StreamReactionDao interface. +type MockStreamReactionDao struct { + ctrl *gomock.Controller + recorder *MockStreamReactionDaoMockRecorder +} + +// MockStreamReactionDaoMockRecorder is the mock recorder for MockStreamReactionDao. +type MockStreamReactionDaoMockRecorder struct { + mock *MockStreamReactionDao +} + +// NewMockStreamReactionDao creates a new mock instance. +func NewMockStreamReactionDao(ctrl *gomock.Controller) *MockStreamReactionDao { + mock := &MockStreamReactionDao{ctrl: ctrl} + mock.recorder = &MockStreamReactionDaoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStreamReactionDao) EXPECT() *MockStreamReactionDaoMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockStreamReactionDao) Create(arg0 context.Context, arg1 *model.StreamReaction) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockStreamReactionDaoMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockStreamReactionDao)(nil).Create), arg0, arg1) +} + +// Delete mocks base method. +func (m *MockStreamReactionDao) Delete(arg0 context.Context, arg1 uint) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockStreamReactionDaoMockRecorder) Delete(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockStreamReactionDao)(nil).Delete), arg0, arg1) +} + +// Get mocks base method. +func (m *MockStreamReactionDao) Get(arg0 context.Context, arg1 uint) (model.StreamReaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", arg0, arg1) + ret0, _ := ret[0].(model.StreamReaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockStreamReactionDaoMockRecorder) Get(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStreamReactionDao)(nil).Get), arg0, arg1) +} + +// GetByStream mocks base method. +func (m *MockStreamReactionDao) GetByStream(arg0 context.Context, arg1 uint) ([]model.StreamReaction, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByStream", arg0, arg1) + ret0, _ := ret[0].([]model.StreamReaction) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByStream indicates an expected call of GetByStream. +func (mr *MockStreamReactionDaoMockRecorder) GetByStream(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByStream", reflect.TypeOf((*MockStreamReactionDao)(nil).GetByStream), arg0, arg1) +} + +// GetNumbersOfReactions mocks base method. +func (m *MockStreamReactionDao) GetNumbersOfReactions(arg0 context.Context, arg1 uint) (map[string]int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNumbersOfReactions", arg0, arg1) + ret0, _ := ret[0].(map[string]int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNumbersOfReactions indicates an expected call of GetNumbersOfReactions. +func (mr *MockStreamReactionDaoMockRecorder) GetNumbersOfReactions(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNumbersOfReactions", reflect.TypeOf((*MockStreamReactionDao)(nil).GetNumbersOfReactions), arg0, arg1) +} diff --git a/model/stream-reaction.go b/model/stream-reaction.go new file mode 100644 index 000000000..a5e876a2b --- /dev/null +++ b/model/stream-reaction.go @@ -0,0 +1,18 @@ +package model + +import "gorm.io/gorm" + +// StreamReaction represents Reactions of users to a Stream. +type StreamReaction struct { + gorm.Model + + Reaction string `gorm:"not null" json:"reaction"` + StreamID uint `gorm:"not null" json:"streamID"` + UserID uint `gorm:"not null" json:"userID"` + // Name string `gorm:"column:name;type:text;not null;default:'unnamed'"` +} + +// TableName returns the name of the table for the StreamReaction model in the database. +func (*StreamReaction) TableName() string { + return "stream_reaction" +} diff --git a/pkg/campus/campusonline/campusonline.go b/pkg/campus/campusonline/campusonline.go index 94bf6958d..b2065c741 100644 --- a/pkg/campus/campusonline/campusonline.go +++ b/pkg/campus/campusonline/campusonline.go @@ -8,10 +8,11 @@ import ( "net/http" "regexp" + "golang.org/x/oauth2/clientcredentials" + genaccount "github.com/TUM-Dev/gocast/pkg/campus/campusonline/gen/account" gencourse "github.com/TUM-Dev/gocast/pkg/campus/campusonline/gen/course" "github.com/TUM-Dev/gocast/pkg/campus/model" - "golang.org/x/oauth2/clientcredentials" ) const ( diff --git a/tools/config.go b/tools/config.go index ed9e24801..561da5033 100644 --- a/tools/config.go +++ b/tools/config.go @@ -95,6 +95,11 @@ func initConfig() { if os.Getenv("DBHOST") != "" { Cfg.Db.Host = os.Getenv("DBHOST") } + if len(Cfg.AllowedReactions) > 0 { + logger.Debug("Allowed reactions", "reactions", Cfg.AllowedReactions) + } else { + logger.Warn("No allowed reactions configured") + } } type Config struct { @@ -173,11 +178,12 @@ type Config struct { Host string `yaml:"host"` ApiKey string `yaml:"apiKey"` } `yaml:"meili"` - VodURLTemplate string `yaml:"vodURLTemplate"` - CanonicalURL string `yaml:"canonicalURL"` - WikiURL string `yaml:"wikiURL"` - RtmpProxyURL string `yaml:"rtmpProxyURL"` - RtmpProxyService string `yaml:"rtmpProxyService"` + VodURLTemplate string `yaml:"vodURLTemplate"` + CanonicalURL string `yaml:"canonicalURL"` + WikiURL string `yaml:"wikiURL"` + RtmpProxyURL string `yaml:"rtmpProxyURL"` + RtmpProxyService string `yaml:"rtmpProxyService"` + AllowedReactions []string `yaml:"allowedReactions"` } type MailConfig struct { diff --git a/web/template/admin/lecture-live-management.gohtml b/web/template/admin/lecture-live-management.gohtml index 76eead59d..172ce5719 100644 --- a/web/template/admin/lecture-live-management.gohtml +++ b/web/template/admin/lecture-live-management.gohtml @@ -29,7 +29,7 @@ {{template "header" .IndexData.TUMLiveContext}} -
+

{{$stream.Name}}

@@ -38,7 +38,7 @@
-
+
@@ -46,7 +46,7 @@
-
+
@@ -133,6 +133,23 @@
+
+
+ + + +
+
+ + + {{if and $course.ChatEnabled $stream.ChatEnabled}}
+{{end}} \ No newline at end of file diff --git a/web/template/watch.gohtml b/web/template/watch.gohtml index 67d470e87..2cbf5eccb 100644 --- a/web/template/watch.gohtml +++ b/web/template/watch.gohtml @@ -480,7 +480,7 @@
-
+
{{if or (.IndexData.TUMLiveContext.User.IsAdminOfCourse .IndexData.TUMLiveContext.Course) .IndexData.IsAdmin}}
+ + {{if .IndexData.TUMLiveContext.Stream.LiveNow }} + + + + {{ end }} +
{{end}} @@ -527,6 +536,14 @@ {{template "actions" .}} + +
+ +
{{if and $course.ChatEnabled $stream.ChatEnabled}} {{end}} + +
+
+ {{template "stream-reactions" $stream}} +
+
{{ if not $stream.LiveNow }} diff --git a/web/ts/api/stream-reactions.ts b/web/ts/api/stream-reactions.ts new file mode 100644 index 000000000..777483ba4 --- /dev/null +++ b/web/ts/api/stream-reactions.ts @@ -0,0 +1,38 @@ +import { getData, postData } from "../global"; +import { get } from "../utilities/fetch-wrappers"; +import { Realtime, RealtimeMessageTypes } from "../socket"; + +// Function to add a reaction to a stream +export function addReaction(reaction: string, streamID: number) { + return postData(`/api/stream/${streamID}/reaction`, { reaction: reaction }); +} + +// Function to get all possible reactions for a stream +export function getAllowedReactions(streamID: number): Promise { + return get(`/api/stream/${streamID}/reaction/allowed`).then((data) => { + return data; + }); +} + +export const liveReactionListener = { + async init(streamId: string) { + await Realtime.get().subscribeChannel("reaction-update", this.handle); + await Realtime.get().send("reaction-update", { + type: RealtimeMessageTypes.RealtimeMessageTypeChannelMessage, + payload: { streamId: streamId }, + }); + }, + + handle(payload: object) { + if (payload["reaction"]) { + // TODO: Handle multiple parallel reactions + window.dispatchEvent(new CustomEvent("reactionupdate", { detail: { data: payload } })); + } else if (payload["percentages"]) { + window.dispatchEvent( + new CustomEvent("reactionupdatepercentages", { detail: { data: payload["percentages"] } }), + ); + } else { + console.log(payload); + } + }, +}; diff --git a/web/ts/entry/video.ts b/web/ts/entry/video.ts index 14cfca3a9..a5d59fa9e 100644 --- a/web/ts/entry/video.ts +++ b/web/ts/entry/video.ts @@ -9,3 +9,4 @@ export * from "../subtitle-search"; export * from "../components/video-sections"; // Lecture Units are currently not used, so we don't include them in the bundle at the moment export * from "../interval-updates"; +export * from "../api/stream-reactions"; diff --git a/web/ts/watch.ts b/web/ts/watch.ts index db75e3540..edd1ab04e 100644 --- a/web/ts/watch.ts +++ b/web/ts/watch.ts @@ -1,5 +1,6 @@ import { getPlayers } from "./TUMLiveVjs"; import { copyToClipboard, Time } from "./global"; +import { seekbarOverlay } from "./seekbar-overlay"; export enum SidebarState { Hidden = "hidden",