diff --git a/db/channels.go b/db/channels.go index a2caac2..999ba1c 100644 --- a/db/channels.go +++ b/db/channels.go @@ -73,3 +73,20 @@ func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string return subs, nil } + +var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)` + +func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error { + _, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID) + if err != nil { + if pgerr, ok := err.(*pq.Error); ok { + if pgerr.Code == "23505" { + return ErrAlreadySubscribed + } + } + d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err) + return err + } + + return nil +} diff --git a/db/db.go b/db/db.go index 25efe7a..41d8c14 100644 --- a/db/db.go +++ b/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "errors" + "time" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) @@ -11,14 +12,17 @@ var ( ErrChannelExists = errors.New("channel already exists") ErrAlreadySubscribed = errors.New("already subscribed to channel") ErrVideoExists = errors.New("video already exists") + ErrUserExists = errors.New("user already exists") ) // DB represents a database layer for getting video and channel data type DB interface { - // GetNewVideos returns unwatched videos from all channels - GetNewVideos(ctx context.Context, username string) ([]models.Video, error) - // CreateVideo adds a newly published video to the database - CreateVideo(ctx context.Context, video models.Video, channelID string) error + // AuthenticateUser verifies a user's password against a hashed value + AuthenticateUser(ctx context.Context, user models.User) (bool, error) + // CreateUser registers a new user in the database + CreateUser(ctx context.Context, user models.User) error + // DeleteUser registers a new user in the database + DeleteUser(ctx context.Context, username string) error // CreateChannel starts tracking a new channel and fetch new videos for it CreateChannel(ctx context.Context, channel models.Channel) error @@ -26,17 +30,17 @@ type DB interface { ListChannels(ctx context.Context) ([]models.Channel, error) // GetChannelSubscribers lists all channels from the database GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) - - // AuthenticateUser verifies a user's password against a hashed value - AuthenticateUser(ctx context.Context, user models.User) (bool, error) - // CreateUser registers a new user in the database - CreateUser(ctx context.Context, user models.User) error - // DeleteUser registers a new user in the database - DeleteUser(ctx context.Context, username string) error // SubscribeUserToChannel will start showing new videos for that channel to the user SubscribeUserToChannel(ctx context.Context, username string, channelID string) error + + // GetNewVideos returns a list of unwatched videos from all subscribed channels + GetNewVideos(ctx context.Context, username string) ([]models.Video, error) + // GetWatchedVideos returns a list of all watched videos for a user + GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) + // AddVideo adds a newly published video to the database + AddVideo(ctx context.Context, video models.Video, channelID string) error // AddVideoToUser will list the video in the users feed AddVideoToUser(ctx context.Context, username string, videoID string) error - // WatchVideo marks a video as watched so it no longer shows in the feed - WatchVideo(ctx context.Context, username string, videoID string) error + // SetVideoWatchTime sets or unsets the watch timestamp of a user's video + SetVideoWatchTime(ctx context.Context, username string, videoID string, watchTime *time.Time) error } diff --git a/db/users.go b/db/users.go index 740b48e..64bb9f5 100644 --- a/db/users.go +++ b/db/users.go @@ -2,6 +2,8 @@ package db import ( "context" + "database/sql" + "errors" "github.com/alexedwards/argon2id" "github.com/lib/pq" @@ -16,6 +18,10 @@ func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bo var hashedPassword string err := row.Scan(&hashedPassword) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err) return false, err } @@ -34,6 +40,11 @@ var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)` func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error { _, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password) if err != nil { + if pgerr, ok := err.(*pq.Error); ok { + if pgerr.Code == "23505" { + return ErrUserExists + } + } d.l.Log("level", "ERROR", "function", "db.CreateUser", "error", err) return err } @@ -52,44 +63,3 @@ func (d *postgresDB) DeleteUser(ctx context.Context, username string) error { return nil } - -var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)` - -func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error { - _, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID) - if err != nil { - if pgerr, ok := err.(*pq.Error); ok { - if pgerr.Code == "23505" { - return ErrAlreadySubscribed - } - } - d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err) - return err - } - - return nil -} - -var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)` - -func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error { - _, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID) - if err != nil { - d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err) - return err - } - - return nil -} - -var watchVideoQuery = `UPDATE user_videos SET watch_timestamp = NOW() WHERE username = $2 AND video_id = $3` - -func (d *postgresDB) WatchVideo(ctx context.Context, username string, videoID string) error { - _, err := d.db.ExecContext(ctx, watchVideoQuery, username, videoID) - if err != nil { - d.l.Log("level", "ERROR", "function", "db.WatchVideo", "error", err) - return err - } - - return nil -} diff --git a/db/videos.go b/db/videos.go index 47f1cb1..35acba5 100644 --- a/db/videos.go +++ b/db/videos.go @@ -2,6 +2,7 @@ package db import ( "context" + "time" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "github.com/lib/pq" @@ -52,17 +53,88 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]model return videos, nil } -var createVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)` +var getWatchedVideosQuery = ` + SELECT + video_id + , title + , published_timestamp + , watch_timestamp + , name as channel_name + FROM user_videos + LEFT JOIN videos ON video_id=videos.id + LEFT JOIN channels ON channel_id=channels.id + WHERE + 1=1 + AND watch_timestamp IS NOT NULL + AND username=$1 + ORDER BY watch_timestamp ASC +` -func (d *postgresDB) CreateVideo(ctx context.Context, video models.Video, channelID string) error { - _, err := d.db.ExecContext(ctx, createVideoQuery, video.ID, video.Title, video.PublishedTime, channelID) +func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) { + rows, err := d.db.QueryContext(ctx, getWatchedVideosQuery, username) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.GetWatchedVideos", "call", "sql.QueryContext", "error", err) + return nil, err + } + defer rows.Close() + + videos := make([]models.Video, 0) + for rows.Next() { + var video models.Video + err = rows.Scan( + &video.ID, + &video.Title, + &video.PublishedTime, + &video.WatchTime, + &video.ChannelName, + ) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.GetWatchedVideos", "call", "sql.Scan", "error", err) + return nil, err + } + videos = append(videos, video) + } + + return videos, nil +} + +var addVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)` + +func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string) error { + _, err := d.db.ExecContext(ctx, addVideoQuery, video.ID, video.Title, video.PublishedTime, channelID) if err != nil { if pgerr, ok := err.(*pq.Error); ok { if pgerr.Code == "23505" { return ErrVideoExists } } - d.l.Log("level", "ERROR", "function", "db.CreateVideo", "call", "sql.Exec", "error", err) + d.l.Log("level", "ERROR", "function", "db.AddVideo", "call", "sql.Exec", "error", err) + return err + } + + return nil +} + +var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)` + +func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error { + _, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err) + return err + } + + return nil +} + +var setVideoWatchTimeQuery = `UPDATE user_videos SET watch_timestamp = $1 WHERE username = $2 AND video_id = $3` + +func (d *postgresDB) SetVideoWatchTime( + ctx context.Context, username string, videoID string, watchTime *time.Time, +) error { + _, err := d.db.ExecContext(ctx, setVideoWatchTimeQuery, watchTime, username, videoID) + if err != nil { + d.l.Log("level", "ERROR", "function", "db.WatchVideo", "error", err) return err } diff --git a/handler/channels.go b/handler/channels.go new file mode 100644 index 0000000..eebcead --- /dev/null +++ b/handler/channels.go @@ -0,0 +1,29 @@ +package handler + +import ( + "context" + "errors" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error { + parsedChannel, err := feedparser.Parse(h.log, channelID) + if err != nil { + return err + } + + channel := models.Channel{ + ID: channelID, + Name: parsedChannel.Name, + } + + err = h.db.CreateChannel(ctx, channel) + if err != nil && !errors.Is(err, db.ErrChannelExists) { + return err + } + + return h.db.SubscribeUserToChannel(ctx, username, channelID) +} diff --git a/handler/handler.go b/handler/handler.go index cac613e..f8f82ae 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -2,13 +2,8 @@ package handler import ( "context" - "errors" - "strings" - - "github.com/alexedwards/argon2id" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" - "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) @@ -17,7 +12,10 @@ type Handler interface { CreateUser(ctx context.Context, user models.User) error SubscribeToChannel(ctx context.Context, username string, channelID string) error GetNewVideos(ctx context.Context, username string) ([]models.Video, error) + GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) FetchVideos(ctx context.Context) error + MarkVideoAsWatched(ctx context.Context, username string, videoID string) error + MarkVideoAsUnwatched(ctx context.Context, username string, videoID string) error } type handler struct { @@ -28,100 +26,3 @@ type handler struct { func New(log log.Logger, db db.DB) *handler { return &handler{log: log, db: db} } - -func (h *handler) CreateUser(ctx context.Context, user models.User) error { - hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams) - if err != nil { - return err - } - user.Password = hashedPassword - - return h.db.CreateUser(ctx, user) -} - -func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error { - parsedChannel, err := feedparser.Parse(h.log, channelID) - if err != nil { - return err - } - - channel := models.Channel{ - ID: channelID, - Name: parsedChannel.Name, - } - - err = h.db.CreateChannel(ctx, channel) - if !errors.Is(err, db.ErrChannelExists) { - return err - } - - return h.db.SubscribeUserToChannel(ctx, username, channelID) -} - -func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) { - return h.db.GetNewVideos(ctx, username) -} - -func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error { - subs, err := h.db.GetChannelSubscribers(ctx, channelID) - if err != nil { - h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err) - return err - } - - for _, sub := range subs { - err = h.db.AddVideoToUser(ctx, sub, videoID) - if err != nil { - h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err) - continue - } - } - - return nil -} - -func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) { - for _, parsedVideo := range parsedChannel.Videos { - date, err := parsedVideo.Published.Parse() - if err != nil { - h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err) - continue - } - - id := strings.Split(parsedVideo.ID, ":")[2] - video := models.Video{ - ID: id, - Title: parsedVideo.Title, - PublishedTime: date, - } - err = h.db.CreateVideo(ctx, video, channelID) - if err != nil { - if !errors.Is(err, db.ErrVideoExists) { - h.log.Log("level", "WARNING", "call", "db.CreateVideo", "err", err) - } - continue - } - err = h.addVideoToAllSubscribers(ctx, channelID, id) - if err != nil { - continue - } - } -} - -func (h *handler) FetchVideos(ctx context.Context) error { - channels, err := h.db.ListChannels(ctx) - if err != nil { - return err - } - - for _, channel := range channels { - parsedChannel, err := feedparser.Parse(h.log, channel.ID) - if err != nil { - continue - } - - h.fetchVideosForChannel(ctx, channel.ID, parsedChannel) - } - - return nil -} diff --git a/handler/users.go b/handler/users.go new file mode 100644 index 0000000..7157138 --- /dev/null +++ b/handler/users.go @@ -0,0 +1,19 @@ +package handler + +import ( + "context" + + "github.com/alexedwards/argon2id" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +func (h *handler) CreateUser(ctx context.Context, user models.User) error { + hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams) + if err != nil { + return err + } + user.Password = hashedPassword + + return h.db.CreateUser(ctx, user) +} diff --git a/handler/videos.go b/handler/videos.go new file mode 100644 index 0000000..142e597 --- /dev/null +++ b/handler/videos.go @@ -0,0 +1,93 @@ +package handler + +import ( + "context" + "errors" + "strings" + "time" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) { + return h.db.GetNewVideos(ctx, username) +} + +func (h *handler) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) { + return h.db.GetWatchedVideos(ctx, username) +} + +func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error { + subs, err := h.db.GetChannelSubscribers(ctx, channelID) + if err != nil { + h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err) + return err + } + + for _, sub := range subs { + err = h.db.AddVideoToUser(ctx, sub, videoID) + if err != nil { + h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err) + continue + } + } + + return nil +} + +func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) { + for _, parsedVideo := range parsedChannel.Videos { + date, err := parsedVideo.Published.Parse() + if err != nil { + h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err) + continue + } + + id := strings.Split(parsedVideo.ID, ":")[2] + video := models.Video{ + ID: id, + Title: parsedVideo.Title, + PublishedTime: date, + } + err = h.db.AddVideo(ctx, video, channelID) + if err != nil { + if !errors.Is(err, db.ErrVideoExists) { + h.log.Log("level", "WARNING", "call", "db.AddVideo", "err", err) + } + continue + } + err = h.addVideoToAllSubscribers(ctx, channelID, id) + if err != nil { + continue + } + } +} + +func (h *handler) FetchVideos(ctx context.Context) error { + channels, err := h.db.ListChannels(ctx) + if err != nil { + return err + } + + for _, channel := range channels { + parsedChannel, err := feedparser.Parse(h.log, channel.ID) + if err != nil { + continue + } + + h.fetchVideosForChannel(ctx, channel.ID, parsedChannel) + } + + return nil +} + +func (h *handler) MarkVideoAsWatched(ctx context.Context, username string, videoID string) error { + watchTime := time.Now() + return h.db.SetVideoWatchTime(ctx, username, videoID, &watchTime) +} + +func (h *handler) MarkVideoAsUnwatched(ctx context.Context, username string, videoID string) error { + return h.db.SetVideoWatchTime(ctx, username, videoID, nil) +} diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go new file mode 100644 index 0000000..457677c --- /dev/null +++ b/httpserver/ytrssil/channels.go @@ -0,0 +1,39 @@ +package ytrssil + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" + "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" +) + +func (s *server) SubscribeToChannel(c *gin.Context) { + var channel models.Channel + err := c.ShouldBindUri(&channel) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + username := c.GetString("username") + + err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID) + if err != nil { + if errors.Is(err, db.ErrAlreadySubscribed) { + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, feedparser.ErrInvalidChannelID) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"}) +} diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go index 4f5ac66..668a8c5 100644 --- a/httpserver/ytrssil/server.go +++ b/httpserver/ytrssil/server.go @@ -43,8 +43,11 @@ func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c api := engine.Group("/api") api.Use(authMiddleware) { - api.GET("videos/new", srv.GetNewVideos) api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel) + api.GET("videos/new", srv.GetNewVideos) + api.GET("videos/watched", srv.GetWatchedVideos) + api.POST("videos/:video_id/watch", srv.MarkVideoAsWatched) + api.POST("videos/:video_id/unwatch", srv.MarkVideoAsUnwatched) } return engine, nil diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go index 6938880..c9a426a 100644 --- a/httpserver/ytrssil/users.go +++ b/httpserver/ytrssil/users.go @@ -7,13 +7,12 @@ import ( "github.com/gin-gonic/gin" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" - "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" ) func (s *server) CreateUser(c *gin.Context) { var user models.User - err := c.BindJSON(&user) + err := c.ShouldBindJSON(&user) if err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -21,36 +20,14 @@ func (s *server) CreateUser(c *gin.Context) { err = s.handler.CreateUser(c.Request.Context(), user) if err != nil { + if errors.Is(err, db.ErrUserExists) { + c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"msg": "user created"}) } - -func (s *server) SubscribeToChannel(c *gin.Context) { - var channel models.Channel - err := c.BindUri(&channel) - if err != nil { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - username := c.GetString("username") - - err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID) - if err != nil { - if errors.Is(err, db.ErrAlreadySubscribed) { - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - return - } - if errors.Is(err, feedparser.ErrInvalidChannelID) { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"}) -} diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go index 9aaab82..da7ce1a 100644 --- a/httpserver/ytrssil/videos.go +++ b/httpserver/ytrssil/videos.go @@ -8,14 +8,27 @@ import ( ) func (s *server) GetNewVideos(c *gin.Context) { - username := c.MustGet("username").(string) + username := c.GetString("username") videos, err := s.handler.GetNewVideos(c.Request.Context(), username) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, models.GetNewVideosResponse{ + c.JSON(http.StatusOK, models.VideosResponse{ + Videos: videos, + }) +} + +func (s *server) GetWatchedVideos(c *gin.Context) { + username := c.GetString("username") + videos, err := s.handler.GetWatchedVideos(c.Request.Context(), username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, models.VideosResponse{ Videos: videos, }) } @@ -29,3 +42,39 @@ func (s *server) FetchVideos(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"}) } + +func (s *server) MarkVideoAsWatched(c *gin.Context) { + username := c.GetString("username") + var req models.VideoURIRequest + err := c.ShouldBindUri(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = s.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "marked video as watched"}) +} + +func (s *server) MarkVideoAsUnwatched(c *gin.Context) { + username := c.GetString("username") + var req models.VideoURIRequest + err := c.ShouldBindUri(&req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = s.handler.MarkVideoAsUnwatched(c.Request.Context(), username, req.VideoID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"msg": "cleared video from watch history"}) +} diff --git a/mocks/db/db.go b/mocks/db/db.go index d88b2e1..0bcbb02 100644 --- a/mocks/db/db.go +++ b/mocks/db/db.go @@ -8,6 +8,7 @@ import ( "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "sync" + "time" ) // Ensure, that DBMock does implement db.DB. @@ -20,6 +21,9 @@ var _ db.DB = &DBMock{} // // // make and configure a mocked db.DB // mockedDB := &DBMock{ +// AddVideoFunc: func(ctx context.Context, video models.Video, channelID string) error { +// panic("mock out the AddVideo method") +// }, // AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error { // panic("mock out the AddVideoToUser method") // }, @@ -32,9 +36,6 @@ var _ db.DB = &DBMock{} // CreateUserFunc: func(ctx context.Context, user models.User) error { // panic("mock out the CreateUser method") // }, -// CreateVideoFunc: func(ctx context.Context, video models.Video, channelID string) error { -// panic("mock out the CreateVideo method") -// }, // DeleteUserFunc: func(ctx context.Context, username string) error { // panic("mock out the DeleteUser method") // }, @@ -44,15 +45,18 @@ var _ db.DB = &DBMock{} // GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) { // panic("mock out the GetNewVideos method") // }, +// GetWatchedVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) { +// panic("mock out the GetWatchedVideos method") +// }, // ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) { // panic("mock out the ListChannels method") // }, +// SetVideoWatchTimeFunc: func(ctx context.Context, username string, videoID string, watchTime *time.Time) error { +// panic("mock out the SetVideoWatchTime method") +// }, // SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error { // panic("mock out the SubscribeUserToChannel method") // }, -// WatchVideoFunc: func(ctx context.Context, username string, videoID string) error { -// panic("mock out the WatchVideo method") -// }, // } // // // use mockedDB in code that requires db.DB @@ -60,6 +64,9 @@ var _ db.DB = &DBMock{} // // } type DBMock struct { + // AddVideoFunc mocks the AddVideo method. + AddVideoFunc func(ctx context.Context, video models.Video, channelID string) error + // AddVideoToUserFunc mocks the AddVideoToUser method. AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error @@ -72,9 +79,6 @@ type DBMock struct { // CreateUserFunc mocks the CreateUser method. CreateUserFunc func(ctx context.Context, user models.User) error - // CreateVideoFunc mocks the CreateVideo method. - CreateVideoFunc func(ctx context.Context, video models.Video, channelID string) error - // DeleteUserFunc mocks the DeleteUser method. DeleteUserFunc func(ctx context.Context, username string) error @@ -84,17 +88,29 @@ type DBMock struct { // GetNewVideosFunc mocks the GetNewVideos method. GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error) + // GetWatchedVideosFunc mocks the GetWatchedVideos method. + GetWatchedVideosFunc func(ctx context.Context, username string) ([]models.Video, error) + // ListChannelsFunc mocks the ListChannels method. ListChannelsFunc func(ctx context.Context) ([]models.Channel, error) + // SetVideoWatchTimeFunc mocks the SetVideoWatchTime method. + SetVideoWatchTimeFunc func(ctx context.Context, username string, videoID string, watchTime *time.Time) error + // SubscribeUserToChannelFunc mocks the SubscribeUserToChannel method. SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error - // WatchVideoFunc mocks the WatchVideo method. - WatchVideoFunc func(ctx context.Context, username string, videoID string) error - // calls tracks calls to the methods. calls struct { + // AddVideo holds details about calls to the AddVideo method. + AddVideo []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Video is the video argument value. + Video models.Video + // ChannelID is the channelID argument value. + ChannelID string + } // AddVideoToUser holds details about calls to the AddVideoToUser method. AddVideoToUser []struct { // Ctx is the ctx argument value. @@ -125,15 +141,6 @@ type DBMock struct { // User is the user argument value. User models.User } - // CreateVideo holds details about calls to the CreateVideo method. - CreateVideo []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Video is the video argument value. - Video models.Video - // ChannelID is the channelID argument value. - ChannelID string - } // DeleteUser holds details about calls to the DeleteUser method. DeleteUser []struct { // Ctx is the ctx argument value. @@ -155,11 +162,29 @@ type DBMock struct { // Username is the username argument value. Username string } + // GetWatchedVideos holds details about calls to the GetWatchedVideos method. + GetWatchedVideos []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + } // ListChannels holds details about calls to the ListChannels method. ListChannels []struct { // Ctx is the ctx argument value. Ctx context.Context } + // SetVideoWatchTime holds details about calls to the SetVideoWatchTime method. + SetVideoWatchTime []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Username is the username argument value. + Username string + // VideoID is the videoID argument value. + VideoID string + // WatchTime is the watchTime argument value. + WatchTime *time.Time + } // SubscribeUserToChannel holds details about calls to the SubscribeUserToChannel method. SubscribeUserToChannel []struct { // Ctx is the ctx argument value. @@ -169,27 +194,59 @@ type DBMock struct { // ChannelID is the channelID argument value. ChannelID string } - // WatchVideo holds details about calls to the WatchVideo method. - WatchVideo []struct { - // Ctx is the ctx argument value. - Ctx context.Context - // Username is the username argument value. - Username string - // VideoID is the videoID argument value. - VideoID string - } } + lockAddVideo sync.RWMutex lockAddVideoToUser sync.RWMutex lockAuthenticateUser sync.RWMutex lockCreateChannel sync.RWMutex lockCreateUser sync.RWMutex - lockCreateVideo sync.RWMutex lockDeleteUser sync.RWMutex lockGetChannelSubscribers sync.RWMutex lockGetNewVideos sync.RWMutex + lockGetWatchedVideos sync.RWMutex lockListChannels sync.RWMutex + lockSetVideoWatchTime sync.RWMutex lockSubscribeUserToChannel sync.RWMutex - lockWatchVideo sync.RWMutex +} + +// AddVideo calls AddVideoFunc. +func (mock *DBMock) AddVideo(ctx context.Context, video models.Video, channelID string) error { + if mock.AddVideoFunc == nil { + panic("DBMock.AddVideoFunc: method is nil but DB.AddVideo was just called") + } + callInfo := struct { + Ctx context.Context + Video models.Video + ChannelID string + }{ + Ctx: ctx, + Video: video, + ChannelID: channelID, + } + mock.lockAddVideo.Lock() + mock.calls.AddVideo = append(mock.calls.AddVideo, callInfo) + mock.lockAddVideo.Unlock() + return mock.AddVideoFunc(ctx, video, channelID) +} + +// AddVideoCalls gets all the calls that were made to AddVideo. +// Check the length with: +// +// len(mockedDB.AddVideoCalls()) +func (mock *DBMock) AddVideoCalls() []struct { + Ctx context.Context + Video models.Video + ChannelID string +} { + var calls []struct { + Ctx context.Context + Video models.Video + ChannelID string + } + mock.lockAddVideo.RLock() + calls = mock.calls.AddVideo + mock.lockAddVideo.RUnlock() + return calls } // AddVideoToUser calls AddVideoToUserFunc. @@ -340,46 +397,6 @@ func (mock *DBMock) CreateUserCalls() []struct { return calls } -// CreateVideo calls CreateVideoFunc. -func (mock *DBMock) CreateVideo(ctx context.Context, video models.Video, channelID string) error { - if mock.CreateVideoFunc == nil { - panic("DBMock.CreateVideoFunc: method is nil but DB.CreateVideo was just called") - } - callInfo := struct { - Ctx context.Context - Video models.Video - ChannelID string - }{ - Ctx: ctx, - Video: video, - ChannelID: channelID, - } - mock.lockCreateVideo.Lock() - mock.calls.CreateVideo = append(mock.calls.CreateVideo, callInfo) - mock.lockCreateVideo.Unlock() - return mock.CreateVideoFunc(ctx, video, channelID) -} - -// CreateVideoCalls gets all the calls that were made to CreateVideo. -// Check the length with: -// -// len(mockedDB.CreateVideoCalls()) -func (mock *DBMock) CreateVideoCalls() []struct { - Ctx context.Context - Video models.Video - ChannelID string -} { - var calls []struct { - Ctx context.Context - Video models.Video - ChannelID string - } - mock.lockCreateVideo.RLock() - calls = mock.calls.CreateVideo - mock.lockCreateVideo.RUnlock() - return calls -} - // DeleteUser calls DeleteUserFunc. func (mock *DBMock) DeleteUser(ctx context.Context, username string) error { if mock.DeleteUserFunc == nil { @@ -488,6 +505,42 @@ func (mock *DBMock) GetNewVideosCalls() []struct { return calls } +// GetWatchedVideos calls GetWatchedVideosFunc. +func (mock *DBMock) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) { + if mock.GetWatchedVideosFunc == nil { + panic("DBMock.GetWatchedVideosFunc: method is nil but DB.GetWatchedVideos was just called") + } + callInfo := struct { + Ctx context.Context + Username string + }{ + Ctx: ctx, + Username: username, + } + mock.lockGetWatchedVideos.Lock() + mock.calls.GetWatchedVideos = append(mock.calls.GetWatchedVideos, callInfo) + mock.lockGetWatchedVideos.Unlock() + return mock.GetWatchedVideosFunc(ctx, username) +} + +// GetWatchedVideosCalls gets all the calls that were made to GetWatchedVideos. +// Check the length with: +// +// len(mockedDB.GetWatchedVideosCalls()) +func (mock *DBMock) GetWatchedVideosCalls() []struct { + Ctx context.Context + Username string +} { + var calls []struct { + Ctx context.Context + Username string + } + mock.lockGetWatchedVideos.RLock() + calls = mock.calls.GetWatchedVideos + mock.lockGetWatchedVideos.RUnlock() + return calls +} + // ListChannels calls ListChannelsFunc. func (mock *DBMock) ListChannels(ctx context.Context) ([]models.Channel, error) { if mock.ListChannelsFunc == nil { @@ -520,6 +573,50 @@ func (mock *DBMock) ListChannelsCalls() []struct { return calls } +// SetVideoWatchTime calls SetVideoWatchTimeFunc. +func (mock *DBMock) SetVideoWatchTime(ctx context.Context, username string, videoID string, watchTime *time.Time) error { + if mock.SetVideoWatchTimeFunc == nil { + panic("DBMock.SetVideoWatchTimeFunc: method is nil but DB.SetVideoWatchTime was just called") + } + callInfo := struct { + Ctx context.Context + Username string + VideoID string + WatchTime *time.Time + }{ + Ctx: ctx, + Username: username, + VideoID: videoID, + WatchTime: watchTime, + } + mock.lockSetVideoWatchTime.Lock() + mock.calls.SetVideoWatchTime = append(mock.calls.SetVideoWatchTime, callInfo) + mock.lockSetVideoWatchTime.Unlock() + return mock.SetVideoWatchTimeFunc(ctx, username, videoID, watchTime) +} + +// SetVideoWatchTimeCalls gets all the calls that were made to SetVideoWatchTime. +// Check the length with: +// +// len(mockedDB.SetVideoWatchTimeCalls()) +func (mock *DBMock) SetVideoWatchTimeCalls() []struct { + Ctx context.Context + Username string + VideoID string + WatchTime *time.Time +} { + var calls []struct { + Ctx context.Context + Username string + VideoID string + WatchTime *time.Time + } + mock.lockSetVideoWatchTime.RLock() + calls = mock.calls.SetVideoWatchTime + mock.lockSetVideoWatchTime.RUnlock() + return calls +} + // SubscribeUserToChannel calls SubscribeUserToChannelFunc. func (mock *DBMock) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error { if mock.SubscribeUserToChannelFunc == nil { @@ -559,43 +656,3 @@ func (mock *DBMock) SubscribeUserToChannelCalls() []struct { mock.lockSubscribeUserToChannel.RUnlock() return calls } - -// WatchVideo calls WatchVideoFunc. -func (mock *DBMock) WatchVideo(ctx context.Context, username string, videoID string) error { - if mock.WatchVideoFunc == nil { - panic("DBMock.WatchVideoFunc: method is nil but DB.WatchVideo was just called") - } - callInfo := struct { - Ctx context.Context - Username string - VideoID string - }{ - Ctx: ctx, - Username: username, - VideoID: videoID, - } - mock.lockWatchVideo.Lock() - mock.calls.WatchVideo = append(mock.calls.WatchVideo, callInfo) - mock.lockWatchVideo.Unlock() - return mock.WatchVideoFunc(ctx, username, videoID) -} - -// WatchVideoCalls gets all the calls that were made to WatchVideo. -// Check the length with: -// -// len(mockedDB.WatchVideoCalls()) -func (mock *DBMock) WatchVideoCalls() []struct { - Ctx context.Context - Username string - VideoID string -} { - var calls []struct { - Ctx context.Context - Username string - VideoID string - } - mock.lockWatchVideo.RLock() - calls = mock.calls.WatchVideo - mock.lockWatchVideo.RUnlock() - return calls -} diff --git a/models/http.go b/models/http.go index d59ad49..9c26731 100644 --- a/models/http.go +++ b/models/http.go @@ -1,5 +1,9 @@ package models -type GetNewVideosResponse struct { +type VideosResponse struct { Videos []Video `json:"videos"` } + +type VideoURIRequest struct { + VideoID string `uri:"video_id" binding:"required"` +}