Mark videos as watched

This commit is contained in:
Pavle Portic 2022-10-30 01:08:07 +02:00
parent fa2a3cccec
commit 3147d3dd4c
Signed by: TheEdgeOfRage
GPG Key ID: 66AD4BA646FBC0D2
14 changed files with 539 additions and 305 deletions

View File

@ -73,3 +73,20 @@ func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string
return subs, nil 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
}

View File

@ -3,6 +3,7 @@ package db
import ( import (
"context" "context"
"errors" "errors"
"time"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
) )
@ -11,14 +12,17 @@ var (
ErrChannelExists = errors.New("channel already exists") ErrChannelExists = errors.New("channel already exists")
ErrAlreadySubscribed = errors.New("already subscribed to channel") ErrAlreadySubscribed = errors.New("already subscribed to channel")
ErrVideoExists = errors.New("video already exists") ErrVideoExists = errors.New("video already exists")
ErrUserExists = errors.New("user already exists")
) )
// DB represents a database layer for getting video and channel data // DB represents a database layer for getting video and channel data
type DB interface { type DB interface {
// GetNewVideos returns unwatched videos from all channels // AuthenticateUser verifies a user's password against a hashed value
GetNewVideos(ctx context.Context, username string) ([]models.Video, error) AuthenticateUser(ctx context.Context, user models.User) (bool, error)
// CreateVideo adds a newly published video to the database // CreateUser registers a new user in the database
CreateVideo(ctx context.Context, video models.Video, channelID string) error 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 starts tracking a new channel and fetch new videos for it
CreateChannel(ctx context.Context, channel models.Channel) error CreateChannel(ctx context.Context, channel models.Channel) error
@ -26,17 +30,17 @@ type DB interface {
ListChannels(ctx context.Context) ([]models.Channel, error) ListChannels(ctx context.Context) ([]models.Channel, error)
// GetChannelSubscribers lists all channels from the database // GetChannelSubscribers lists all channels from the database
GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) 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 will start showing new videos for that channel to the user
SubscribeUserToChannel(ctx context.Context, username string, channelID string) error 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 will list the video in the users feed
AddVideoToUser(ctx context.Context, username string, videoID string) error AddVideoToUser(ctx context.Context, username string, videoID string) error
// WatchVideo marks a video as watched so it no longer shows in the feed // SetVideoWatchTime sets or unsets the watch timestamp of a user's video
WatchVideo(ctx context.Context, username string, videoID string) error SetVideoWatchTime(ctx context.Context, username string, videoID string, watchTime *time.Time) error
} }

View File

@ -2,6 +2,8 @@ package db
import ( import (
"context" "context"
"database/sql"
"errors"
"github.com/alexedwards/argon2id" "github.com/alexedwards/argon2id"
"github.com/lib/pq" "github.com/lib/pq"
@ -16,6 +18,10 @@ func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bo
var hashedPassword string var hashedPassword string
err := row.Scan(&hashedPassword) err := row.Scan(&hashedPassword)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err) d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err)
return false, 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 { func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error {
_, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password) _, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password)
if err != nil { 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) d.l.Log("level", "ERROR", "function", "db.CreateUser", "error", err)
return err return err
} }
@ -52,44 +63,3 @@ func (d *postgresDB) DeleteUser(ctx context.Context, username string) error {
return nil 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
}

View File

@ -2,6 +2,7 @@ package db
import ( import (
"context" "context"
"time"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
"github.com/lib/pq" "github.com/lib/pq"
@ -52,17 +53,88 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]model
return videos, nil 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 { func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) {
_, err := d.db.ExecContext(ctx, createVideoQuery, video.ID, video.Title, video.PublishedTime, channelID) 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 err != nil {
if pgerr, ok := err.(*pq.Error); ok { if pgerr, ok := err.(*pq.Error); ok {
if pgerr.Code == "23505" { if pgerr.Code == "23505" {
return ErrVideoExists 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 return err
} }

29
handler/channels.go Normal file
View File

@ -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)
}

View File

@ -2,13 +2,8 @@ package handler
import ( import (
"context" "context"
"errors"
"strings"
"github.com/alexedwards/argon2id"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" "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/lib/log"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
) )
@ -17,7 +12,10 @@ type Handler interface {
CreateUser(ctx context.Context, user models.User) error CreateUser(ctx context.Context, user models.User) error
SubscribeToChannel(ctx context.Context, username string, channelID string) error SubscribeToChannel(ctx context.Context, username string, channelID string) error
GetNewVideos(ctx context.Context, username string) ([]models.Video, error) GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
FetchVideos(ctx context.Context) 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 { type handler struct {
@ -28,100 +26,3 @@ type handler struct {
func New(log log.Logger, db db.DB) *handler { func New(log log.Logger, db db.DB) *handler {
return &handler{log: log, db: db} 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
}

19
handler/users.go Normal file
View File

@ -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)
}

93
handler/videos.go Normal file
View File

@ -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)
}

View File

@ -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"})
}

View File

@ -43,8 +43,11 @@ func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c
api := engine.Group("/api") api := engine.Group("/api")
api.Use(authMiddleware) api.Use(authMiddleware)
{ {
api.GET("videos/new", srv.GetNewVideos)
api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel) 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 return engine, nil

View File

@ -7,13 +7,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
) )
func (s *server) CreateUser(c *gin.Context) { func (s *server) CreateUser(c *gin.Context) {
var user models.User var user models.User
err := c.BindJSON(&user) err := c.ShouldBindJSON(&user)
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@ -21,36 +20,14 @@ func (s *server) CreateUser(c *gin.Context) {
err = s.handler.CreateUser(c.Request.Context(), user) err = s.handler.CreateUser(c.Request.Context(), user)
if err != nil { 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"msg": "user created"}) 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"})
}

View File

@ -8,14 +8,27 @@ import (
) )
func (s *server) GetNewVideos(c *gin.Context) { 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) videos, err := s.handler.GetNewVideos(c.Request.Context(), username)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return 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, Videos: videos,
}) })
} }
@ -29,3 +42,39 @@ func (s *server) FetchVideos(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"}) 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"})
}

View File

@ -8,6 +8,7 @@ import (
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models" "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
"sync" "sync"
"time"
) )
// Ensure, that DBMock does implement db.DB. // Ensure, that DBMock does implement db.DB.
@ -20,6 +21,9 @@ var _ db.DB = &DBMock{}
// //
// // make and configure a mocked db.DB // // make and configure a mocked db.DB
// mockedDB := &DBMock{ // 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 { // AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error {
// panic("mock out the AddVideoToUser method") // panic("mock out the AddVideoToUser method")
// }, // },
@ -32,9 +36,6 @@ var _ db.DB = &DBMock{}
// CreateUserFunc: func(ctx context.Context, user models.User) error { // CreateUserFunc: func(ctx context.Context, user models.User) error {
// panic("mock out the CreateUser method") // 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 { // DeleteUserFunc: func(ctx context.Context, username string) error {
// panic("mock out the DeleteUser method") // panic("mock out the DeleteUser method")
// }, // },
@ -44,15 +45,18 @@ var _ db.DB = &DBMock{}
// GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) { // GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
// panic("mock out the GetNewVideos method") // 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) { // ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
// panic("mock out the ListChannels method") // 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 { // SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error {
// panic("mock out the SubscribeUserToChannel method") // 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 // // use mockedDB in code that requires db.DB
@ -60,6 +64,9 @@ var _ db.DB = &DBMock{}
// //
// } // }
type DBMock struct { 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 mocks the AddVideoToUser method.
AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error
@ -72,9 +79,6 @@ type DBMock struct {
// CreateUserFunc mocks the CreateUser method. // CreateUserFunc mocks the CreateUser method.
CreateUserFunc func(ctx context.Context, user models.User) error 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 mocks the DeleteUser method.
DeleteUserFunc func(ctx context.Context, username string) error DeleteUserFunc func(ctx context.Context, username string) error
@ -84,17 +88,29 @@ type DBMock struct {
// GetNewVideosFunc mocks the GetNewVideos method. // GetNewVideosFunc mocks the GetNewVideos method.
GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error) 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 mocks the ListChannels method.
ListChannelsFunc func(ctx context.Context) ([]models.Channel, error) 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 mocks the SubscribeUserToChannel method.
SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error 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 tracks calls to the methods.
calls struct { 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 holds details about calls to the AddVideoToUser method.
AddVideoToUser []struct { AddVideoToUser []struct {
// Ctx is the ctx argument value. // Ctx is the ctx argument value.
@ -125,15 +141,6 @@ type DBMock struct {
// User is the user argument value. // User is the user argument value.
User models.User 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 holds details about calls to the DeleteUser method.
DeleteUser []struct { DeleteUser []struct {
// Ctx is the ctx argument value. // Ctx is the ctx argument value.
@ -155,11 +162,29 @@ type DBMock struct {
// Username is the username argument value. // Username is the username argument value.
Username string 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 holds details about calls to the ListChannels method.
ListChannels []struct { ListChannels []struct {
// Ctx is the ctx argument value. // Ctx is the ctx argument value.
Ctx context.Context 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 holds details about calls to the SubscribeUserToChannel method.
SubscribeUserToChannel []struct { SubscribeUserToChannel []struct {
// Ctx is the ctx argument value. // Ctx is the ctx argument value.
@ -169,27 +194,59 @@ type DBMock struct {
// ChannelID is the channelID argument value. // ChannelID is the channelID argument value.
ChannelID string 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 lockAddVideoToUser sync.RWMutex
lockAuthenticateUser sync.RWMutex lockAuthenticateUser sync.RWMutex
lockCreateChannel sync.RWMutex lockCreateChannel sync.RWMutex
lockCreateUser sync.RWMutex lockCreateUser sync.RWMutex
lockCreateVideo sync.RWMutex
lockDeleteUser sync.RWMutex lockDeleteUser sync.RWMutex
lockGetChannelSubscribers sync.RWMutex lockGetChannelSubscribers sync.RWMutex
lockGetNewVideos sync.RWMutex lockGetNewVideos sync.RWMutex
lockGetWatchedVideos sync.RWMutex
lockListChannels sync.RWMutex lockListChannels sync.RWMutex
lockSetVideoWatchTime sync.RWMutex
lockSubscribeUserToChannel 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. // AddVideoToUser calls AddVideoToUserFunc.
@ -340,46 +397,6 @@ func (mock *DBMock) CreateUserCalls() []struct {
return calls 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. // DeleteUser calls DeleteUserFunc.
func (mock *DBMock) DeleteUser(ctx context.Context, username string) error { func (mock *DBMock) DeleteUser(ctx context.Context, username string) error {
if mock.DeleteUserFunc == nil { if mock.DeleteUserFunc == nil {
@ -488,6 +505,42 @@ func (mock *DBMock) GetNewVideosCalls() []struct {
return calls 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. // ListChannels calls ListChannelsFunc.
func (mock *DBMock) ListChannels(ctx context.Context) ([]models.Channel, error) { func (mock *DBMock) ListChannels(ctx context.Context) ([]models.Channel, error) {
if mock.ListChannelsFunc == nil { if mock.ListChannelsFunc == nil {
@ -520,6 +573,50 @@ func (mock *DBMock) ListChannelsCalls() []struct {
return calls 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. // SubscribeUserToChannel calls SubscribeUserToChannelFunc.
func (mock *DBMock) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error { func (mock *DBMock) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
if mock.SubscribeUserToChannelFunc == nil { if mock.SubscribeUserToChannelFunc == nil {
@ -559,43 +656,3 @@ func (mock *DBMock) SubscribeUserToChannelCalls() []struct {
mock.lockSubscribeUserToChannel.RUnlock() mock.lockSubscribeUserToChannel.RUnlock()
return calls 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
}

View File

@ -1,5 +1,9 @@
package models package models
type GetNewVideosResponse struct { type VideosResponse struct {
Videos []Video `json:"videos"` Videos []Video `json:"videos"`
} }
type VideoURIRequest struct {
VideoID string `uri:"video_id" binding:"required"`
}