3147d3dd4c5e463af462d679981f751c34574f12

Author
Pavle Portic <git@theedgeofrage.com>
Committer
Pavle Portic <git@theedgeofrage.com>
Date

Message

Mark videos as watched

Diff

This diff is truncated to protect this page.

  1diff --git a/db/channels.go b/db/channels.go
  2index a2caac26068cc1362dbbb6fd32eeb3a7d3983bfd..999ba1c706e1d3c32cf4f84f3f1569306985a9e2 100644
  3--- a/db/channels.go
  4+++ b/db/channels.go
  5@@ -73,3 +73,20 @@ func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string
  6 
  7 	return subs, nil
  8 }
  9+
 10+var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)`
 11+
 12+func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
 13+	_, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID)
 14+	if err != nil {
 15+		if pgerr, ok := err.(*pq.Error); ok {
 16+			if pgerr.Code == "23505" {
 17+				return ErrAlreadySubscribed
 18+			}
 19+		}
 20+		d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err)
 21+		return err
 22+	}
 23+
 24+	return nil
 25+}
 26diff --git a/db/db.go b/db/db.go
 27index 25efe7ae6cc761e5c87d66b76ace035ac7c56f30..41d8c14b200b7b6964f18fae1237ec7fc5dca737 100644
 28--- a/db/db.go
 29+++ b/db/db.go
 30@@ -3,6 +3,7 @@ package db
 31 import (
 32 	"context"
 33 	"errors"
 34+	"time"
 35 
 36 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
 37 )
 38@@ -11,14 +12,17 @@ var (
 39 	ErrChannelExists     = errors.New("channel already exists")
 40 	ErrAlreadySubscribed = errors.New("already subscribed to channel")
 41 	ErrVideoExists       = errors.New("video already exists")
 42+	ErrUserExists        = errors.New("user already exists")
 43 )
 44 
 45 // DB represents a database layer for getting video and channel data
 46 type DB interface {
 47-	// GetNewVideos returns unwatched videos from all channels
 48-	GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
 49-	// CreateVideo adds a newly published video to the database
 50-	CreateVideo(ctx context.Context, video models.Video, channelID string) error
 51+	// AuthenticateUser verifies a user's password against a hashed value
 52+	AuthenticateUser(ctx context.Context, user models.User) (bool, error)
 53+	// CreateUser registers a new user in the database
 54+	CreateUser(ctx context.Context, user models.User) error
 55+	// DeleteUser registers a new user in the database
 56+	DeleteUser(ctx context.Context, username string) error
 57 
 58 	// CreateChannel starts tracking a new channel and fetch new videos for it
 59 	CreateChannel(ctx context.Context, channel models.Channel) error
 60@@ -26,17 +30,17 @@ type DB interface {
 61 	ListChannels(ctx context.Context) ([]models.Channel, error)
 62 	// GetChannelSubscribers lists all channels from the database
 63 	GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error)
 64-
 65-	// AuthenticateUser verifies a user's password against a hashed value
 66-	AuthenticateUser(ctx context.Context, user models.User) (bool, error)
 67-	// CreateUser registers a new user in the database
 68-	CreateUser(ctx context.Context, user models.User) error
 69-	// DeleteUser registers a new user in the database
 70-	DeleteUser(ctx context.Context, username string) error
 71 	// SubscribeUserToChannel will start showing new videos for that channel to the user
 72 	SubscribeUserToChannel(ctx context.Context, username string, channelID string) error
 73+
 74+	// GetNewVideos returns a list of unwatched videos from all subscribed channels
 75+	GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
 76+	// GetWatchedVideos returns a list of all watched videos for a user
 77+	GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
 78+	// AddVideo adds a newly published video to the database
 79+	AddVideo(ctx context.Context, video models.Video, channelID string) error
 80 	// AddVideoToUser will list the video in the users feed
 81 	AddVideoToUser(ctx context.Context, username string, videoID string) error
 82-	// WatchVideo marks a video as watched so it no longer shows in the feed
 83-	WatchVideo(ctx context.Context, username string, videoID string) error
 84+	// SetVideoWatchTime sets or unsets the watch timestamp of a user's video
 85+	SetVideoWatchTime(ctx context.Context, username string, videoID string, watchTime *time.Time) error
 86 }
 87diff --git a/db/users.go b/db/users.go
 88index 740b48e9947c1189facd3377bf456b36eeba6c6d..64bb9f53bda323837b09526c5bd5c1ef904fbdd0 100644
 89--- a/db/users.go
 90+++ b/db/users.go
 91@@ -2,6 +2,8 @@ package db
 92 
 93 import (
 94 	"context"
 95+	"database/sql"
 96+	"errors"
 97 
 98 	"github.com/alexedwards/argon2id"
 99 	"github.com/lib/pq"
100@@ -16,6 +18,10 @@ func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bo
101 	var hashedPassword string
102 	err := row.Scan(&hashedPassword)
103 	if err != nil {
104+		if errors.Is(err, sql.ErrNoRows) {
105+			return false, nil
106+		}
107+
108 		d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err)
109 		return false, err
110 	}
111@@ -34,6 +40,11 @@ var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)`
112 func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error {
113 	_, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password)
114 	if err != nil {
115+		if pgerr, ok := err.(*pq.Error); ok {
116+			if pgerr.Code == "23505" {
117+				return ErrUserExists
118+			}
119+		}
120 		d.l.Log("level", "ERROR", "function", "db.CreateUser", "error", err)
121 		return err
122 	}
123@@ -52,44 +63,3 @@ func (d *postgresDB) DeleteUser(ctx context.Context, username string) error {
124 
125 	return nil
126 }
127-
128-var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)`
129-
130-func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
131-	_, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID)
132-	if err != nil {
133-		if pgerr, ok := err.(*pq.Error); ok {
134-			if pgerr.Code == "23505" {
135-				return ErrAlreadySubscribed
136-			}
137-		}
138-		d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err)
139-		return err
140-	}
141-
142-	return nil
143-}
144-
145-var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)`
146-
147-func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error {
148-	_, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID)
149-	if err != nil {
150-		d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err)
151-		return err
152-	}
153-
154-	return nil
155-}
156-
157-var watchVideoQuery = `UPDATE user_videos SET watch_timestamp = NOW() WHERE username = $2 AND video_id = $3`
158-
159-func (d *postgresDB) WatchVideo(ctx context.Context, username string, videoID string) error {
160-	_, err := d.db.ExecContext(ctx, watchVideoQuery, username, videoID)
161-	if err != nil {
162-		d.l.Log("level", "ERROR", "function", "db.WatchVideo", "error", err)
163-		return err
164-	}
165-
166-	return nil
167-}
168diff --git a/db/videos.go b/db/videos.go
169index 47f1cb10ff53648bd38ff364872446dc94bfe0df..35acba5aa811f2eafadb28ca29ff5fe747a81dd3 100644
170--- a/db/videos.go
171+++ b/db/videos.go
172@@ -2,6 +2,7 @@ package db
173 
174 import (
175 	"context"
176+	"time"
177 
178 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
179 	"github.com/lib/pq"
180@@ -52,17 +53,88 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]model
181 	return videos, nil
182 }
183 
184-var createVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)`
185+var getWatchedVideosQuery = `
186+	SELECT
187+		video_id
188+		, title
189+		, published_timestamp
190+		, watch_timestamp
191+		, name as channel_name
192+	FROM user_videos
193+	LEFT JOIN videos ON video_id=videos.id
194+	LEFT JOIN channels ON channel_id=channels.id
195+	WHERE
196+		1=1
197+		AND watch_timestamp IS NOT NULL
198+		AND username=$1
199+	ORDER BY watch_timestamp ASC
200+`
201+
202+func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) {
203+	rows, err := d.db.QueryContext(ctx, getWatchedVideosQuery, username)
204+	if err != nil {
205+		d.l.Log("level", "ERROR", "function", "db.GetWatchedVideos", "call", "sql.QueryContext", "error", err)
206+		return nil, err
207+	}
208+	defer rows.Close()
209+
210+	videos := make([]models.Video, 0)
211+	for rows.Next() {
212+		var video models.Video
213+		err = rows.Scan(
214+			&video.ID,
215+			&video.Title,
216+			&video.PublishedTime,
217+			&video.WatchTime,
218+			&video.ChannelName,
219+		)
220+		if err != nil {
221+			d.l.Log("level", "ERROR", "function", "db.GetWatchedVideos", "call", "sql.Scan", "error", err)
222+			return nil, err
223+		}
224+		videos = append(videos, video)
225+	}
226 
227-func (d *postgresDB) CreateVideo(ctx context.Context, video models.Video, channelID string) error {
228-	_, err := d.db.ExecContext(ctx, createVideoQuery, video.ID, video.Title, video.PublishedTime, channelID)
229+	return videos, nil
230+}
231+
232+var addVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)`
233+
234+func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string) error {
235+	_, err := d.db.ExecContext(ctx, addVideoQuery, video.ID, video.Title, video.PublishedTime, channelID)
236 	if err != nil {
237 		if pgerr, ok := err.(*pq.Error); ok {
238 			if pgerr.Code == "23505" {
239 				return ErrVideoExists
240 			}
241 		}
242-		d.l.Log("level", "ERROR", "function", "db.CreateVideo", "call", "sql.Exec", "error", err)
243+		d.l.Log("level", "ERROR", "function", "db.AddVideo", "call", "sql.Exec", "error", err)
244+		return err
245+	}
246+
247+	return nil
248+}
249+
250+var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)`
251+
252+func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error {
253+	_, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID)
254+	if err != nil {
255+		d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err)
256+		return err
257+	}
258+
259+	return nil
260+}
261+
262+var setVideoWatchTimeQuery = `UPDATE user_videos SET watch_timestamp = $1 WHERE username = $2 AND video_id = $3`
263+
264+func (d *postgresDB) SetVideoWatchTime(
265+	ctx context.Context, username string, videoID string, watchTime *time.Time,
266+) error {
267+	_, err := d.db.ExecContext(ctx, setVideoWatchTimeQuery, watchTime, username, videoID)
268+	if err != nil {
269+		d.l.Log("level", "ERROR", "function", "db.WatchVideo", "error", err)
270 		return err
271 	}
272diff --git a/handler/channels.go b/handler/channels.go
273new file mode 100644
274index 0000000000000000000000000000000000000000..eebceadf8c8e933f42ff519919196545cce3ef29
275--- /dev/null
276+++ b/handler/channels.go
277@@ -0,0 +1,29 @@
278+package handler
279+
280+import (
281+	"context"
282+	"errors"
283+
284+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
285+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
286+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
287+)
288+
289+func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error {
290+	parsedChannel, err := feedparser.Parse(h.log, channelID)
291+	if err != nil {
292+		return err
293+	}
294+
295+	channel := models.Channel{
296+		ID:   channelID,
297+		Name: parsedChannel.Name,
298+	}
299+
300+	err = h.db.CreateChannel(ctx, channel)
301+	if err != nil && !errors.Is(err, db.ErrChannelExists) {
302+		return err
303+	}
304+
305+	return h.db.SubscribeUserToChannel(ctx, username, channelID)
306+}
307diff --git a/handler/handler.go b/handler/handler.go
308index cac613ea927b24c9ca2d5f7750f3df1a37d413c9..f8f82ae13e8d41093448a5763c0b844b3b241853 100644
309--- a/handler/handler.go
310+++ b/handler/handler.go
311@@ -2,13 +2,8 @@ package handler
312 
313 import (
314 	"context"
315-	"errors"
316-	"strings"
317-
318-	"github.com/alexedwards/argon2id"
319 
320 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
321-	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
322 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
323 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
324 )
325@@ -17,7 +12,10 @@ type Handler interface {
326 	CreateUser(ctx context.Context, user models.User) error
327 	SubscribeToChannel(ctx context.Context, username string, channelID string) error
328 	GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
329+	GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
330 	FetchVideos(ctx context.Context) error
331+	MarkVideoAsWatched(ctx context.Context, username string, videoID string) error
332+	MarkVideoAsUnwatched(ctx context.Context, username string, videoID string) error
333 }
334 
335 type handler struct {
336@@ -28,100 +26,3 @@ type handler struct {
337 func New(log log.Logger, db db.DB) *handler {
338 	return &handler{log: log, db: db}
339 }
340-
341-func (h *handler) CreateUser(ctx context.Context, user models.User) error {
342-	hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
343-	if err != nil {
344-		return err
345-	}
346-	user.Password = hashedPassword
347-
348-	return h.db.CreateUser(ctx, user)
349-}
350-
351-func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error {
352-	parsedChannel, err := feedparser.Parse(h.log, channelID)
353-	if err != nil {
354-		return err
355-	}
356-
357-	channel := models.Channel{
358-		ID:   channelID,
359-		Name: parsedChannel.Name,
360-	}
361-
362-	err = h.db.CreateChannel(ctx, channel)
363-	if !errors.Is(err, db.ErrChannelExists) {
364-		return err
365-	}
366-
367-	return h.db.SubscribeUserToChannel(ctx, username, channelID)
368-}
369-
370-func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
371-	return h.db.GetNewVideos(ctx, username)
372-}
373-
374-func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error {
375-	subs, err := h.db.GetChannelSubscribers(ctx, channelID)
376-	if err != nil {
377-		h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err)
378-		return err
379-	}
380-
381-	for _, sub := range subs {
382-		err = h.db.AddVideoToUser(ctx, sub, videoID)
383-		if err != nil {
384-			h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err)
385-			continue
386-		}
387-	}
388-
389-	return nil
390-}
391-
392-func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) {
393-	for _, parsedVideo := range parsedChannel.Videos {
394-		date, err := parsedVideo.Published.Parse()
395-		if err != nil {
396-			h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err)
397-			continue
398-		}
399-
400-		id := strings.Split(parsedVideo.ID, ":")[2]
401-		video := models.Video{
402-			ID:            id,
403-			Title:         parsedVideo.Title,
404-			PublishedTime: date,
405-		}
406-		err = h.db.CreateVideo(ctx, video, channelID)
407-		if err != nil {
408-			if !errors.Is(err, db.ErrVideoExists) {
409-				h.log.Log("level", "WARNING", "call", "db.CreateVideo", "err", err)
410-			}
411diff --git a/handler/users.go b/handler/users.go
412new file mode 100644
413index 0000000000000000000000000000000000000000..715713856da9af36528dac9d32c9012d75b3e449
414--- /dev/null
415+++ b/handler/users.go
416@@ -0,0 +1,19 @@
417+package handler
418+
419+import (
420+	"context"
421+
422+	"github.com/alexedwards/argon2id"
423+
424+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
425+)
426+
427+func (h *handler) CreateUser(ctx context.Context, user models.User) error {
428+	hashedPassword, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
429+	if err != nil {
430+		return err
431+	}
432+	user.Password = hashedPassword
433+
434+	return h.db.CreateUser(ctx, user)
435+}
436diff --git a/handler/videos.go b/handler/videos.go
437new file mode 100644
438index 0000000000000000000000000000000000000000..142e5976fec02c728bc939bab23fa26c04fdb899
439--- /dev/null
440+++ b/handler/videos.go
441@@ -0,0 +1,93 @@
442+package handler
443+
444+import (
445+	"context"
446+	"errors"
447+	"strings"
448+	"time"
449+
450+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
451+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
452+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
453+)
454+
455+func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
456+	return h.db.GetNewVideos(ctx, username)
457+}
458+
459+func (h *handler) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) {
460+	return h.db.GetWatchedVideos(ctx, username)
461+}
462+
463+func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error {
464+	subs, err := h.db.GetChannelSubscribers(ctx, channelID)
465+	if err != nil {
466+		h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err)
467+		return err
468+	}
469+
470+	for _, sub := range subs {
471+		err = h.db.AddVideoToUser(ctx, sub, videoID)
472+		if err != nil {
473+			h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err)
474+			continue
475+		}
476+	}
477+
478+	return nil
479+}
480+
481+func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) {
482+	for _, parsedVideo := range parsedChannel.Videos {
483+		date, err := parsedVideo.Published.Parse()
484+		if err != nil {
485+			h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err)
486+			continue
487+		}
488+
489+		id := strings.Split(parsedVideo.ID, ":")[2]
490+		video := models.Video{
491+			ID:            id,
492+			Title:         parsedVideo.Title,
493+			PublishedTime: date,
494+		}
495+		err = h.db.AddVideo(ctx, video, channelID)
496+		if err != nil {
497+			if !errors.Is(err, db.ErrVideoExists) {
498+				h.log.Log("level", "WARNING", "call", "db.AddVideo", "err", err)
499+			}
500+			continue
501+		}
502+		err = h.addVideoToAllSubscribers(ctx, channelID, id)
503+		if err != nil {
504+			continue
505+		}
506+	}
507+}
508+
509+func (h *handler) FetchVideos(ctx context.Context) error {
510+	channels, err := h.db.ListChannels(ctx)
511+	if err != nil {
512+		return err
513+	}
514+
515+	for _, channel := range channels {
516+		parsedChannel, err := feedparser.Parse(h.log, channel.ID)
517+		if err != nil {
518+			continue
519+		}
520+
521+		h.fetchVideosForChannel(ctx, channel.ID, parsedChannel)
522+	}
523+
524+	return nil
525+}
526+
527+func (h *handler) MarkVideoAsWatched(ctx context.Context, username string, videoID string) error {
528+	watchTime := time.Now()
529+	return h.db.SetVideoWatchTime(ctx, username, videoID, &watchTime)
530+}
531+
532+func (h *handler) MarkVideoAsUnwatched(ctx context.Context, username string, videoID string) error {
533+	return h.db.SetVideoWatchTime(ctx, username, videoID, nil)
534+}
535diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
536new file mode 100644
537index 0000000000000000000000000000000000000000..457677cdfd7932810feb72baddfa096b64c6fd34
538--- /dev/null
539+++ b/httpserver/ytrssil/channels.go
540@@ -0,0 +1,39 @@
541+package ytrssil
542+
543+import (
544+	"errors"
545+	"net/http"
546+
547+	"github.com/gin-gonic/gin"
548+
549+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
550+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
551+	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
552+)
553+
554+func (s *server) SubscribeToChannel(c *gin.Context) {
555+	var channel models.Channel
556+	err := c.ShouldBindUri(&channel)
557+	if err != nil {
558+		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
559+		return
560+	}
561+	username := c.GetString("username")
562+
563+	err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
564+	if err != nil {
565+		if errors.Is(err, db.ErrAlreadySubscribed) {
566+			c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
567+			return
568+		}
569+		if errors.Is(err, feedparser.ErrInvalidChannelID) {
570+			c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
571+			return
572+		}
573+
574+		c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
575+		return
576+	}
577+
578+	c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"})
579+}
580diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
581index 4f5ac66138a251096eab0cfe2045e6a8634ba989..668a8c5ae337b90d9b1d134d625bb02b7441564c 100644
582--- a/httpserver/ytrssil/server.go
583+++ b/httpserver/ytrssil/server.go
584@@ -43,8 +43,11 @@ func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c
585 	api := engine.Group("/api")
586 	api.Use(authMiddleware)
587 	{
588-		api.GET("videos/new", srv.GetNewVideos)
589 		api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel)
590+		api.GET("videos/new", srv.GetNewVideos)
591+		api.GET("videos/watched", srv.GetWatchedVideos)
592+		api.POST("videos/:video_id/watch", srv.MarkVideoAsWatched)
593+		api.POST("videos/:video_id/unwatch", srv.MarkVideoAsUnwatched)
594 	}
595 
596 	return engine, nil
597diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go
598index 69388801fab6166945158b9d19ab3789c7512280..c9a426a3487697fc94a25786bfc801191c9f65ad 100644
599--- a/httpserver/ytrssil/users.go
600+++ b/httpserver/ytrssil/users.go
601@@ -7,13 +7,12 @@ import (
602 	"github.com/gin-gonic/gin"
603 
604 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
605-	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
606 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
607 )
608 
609 func (s *server) CreateUser(c *gin.Context) {
610 	var user models.User
611-	err := c.BindJSON(&user)
612+	err := c.ShouldBindJSON(&user)
613 	if err != nil {
614 		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
615 		return
616@@ -21,30 +20,8 @@ func (s *server) CreateUser(c *gin.Context) {
617 
618 	err = s.handler.CreateUser(c.Request.Context(), user)
619 	if err != nil {
620-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
621-		return
622-	}
623-
624-	c.JSON(http.StatusOK, gin.H{"msg": "user created"})
625-}
626-
627-func (s *server) SubscribeToChannel(c *gin.Context) {
628-	var channel models.Channel
629-	err := c.BindUri(&channel)
630-	if err != nil {
631-		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
632-		return
633-	}
634-	username := c.GetString("username")
635-
636-	err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
637-	if err != nil {
638-		if errors.Is(err, db.ErrAlreadySubscribed) {
639-			c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
640-			return
641-		}
642-		if errors.Is(err, feedparser.ErrInvalidChannelID) {
643-			c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
644+		if errors.Is(err, db.ErrUserExists) {
645+			c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
646 			return
647 		}
648 
649@@ -52,5 +29,5 @@ func (s *server) SubscribeToChannel(c *gin.Context) {
650 		return
651 	}
652 
653-	c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"})
654+	c.JSON(http.StatusOK, gin.H{"msg": "user created"})
655 }
656diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
657index 9aaab82bca89ea6ca6ee0702e95141733fb9ad3c..da7ce1a848eae0a540740955ce3df630d635d879 100644
658--- a/httpserver/ytrssil/videos.go
659+++ b/httpserver/ytrssil/videos.go
660@@ -8,14 +8,27 @@ import (
661 )
662 
663 func (s *server) GetNewVideos(c *gin.Context) {
664-	username := c.MustGet("username").(string)
665+	username := c.GetString("username")
666 	videos, err := s.handler.GetNewVideos(c.Request.Context(), username)
667 	if err != nil {
668 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
669 		return
670 	}
671 
672-	c.JSON(http.StatusOK, models.GetNewVideosResponse{
673+	c.JSON(http.StatusOK, models.VideosResponse{
674+		Videos: videos,
675+	})
676+}
677+
678+func (s *server) GetWatchedVideos(c *gin.Context) {
679+	username := c.GetString("username")
680+	videos, err := s.handler.GetWatchedVideos(c.Request.Context(), username)
681+	if err != nil {
682+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
683+		return
684+	}
685+
686+	c.JSON(http.StatusOK, models.VideosResponse{
687 		Videos: videos,
688 	})
689 }
690@@ -29,3 +42,39 @@ func (s *server) FetchVideos(c *gin.Context) {
691 
692 	c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"})
693 }
694+
695+func (s *server) MarkVideoAsWatched(c *gin.Context) {
696+	username := c.GetString("username")
697+	var req models.VideoURIRequest
698+	err := c.ShouldBindUri(&req)
699+	if err != nil {
700+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
701+		return
702+	}
703+
704+	err = s.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID)
705+	if err != nil {
706+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
707+		return
708+	}
709+
710+	c.JSON(http.StatusOK, gin.H{"msg": "marked video as watched"})
711+}
712+
713+func (s *server) MarkVideoAsUnwatched(c *gin.Context) {
714+	username := c.GetString("username")
715+	var req models.VideoURIRequest
716+	err := c.ShouldBindUri(&req)
717+	if err != nil {
718+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
719+		return
720+	}
721+
722+	err = s.handler.MarkVideoAsUnwatched(c.Request.Context(), username, req.VideoID)
723+	if err != nil {
724+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
725+		return
726+	}
727+
728+	c.JSON(http.StatusOK, gin.H{"msg": "cleared video from watch history"})
729+}
730diff --git a/mocks/db/db.go b/mocks/db/db.go
731index d88b2e151bdd5dc13bf9e1dc6a9e66c54710ed53..0bcbb02b6dcd1d54ce61279dddbdfa4d011ed3c5 100644
732--- a/mocks/db/db.go
733+++ b/mocks/db/db.go
734@@ -8,6 +8,7 @@ import (
735 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
736 	"gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
737 	"sync"
738+	"time"
739 )
740 
741 // Ensure, that DBMock does implement db.DB.
742@@ -20,6 +21,9 @@ var _ db.DB = &DBMock{}
743 //
744 //		// make and configure a mocked db.DB
745 //		mockedDB := &DBMock{
746+//			AddVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
747+//				panic("mock out the AddVideo method")
748+//			},
749 //			AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error {
750 //				panic("mock out the AddVideoToUser method")
751 //			},
752@@ -32,9 +36,6 @@ var _ db.DB = &DBMock{}
753 //			CreateUserFunc: func(ctx context.Context, user models.User) error {
754 //				panic("mock out the CreateUser method")
755 //			},
756-//			CreateVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
757-//				panic("mock out the CreateVideo method")
758-//			},
759 //			DeleteUserFunc: func(ctx context.Context, username string) error {
760 //				panic("mock out the DeleteUser method")
761 //			},
762@@ -44,15 +45,18 @@ var _ db.DB = &DBMock{}
763 //			GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
764 //				panic("mock out the GetNewVideos method")
765 //			},
766+//			GetWatchedVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
767+//				panic("mock out the GetWatchedVideos method")
768+//			},
769 //			ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
770 //				panic("mock out the ListChannels method")
771 //			},
772+//			SetVideoWatchTimeFunc: func(ctx context.Context, username string, videoID string, watchTime *time.Time) error {
773+//				panic("mock out the SetVideoWatchTime method")
774+//			},
775 //			SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error {
776 //				panic("mock out the SubscribeUserToChannel method")
777 //			},
778-//			WatchVideoFunc: func(ctx context.Context, username string, videoID string) error {
779-//				panic("mock out the WatchVideo method")
780-//			},
781 //		}
782 //
783 //		// use mockedDB in code that requires db.DB
784@@ -60,6 +64,9 @@ var _ db.DB = &DBMock{}
785 //
786 //	}
787 type DBMock struct {
788+	// AddVideoFunc mocks the AddVideo method.
789+	AddVideoFunc func(ctx context.Context, video models.Video, channelID string) error
790+
791 	// AddVideoToUserFunc mocks the AddVideoToUser method.
792 	AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error
793 
794@@ -72,9 +79,6 @@ type DBMock struct {
795 	// CreateUserFunc mocks the CreateUser method.
796 	CreateUserFunc func(ctx context.Context, user models.User) error
797 
798-	// CreateVideoFunc mocks the CreateVideo method.
799-	CreateVideoFunc func(ctx context.Context, video models.Video, channelID string) error
800-
801 	// DeleteUserFunc mocks the DeleteUser method.
802 	DeleteUserFunc func(ctx context.Context, username string) error
803 
804@@ -84,17 +88,29 @@ type DBMock struct {
805 	// GetNewVideosFunc mocks the GetNewVideos method.
806 	GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
807 
808+	// GetWatchedVideosFunc mocks the GetWatchedVideos method.
809+	GetWatchedVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
810+
811 	// ListChannelsFunc mocks the ListChannels method.
812 	ListChannelsFunc func(ctx context.Context) ([]models.Channel, error)
813 
814+	// SetVideoWatchTimeFunc mocks the SetVideoWatchTime method.
815+	SetVideoWatchTimeFunc func(ctx context.Context, username string, videoID string, watchTime *time.Time) error
816+
817 	// SubscribeUserToChannelFunc mocks the SubscribeUserToChannel method.
818 	SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error
819 
820-	// WatchVideoFunc mocks the WatchVideo method.
821-	WatchVideoFunc func(ctx context.Context, username string, videoID string) error
822-
823 	// calls tracks calls to the methods.
824 	calls struct {
825+		// AddVideo holds details about calls to the AddVideo method.
826+		AddVideo []struct {
827+			// Ctx is the ctx argument value.
828+			Ctx context.Context
829+			// Video is the video argument value.
830+			Video models.Video
831+			// ChannelID is the channelID argument value.
832+			ChannelID string
833+		}
834diff --git a/models/http.go b/models/http.go
835index d59ad499aaf2b916498d80c948c4595aadb0756e..9c26731d57e23e44981fe505bc837c611fcc51a1 100644
836--- a/models/http.go
837+++ b/models/http.go
838@@ -1,5 +1,9 @@
839 package models
840 
841-type GetNewVideosResponse struct {
842+type VideosResponse struct {
843 	Videos []Video `json:"videos"`
844 }
845+
846+type VideoURIRequest struct {
847+	VideoID string `uri:"video_id" binding:"required"`
848+}