3147d3dd4c5e463af462d679981f751c34574f12
- Author
- Pavle Portic <git@theedgeofrage.com>
- Committer
- Pavle Portic <git@theedgeofrage.com>
- Date
Message
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+}