fa2a3cccec7ecad6ca580f355b6b521e6f4087f9
- 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/cmd/main.go b/cmd/main.go
2index a2631bf8a560035621fe120942315591789cb08c..795318be34faf7c868a7f7de4e3d15fc3af0f104 100644
3--- a/cmd/main.go
4+++ b/cmd/main.go
5@@ -33,9 +33,9 @@ func main() {
6 return
7 }
8
9- db, err := db.NewPSQLDB(log, config.DB)
10+ db, err := db.NewPostgresDB(log, config.DB)
11 if err != nil {
12- log.Log("level", "FATAL", "call", "db.NewPSQLDB", "error", err)
13+ log.Log("level", "FATAL", "call", "db.NewPostgresDB", "error", err)
14 return
15 }
16
17diff --git a/cmd/main_test.go b/cmd/main_test.go
18index 33583fd2d6bd1ff069a2cc354c00109dca340af7..e42d98501cda6543b27d8918f00a032bb34cdd3c 100644
19--- a/cmd/main_test.go
20+++ b/cmd/main_test.go
21@@ -1,6 +1,9 @@
22 package main
23
24 import (
25+ "bytes"
26+ "context"
27+ "encoding/json"
28 "fmt"
29 "net/http"
30 "net/http/httptest"
31@@ -15,6 +18,7 @@ import (
32 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
33 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
34 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
35+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
36 )
37
38 var testConfig config.Config
39@@ -23,12 +27,12 @@ func init() {
40 testConfig = config.TestConfig()
41 }
42
43-func setupTestServer(t *testing.T, authEnabled bool) *http.Server {
44+func setupTestServer(t *testing.T, authEnabled bool) (*http.Server, db.DB) {
45 l := log.NewNopLogger()
46
47- db, err := db.NewPSQLDB(l, testConfig.DB)
48+ db, err := db.NewPostgresDB(l, testConfig.DB)
49 if !assert.NoError(t, err) {
50- return nil
51+ return nil, nil
52 }
53
54 handler := handler.New(l, db)
55@@ -39,17 +43,17 @@ func setupTestServer(t *testing.T, authEnabled bool) *http.Server {
56 auth.AuthMiddleware(db),
57 )
58 if !assert.NoError(t, err) {
59- return nil
60+ return nil, nil
61 }
62
63 return &http.Server{
64 Addr: fmt.Sprintf(":%v", testConfig.Gin.Port),
65 Handler: router,
66- }
67+ }, db
68 }
69
70 func TestHealthz(t *testing.T) {
71- server := setupTestServer(t, false)
72+ server, _ := setupTestServer(t, false)
73 if !assert.NotNil(t, server) {
74 return
75 }
76@@ -61,3 +65,24 @@ func TestHealthz(t *testing.T) {
77 assert.Equal(t, 200, w.Code)
78 assert.Equal(t, "healthy", w.Body.String())
79 }
80+
81+func TestCreateUser(t *testing.T) {
82+ server, db := setupTestServer(t, false)
83+ if !assert.NotNil(t, server) {
84+ return
85+ }
86+
87+ jsonData, err := json.Marshal(models.User{Username: "test", Password: "test"})
88+ if !assert.Nil(t, err) {
89+ return
90+ }
91+ data := bytes.NewBuffer(jsonData)
92+ req, _ := http.NewRequest("POST", "/register", data)
93+ w := httptest.NewRecorder()
94+ server.Handler.ServeHTTP(w, req)
95+
96+ assert.Equal(t, 200, w.Code)
97+ assert.Equal(t, `{"msg":"user created"}`, w.Body.String())
98+
99+ db.DeleteUser(context.TODO(), "test")
100+}
101diff --git a/db/channels.go b/db/channels.go
102new file mode 100644
103index 0000000000000000000000000000000000000000..a2caac26068cc1362dbbb6fd32eeb3a7d3983bfd
104--- /dev/null
105+++ b/db/channels.go
106@@ -0,0 +1,75 @@
107+package db
108+
109+import (
110+ "context"
111+
112+ "github.com/lib/pq"
113+
114+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
115+)
116+
117+var createChannelQuery = `INSERT INTO channels (id, name) VALUES ($1, $2)`
118+
119+func (d *postgresDB) CreateChannel(ctx context.Context, channel models.Channel) error {
120+ _, err := d.db.ExecContext(ctx, createChannelQuery, channel.ID, channel.Name)
121+ if err != nil {
122+ if pgerr, ok := err.(*pq.Error); ok {
123+ if pgerr.Code == "23505" {
124+ return ErrChannelExists
125+ }
126+ }
127+
128+ d.l.Log("level", "ERROR", "function", "db.CreateChannel", "error", err)
129+ return err
130+ }
131+
132+ return nil
133+}
134+
135+var listChannelsQuery = `SELECT id, name FROM channels`
136+
137+func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
138+ rows, err := d.db.QueryContext(ctx, listChannelsQuery)
139+ if err != nil {
140+ d.l.Log("level", "ERROR", "function", "db.ListChannels", "call", "sql.QueryContext", "error", err)
141+ return nil, err
142+ }
143+ defer rows.Close()
144+
145+ channels := make([]models.Channel, 0)
146+ for rows.Next() {
147+ var channel models.Channel
148+ err = rows.Scan(&channel.ID, &channel.Name)
149+ if err != nil {
150+ d.l.Log("level", "ERROR", "function", "db.ListChannels", "call", "sql.Scan", "error", err)
151+ return nil, err
152+ }
153+ channels = append(channels, channel)
154+ }
155+
156+ return channels, nil
157+}
158+
159+var getChannelSubscribersQuery = `SELECT username FROM user_subscriptions WHERE channel_id = $1`
160+
161+func (d *postgresDB) GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error) {
162+ rows, err := d.db.QueryContext(ctx, getChannelSubscribersQuery, channelID)
163+ if err != nil {
164+ d.l.Log("level", "ERROR", "function", "db.GetChannelSubscribers", "call", "sql.QueryContext", "error", err)
165+ return nil, err
166+ }
167+ defer rows.Close()
168+
169+ subs := make([]string, 0)
170+ for rows.Next() {
171+ var sub string
172+ err = rows.Scan(&sub)
173+ if err != nil {
174+ d.l.Log("level", "ERROR", "function", "db.GetChannelSubscribers", "call", "sql.Scan", "error", err)
175+ return nil, err
176+ }
177+ subs = append(subs, sub)
178+ }
179+
180+ return subs, nil
181+}
182diff --git a/db/db.go b/db/db.go
183index 0632c65f5827a20b6393c6b62cc3fe893caa2346..25efe7ae6cc761e5c87d66b76ace035ac7c56f30 100644
184--- a/db/db.go
185+++ b/db/db.go
186@@ -2,14 +2,41 @@ package db
187
188 import (
189 "context"
190+ "errors"
191
192 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
193 )
194
195+var (
196+ ErrChannelExists = errors.New("channel already exists")
197+ ErrAlreadySubscribed = errors.New("already subscribed to channel")
198+ ErrVideoExists = errors.New("video already exists")
199+)
200+
201 // DB represents a database layer for getting video and channel data
202 type DB interface {
203 // GetNewVideos returns unwatched videos from all channels
204- GetNewVideos(ctx context.Context, username string) ([]*models.Video, error)
205- AuthenticateUser(ctx context.Context, username string, password string) (bool, error)
206+ GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
207+ // CreateVideo adds a newly published video to the database
208+ CreateVideo(ctx context.Context, video models.Video, channelID string) error
209+
210+ // CreateChannel starts tracking a new channel and fetch new videos for it
211+ CreateChannel(ctx context.Context, channel models.Channel) error
212+ // ListChannels lists all channels from the database
213+ ListChannels(ctx context.Context) ([]models.Channel, error)
214+ // GetChannelSubscribers lists all channels from the database
215+ GetChannelSubscribers(ctx context.Context, channelID string) ([]string, error)
216+
217+ // AuthenticateUser verifies a user's password against a hashed value
218+ AuthenticateUser(ctx context.Context, user models.User) (bool, error)
219+ // CreateUser registers a new user in the database
220 CreateUser(ctx context.Context, user models.User) error
221+ // DeleteUser registers a new user in the database
222+ DeleteUser(ctx context.Context, username string) error
223+ // SubscribeUserToChannel will start showing new videos for that channel to the user
224+ SubscribeUserToChannel(ctx context.Context, username string, channelID string) error
225+ // AddVideoToUser will list the video in the users feed
226+ AddVideoToUser(ctx context.Context, username string, videoID string) error
227+ // WatchVideo marks a video as watched so it no longer shows in the feed
228+ WatchVideo(ctx context.Context, username string, videoID string) error
229 }
230diff --git a/db/psql.go b/db/psql.go
231index 7045e05f625705a16fbc898f364c16ba98e19d62..601740b0851563fa4ea2e6f0486042c672dbdd14 100644
232--- a/db/psql.go
233+++ b/db/psql.go
234@@ -9,18 +9,18 @@ import (
235 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
236 )
237
238-type psqlDB struct {
239+type postgresDB struct {
240 l log.Logger
241 db *sql.DB
242 }
243
244-func NewPSQLDB(log log.Logger, dbCfg ytrssilConfig.DB) (*psqlDB, error) {
245+func NewPostgresDB(log log.Logger, dbCfg ytrssilConfig.DB) (*postgresDB, error) {
246 db, err := sql.Open("postgres", dbCfg.DBURI)
247 if err != nil {
248 return nil, err
249 }
250
251- return &psqlDB{
252+ return &postgresDB{
253 l: log,
254 db: db,
255 }, nil
256diff --git a/db/users.go b/db/users.go
257index 993da9265052b1d20ba3ba49a61179182c9257d4..740b48e9947c1189facd3377bf456b36eeba6c6d 100644
258--- a/db/users.go
259+++ b/db/users.go
260@@ -2,27 +2,27 @@ package db
261
262 import (
263 "context"
264- "fmt"
265
266 "github.com/alexedwards/argon2id"
267- "github.com/georgysavva/scany/v2/sqlscan"
268+ "github.com/lib/pq"
269
270 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
271 )
272
273-var authenticateUserQuery = `SELECT username, password FROM users WHERE username = $1`
274+var authenticateUserQuery = `SELECT password FROM users WHERE username = $1`
275
276-func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password string) (bool, error) {
277- var user []*models.User
278- err := sqlscan.Select(ctx, d.db, &user, authenticateUserQuery, username)
279+func (d *postgresDB) AuthenticateUser(ctx context.Context, user models.User) (bool, error) {
280+ row := d.db.QueryRowContext(ctx, authenticateUserQuery, user.Username)
281+ var hashedPassword string
282+ err := row.Scan(&hashedPassword)
283 if err != nil {
284- d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to query user", "error", err)
285+ d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err)
286 return false, err
287 }
288
289- match, err := argon2id.ComparePasswordAndHash(password, user[0].Password)
290+ match, err := argon2id.ComparePasswordAndHash(user.Password, hashedPassword)
291 if err != nil {
292- d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "msg", "failed to check hashed passsword", "error", err)
293+ d.l.Log("level", "ERROR", "function", "db.AuthenticateUser", "error", err)
294 return false, err
295 }
296
297@@ -31,21 +31,64 @@ func (d *psqlDB) AuthenticateUser(ctx context.Context, username string, password
298
299 var createUserQuery = `INSERT INTO users (username, password) VALUES ($1, $2)`
300
301-func (d *psqlDB) CreateUser(ctx context.Context, user models.User) error {
302- res, err := d.db.Exec(createUserQuery, user.Username, user.Password)
303+func (d *postgresDB) CreateUser(ctx context.Context, user models.User) error {
304+ _, err := d.db.ExecContext(ctx, createUserQuery, user.Username, user.Password)
305 if err != nil {
306- d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to create user", "error", err)
307+ d.l.Log("level", "ERROR", "function", "db.CreateUser", "error", err)
308 return err
309 }
310- affected, err := res.RowsAffected()
311+
312+ return nil
313+}
314+
315+var deleteUserQuery = `DELETE FROM users WHERE username = $1`
316+
317+func (d *postgresDB) DeleteUser(ctx context.Context, username string) error {
318+ _, err := d.db.ExecContext(ctx, deleteUserQuery, username)
319+ if err != nil {
320+ d.l.Log("level", "ERROR", "function", "db.DeleteUser", "error", err)
321+ return err
322+ }
323+
324+ return nil
325+}
326+
327+var subscribeUserToChannelQuery = `INSERT INTO user_subscriptions (username, channel_id) VALUES ($1, $2)`
328+
329+func (d *postgresDB) SubscribeUserToChannel(ctx context.Context, username string, channelID string) error {
330+ _, err := d.db.ExecContext(ctx, subscribeUserToChannelQuery, username, channelID)
331 if err != nil {
332- d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err)
333+ if pgerr, ok := err.(*pq.Error); ok {
334+ if pgerr.Code == "23505" {
335+ return ErrAlreadySubscribed
336+ }
337+ }
338+ d.l.Log("level", "ERROR", "function", "db.SubscribeUserToChannel", "error", err)
339 return err
340 }
341
342- if affected != 1 {
343- d.l.Log("level", "ERROR", "function", "db.CreateUser", "msg", "failed to get affected row count", "error", err)
344- return fmt.Errorf("expected to insert one row, but %d were inserted", affected)
345+ return nil
346+}
347+
348+var addVideoToUserQuery = `INSERT INTO user_videos (username, video_id) VALUES ($1, $2)`
349+
350+func (d *postgresDB) AddVideoToUser(ctx context.Context, username string, videoID string) error {
351+ _, err := d.db.ExecContext(ctx, addVideoToUserQuery, username, videoID)
352+ if err != nil {
353+ d.l.Log("level", "ERROR", "function", "db.AddVideoToUser", "error", err)
354+ return err
355+ }
356+
357+ return nil
358+}
359+
360diff --git a/db/videos.go b/db/videos.go
361index ebe995edbc00fd2a0ddcc0297cd022b2f2a3f981..47f1cb10ff53648bd38ff364872446dc94bfe0df 100644
362--- a/db/videos.go
363+++ b/db/videos.go
364@@ -3,9 +3,8 @@ package db
365 import (
366 "context"
367
368- "github.com/georgysavva/scany/v2/sqlscan"
369-
370 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
371+ "github.com/lib/pq"
372 )
373
374 var getNewVideosQuery = `
375@@ -25,13 +24,47 @@ var getNewVideosQuery = `
376 ORDER BY published_timestamp
377 `
378
379-func (d *psqlDB) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
380- var videos []*models.Video
381- err := sqlscan.Select(ctx, d.db, &videos, getNewVideosQuery, username)
382+func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
383+ rows, err := d.db.QueryContext(ctx, getNewVideosQuery, username)
384 if err != nil {
385- d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "msg", "failed to query new videos", "error", err)
386+ d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "call", "sql.QueryContext", "error", err)
387 return nil, err
388 }
389+ defer rows.Close()
390+
391+ videos := make([]models.Video, 0)
392+ for rows.Next() {
393+ var video models.Video
394+ err = rows.Scan(
395+ &video.ID,
396+ &video.Title,
397+ &video.PublishedTime,
398+ &video.WatchTime,
399+ &video.ChannelName,
400+ )
401+ if err != nil {
402+ d.l.Log("level", "ERROR", "function", "db.GetNewVideos", "call", "sql.Scan", "error", err)
403+ return nil, err
404+ }
405+ videos = append(videos, video)
406+ }
407
408 return videos, nil
409 }
410+
411+var createVideoQuery = `INSERT INTO videos (id, title, published_timestamp, channel_id) VALUES ($1, $2, $3, $4)`
412+
413+func (d *postgresDB) CreateVideo(ctx context.Context, video models.Video, channelID string) error {
414+ _, err := d.db.ExecContext(ctx, createVideoQuery, video.ID, video.Title, video.PublishedTime, channelID)
415+ if err != nil {
416+ if pgerr, ok := err.(*pq.Error); ok {
417+ if pgerr.Code == "23505" {
418+ return ErrVideoExists
419+ }
420+ }
421+ d.l.Log("level", "ERROR", "function", "db.CreateVideo", "call", "sql.Exec", "error", err)
422+ return err
423+ }
424+
425+ return nil
426+}
427diff --git a/feedparser/date.go b/feedparser/date.go
428new file mode 100644
429index 0000000000000000000000000000000000000000..90594328138755d655481b0f6c9972f9cdc7be71
430--- /dev/null
431+++ b/feedparser/date.go
432@@ -0,0 +1,13 @@
433+package feedparser
434+
435+import (
436+ "time"
437+)
438+
439+// Date type
440+type Date string
441+
442+// Parse (Date function) and returns Time, error
443+func (d Date) Parse() (time.Time, error) {
444+ return time.Parse(time.RFC3339, string(d)) // ISO8601
445+}
446diff --git a/feedparser/feedparser.go b/feedparser/feedparser.go
447new file mode 100644
448index 0000000000000000000000000000000000000000..afebb54e5432cf6e0c813e68bdc849829edfe4ed
449--- /dev/null
450+++ b/feedparser/feedparser.go
451@@ -0,0 +1,74 @@
452+package feedparser
453+
454+import (
455+ "encoding/xml"
456+ "errors"
457+ "fmt"
458+ "io"
459+ "net/http"
460+
461+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
462+ "github.com/paulrosania/go-charset/charset"
463+)
464+
465+var (
466+ ErrInvalidChannelID = errors.New("invalid channel ID")
467+ ErrParseFailed = errors.New("failed to parse feed")
468+)
469+
470+var urlFormat = "https://www.youtube.com/feeds/videos.xml?channel_id=%s"
471+
472+// Video struct for each video in the feed
473+type Video struct {
474+ ID string `xml:"id"`
475+ Title string `xml:"title"`
476+ Published Date `xml:"published"`
477+}
478+
479+// Channel struct for RSS
480+type Channel struct {
481+ Name string `xml:"title"`
482+ Videos []Video `xml:"entry"`
483+}
484+
485+func read(l log.Logger, url string) (io.ReadCloser, error) {
486+ req, err := http.NewRequest("GET", url, nil)
487+ if err != nil {
488+ l.Log("level", "ERROR", "function", "feedparser.read", "call", "http.NewRequest", "error", err)
489+ return nil, err
490+ }
491+
492+ response, err := http.DefaultClient.Do(req)
493+ if err != nil {
494+ l.Log("level", "ERROR", "function", "feedparser.read", "call", "http.Do", "error", err)
495+ return nil, err
496+ }
497+
498+ if response.StatusCode == http.StatusNotFound {
499+ return nil, ErrInvalidChannelID
500+ } else if response.StatusCode != http.StatusOK {
501+ return nil, fmt.Errorf("failed to get feed with status %d", response.StatusCode)
502+ }
503+
504+ return response.Body, nil
505+}
506+
507+// Parse parses a YouTube channel XML feed from a channel ID
508+func Parse(l log.Logger, channelID string) (*Channel, error) {
509+ url := fmt.Sprintf(urlFormat, channelID)
510+ reader, err := read(l, url)
511+ if err != nil {
512+ return nil, err
513+ }
514+
515+ defer reader.Close()
516+ xmlDecoder := xml.NewDecoder(reader)
517+ xmlDecoder.CharsetReader = charset.NewReader
518+
519+ var channel Channel
520+ if err := xmlDecoder.Decode(&channel); err != nil {
521+ l.Log("level", "ERROR", "function", "feedparser.read", "call", "xml.Decode", "error", err)
522+ return nil, fmt.Errorf("%w: %s", ErrParseFailed, err.Error())
523+ }
524+ return &channel, nil
525+}
526diff --git a/go.mod b/go.mod
527index 3ecac63c2506b9236a5931a8c508c30f35378b1e..e30c89370bc17a1d37a909242bf229f76ef126de 100644
528--- a/go.mod
529+++ b/go.mod
530@@ -4,11 +4,11 @@ go 1.19
531
532 require (
533 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
534- github.com/georgysavva/scany/v2 v2.0.0-alpha.3
535 github.com/gin-gonic/gin v1.8.1
536 github.com/go-kit/log v0.2.1
537 github.com/jessevdk/go-flags v1.5.0
538 github.com/lib/pq v1.10.7
539+ github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c
540 github.com/stretchr/testify v1.8.0
541 )
542
543@@ -21,7 +21,6 @@ require (
544 github.com/go-playground/validator/v10 v10.11.0 // indirect
545 github.com/goccy/go-json v0.9.11 // indirect
546 github.com/google/go-cmp v0.5.8 // indirect
547- github.com/jackc/pgx/v5 v5.0.3 // indirect
548 github.com/json-iterator/go v1.1.12 // indirect
549 github.com/leodido/go-urn v1.2.1 // indirect
550 github.com/mattn/go-isatty v0.0.16 // indirect
551@@ -29,6 +28,7 @@ require (
552 github.com/modern-go/reflect2 v1.0.2 // indirect
553 github.com/pelletier/go-toml/v2 v2.0.5 // indirect
554 github.com/pmezard/go-difflib v1.0.0 // indirect
555+ github.com/rogpeppe/go-internal v1.9.0 // indirect
556 github.com/ugorji/go/codec v1.2.7 // indirect
557 golang.org/x/crypto v0.1.0 // indirect
558 golang.org/x/net v0.1.0 // indirect
559diff --git a/go.sum b/go.sum
560index 72d3f1ddd805a8f525478f584b3a555ab67842f2..6f3783e8cf1db13aea5d4392c2ab4947ab00dad6 100644
561--- a/go.sum
562+++ b/go.sum
563@@ -1,12 +1,9 @@
564 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387 h1:loy0fjI90vF44BPW4ZYOkE3tDkGTy7yHURusOJimt+I=
565 github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387/go.mod h1:GuR5j/NW7AU7tDAQUDGCtpiPxWIOy/c3kiRDnlwiCHc=
566-github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs=
567 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
568 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
569 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
570 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
571-github.com/georgysavva/scany/v2 v2.0.0-alpha.3 h1:+n7kJr/xyVQJcsE2ICn0jd/a4QaxaWnx+a+GJQTGRrQ=
572-github.com/georgysavva/scany/v2 v2.0.0-alpha.3/go.mod h1:sigOdh+0qb/+aOs3TVhehVT10p8qJL7K/Zhyz8vWo38=
573 github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
574 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
575 github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
576@@ -25,17 +22,11 @@ github.com/go-playground/validator/v10 v10.11.0 h1:0W+xRM511GY47Yy3bZUbJVitCNg2B
577 github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
578 github.com/goccy/go-json v0.9.11 h1:/pAaQDLHEoCq/5FFmSKBswWmK6H0e8g4159Kc/X/nqk=
579 github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
580-github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
581 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
582 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
583 github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
584 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
585 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
586-github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
587-github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
588-github.com/jackc/pgx/v5 v5.0.3 h1:4flM5ecR/555F0EcnjdaZa6MhBU+nr0QbZIo5vaKjuM=
589-github.com/jackc/pgx/v5 v5.0.3/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o=
590-github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc=
591 github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
592 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
593 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
594@@ -59,17 +50,18 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
595 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
596 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
597 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
598+github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c h1:P6XGcuPTigoHf4TSu+3D/7QOQ1MbL6alNwrGhcW7sKw=
599+github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4=
600 github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg=
601 github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas=
602 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
603-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
604 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
605 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
606 github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
607 github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
608 github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
609+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
610 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
611-github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
612 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
613 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
614 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
615diff --git a/handler/handler.go b/handler/handler.go
616index d1dbdfbfbe224046ba2025bf74ee8321e104a54d..cac613ea927b24c9ca2d5f7750f3df1a37d413c9 100644
617--- a/handler/handler.go
618+++ b/handler/handler.go
619@@ -2,17 +2,22 @@ package handler
620
621 import (
622 "context"
623+ "errors"
624+ "strings"
625
626 "github.com/alexedwards/argon2id"
627
628 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
629+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
630 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/lib/log"
631 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
632 )
633
634 type Handler interface {
635 CreateUser(ctx context.Context, user models.User) error
636- GetNewVideos(ctx context.Context, username string) ([]*models.Video, error)
637+ SubscribeToChannel(ctx context.Context, username string, channelID string) error
638+ GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
639+ FetchVideos(ctx context.Context) error
640 }
641
642 type handler struct {
643@@ -34,6 +39,89 @@ func (h *handler) CreateUser(ctx context.Context, user models.User) error {
644 return h.db.CreateUser(ctx, user)
645 }
646
647-func (h *handler) GetNewVideos(ctx context.Context, username string) ([]*models.Video, error) {
648+func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error {
649+ parsedChannel, err := feedparser.Parse(h.log, channelID)
650+ if err != nil {
651+ return err
652+ }
653+
654+ channel := models.Channel{
655+ ID: channelID,
656+ Name: parsedChannel.Name,
657+ }
658+
659+ err = h.db.CreateChannel(ctx, channel)
660+ if !errors.Is(err, db.ErrChannelExists) {
661+ return err
662+ }
663+
664+ return h.db.SubscribeUserToChannel(ctx, username, channelID)
665+}
666+
667+func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
668 return h.db.GetNewVideos(ctx, username)
669 }
670+
671+func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string, videoID string) error {
672+ subs, err := h.db.GetChannelSubscribers(ctx, channelID)
673+ if err != nil {
674+ h.log.Log("level", "ERROR", "call", "db.GetChannelSubscribers", "err", err)
675+ return err
676+ }
677+
678+ for _, sub := range subs {
679+ err = h.db.AddVideoToUser(ctx, sub, videoID)
680+ if err != nil {
681+ h.log.Log("level", "ERROR", "call", "db.AddVideoToUser", "err", err)
682+ continue
683+ }
684+ }
685+
686+ return nil
687+}
688+
689+func (h *handler) fetchVideosForChannel(ctx context.Context, channelID string, parsedChannel *feedparser.Channel) {
690+ for _, parsedVideo := range parsedChannel.Videos {
691+ date, err := parsedVideo.Published.Parse()
692+ if err != nil {
693+ h.log.Log("level", "WARNING", "call", "feedparser.Parse", "err", err)
694+ continue
695+ }
696+
697+ id := strings.Split(parsedVideo.ID, ":")[2]
698+ video := models.Video{
699+ ID: id,
700+ Title: parsedVideo.Title,
701+ PublishedTime: date,
702+ }
703+ err = h.db.CreateVideo(ctx, video, channelID)
704+ if err != nil {
705+ if !errors.Is(err, db.ErrVideoExists) {
706+ h.log.Log("level", "WARNING", "call", "db.CreateVideo", "err", err)
707+ }
708+ continue
709+ }
710+ err = h.addVideoToAllSubscribers(ctx, channelID, id)
711+ if err != nil {
712+ continue
713+ }
714+ }
715+}
716+
717+func (h *handler) FetchVideos(ctx context.Context) error {
718+ channels, err := h.db.ListChannels(ctx)
719diff --git a/handler/handler_test.go b/handler/handler_test.go
720index 76ad44e43f0c24f9f047468c6cfa962020426125..c9bd1472a1370ad04f50e6ec0ad1a9cbca60489d 100644
721--- a/handler/handler_test.go
722+++ b/handler/handler_test.go
723@@ -24,10 +24,10 @@ func TestGetNewVideos(t *testing.T) {
724 l := log.NewNopLogger()
725
726 handler := New(l, &db_mock.DBMock{
727- GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) {
728- return []*models.Video{
729+ GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
730+ return []models.Video{
731 {
732- VideoID: "test",
733+ ID: "test",
734 ChannelName: "test",
735 Title: "test",
736 PublishedTime: time.Now(),
737@@ -43,7 +43,7 @@ func TestGetNewVideos(t *testing.T) {
738 // Assert
739 if assert.NoError(t, err) {
740 if assert.NotNil(t, resp) {
741- assert.Equal(t, resp[0].VideoID, "test")
742+ assert.Equal(t, resp[0].ID, "test")
743 assert.Equal(t, resp[0].Title, "test")
744 }
745 }
746diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go
747index dbce9a38fd76aeeff8bab043f978cda357eed3f0..16dbe7883359136c388d7c511f2d988c4289e715 100644
748--- a/httpserver/auth/auth.go
749+++ b/httpserver/auth/auth.go
750@@ -6,6 +6,7 @@ import (
751 "github.com/gin-gonic/gin"
752
753 "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
754+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
755 )
756
757 // AuthMiddleware will authenticate against a static API key
758@@ -16,7 +17,11 @@ func AuthMiddleware(db db.DB) gin.HandlerFunc {
759 c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid basic auth header"})
760 return
761 }
762- authenticated, err := db.AuthenticateUser(c.Request.Context(), username, password)
763+ user := models.User{
764+ Username: username,
765+ Password: password,
766+ }
767+ authenticated, err := db.AuthenticateUser(c.Request.Context(), user)
768 if err != nil {
769 c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
770 return
771diff --git a/httpserver/auth/auth_test.go b/httpserver/auth/auth_test.go
772index 33dee70b866eed94d03f4439711f6e6412f7a289..4b8f814503aac3da02687e969ed5821108255d43 100644
773--- a/httpserver/auth/auth_test.go
774+++ b/httpserver/auth/auth_test.go
775@@ -6,15 +6,17 @@ import (
776 "net/http/httptest"
777 "testing"
778
779- db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
780 "github.com/gin-gonic/gin"
781 "github.com/stretchr/testify/assert"
782+
783+ db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
784+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
785 )
786
787 func setupTestServer() *http.Server {
788 db := &db_mock.DBMock{
789- AuthenticateUserFunc: func(ctx context.Context, username, password string) (bool, error) {
790- return username == "username" && password == "password", nil
791+ AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) {
792+ return user.Username == "username" && user.Password == "password", nil
793 },
794 }
795
796@@ -37,7 +39,7 @@ func TestSuccessfulAuthentication(t *testing.T) {
797
798 w := httptest.NewRecorder()
799 req, _ := http.NewRequest("GET", "/", nil)
800- req.Header.Add("Authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") // username:password
801+ req.SetBasicAuth("username", "password") // Valid credentials
802 server.Handler.ServeHTTP(w, req)
803
804 assert.Equal(t, http.StatusOK, w.Code)
805@@ -52,7 +54,7 @@ func TestMissingAuthorizationHeader(t *testing.T) {
806 server.Handler.ServeHTTP(w, req)
807
808 assert.Equal(t, http.StatusUnauthorized, w.Code)
809- assert.Equal(t, `{"error":"invalid authorization header"}`, w.Body.String())
810+ assert.Equal(t, `{"error":"invalid basic auth header"}`, w.Body.String())
811 }
812
813 func TestWrongCredentials(t *testing.T) {
814@@ -60,9 +62,9 @@ func TestWrongCredentials(t *testing.T) {
815
816 w := httptest.NewRecorder()
817 req, _ := http.NewRequest("GET", "/", nil)
818- req.Header.Add("Authorization", "Basic d3Jvbmc=")
819+ req.SetBasicAuth("test", "test") // Invalid credentials
820 server.Handler.ServeHTTP(w, req)
821
822 assert.Equal(t, http.StatusUnauthorized, w.Code)
823- assert.Equal(t, `{"error":"invalid API Key"}`, w.Body.String())
824+ assert.Equal(t, `{"error":"invalid username or password"}`, w.Body.String())
825 }
826diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
827index a0c6d6da11351cf301367aa33aa28049755263d6..4f5ac66138a251096eab0cfe2045e6a8634ba989 100644
828--- a/httpserver/ytrssil/server.go
829+++ b/httpserver/ytrssil/server.go
830@@ -37,12 +37,14 @@ func SetupGinRouter(l log.Logger, handler handler.Handler, authMiddleware func(c
831 }
832 engine.GET("/healthz", srv.Healthz)
833 engine.POST("/register", srv.CreateUser)
834+ engine.POST("/fetch", srv.FetchVideos)
835
836 // all APIs go in this routing group and require authentication
837 api := engine.Group("/api")
838 api.Use(authMiddleware)
839 {
840 api.GET("videos/new", srv.GetNewVideos)
841+ api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel)
842 }
843
844 return engine, nil
845diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go
846index b4c5be9ef4aad9d2304388f06b2de25611d090dd..69388801fab6166945158b9d19ab3789c7512280 100644
847--- a/httpserver/ytrssil/users.go
848+++ b/httpserver/ytrssil/users.go
849@@ -1,10 +1,14 @@
850 package ytrssil
851
852 import (
853+ "errors"
854 "net/http"
855
856- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
857 "github.com/gin-gonic/gin"
858+
859+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
860+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
861+ "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
862 )
863
864 func (s *server) CreateUser(c *gin.Context) {
865@@ -23,3 +27,30 @@ func (s *server) CreateUser(c *gin.Context) {
866
867 c.JSON(http.StatusOK, gin.H{"msg": "user created"})
868 }
869+
870+func (s *server) SubscribeToChannel(c *gin.Context) {
871+ var channel models.Channel
872+ err := c.BindUri(&channel)
873+ if err != nil {
874+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
875+ return
876+ }
877+ username := c.GetString("username")
878+
879+ err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
880+ if err != nil {
881+ if errors.Is(err, db.ErrAlreadySubscribed) {
882+ c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
883+ return
884+ }
885+ if errors.Is(err, feedparser.ErrInvalidChannelID) {
886+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
887+ return
888+ }
889+
890+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
891+ return
892+ }
893+
894+ c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"})
895+}
896diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
897index e3ba2a02decbd4c023cfbe6dbb58a7226092aabf..9aaab82bca89ea6ca6ee0702e95141733fb9ad3c 100644
898--- a/httpserver/ytrssil/videos.go
899+++ b/httpserver/ytrssil/videos.go
900@@ -19,3 +19,13 @@ func (s *server) GetNewVideos(c *gin.Context) {
901 Videos: videos,
902 })
903 }
904+
905+func (s *server) FetchVideos(c *gin.Context) {
906+ err := s.handler.FetchVideos(c.Request.Context())
907+ if err != nil {
908+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
909+ return
910+ }
911+
912+ c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"})
913+}
914diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql
915index 0205bcb86a5095c48e03d2dc9a21d2f6a50f8331..09adbdb73ac7e3046dcb30efcbe41aad8a579889 100644
916--- a/migrations/000001_init.down.sql
917+++ b/migrations/000001_init.down.sql
918@@ -1,3 +1,4 @@
919+DROP TABLE IF EXISTS user_subscriptions;
920 DROP TABLE IF EXISTS user_videos;
921 DROP TABLE IF EXISTS users;
922 DROP TABLE IF EXISTS videos;
923diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql
924index fe887b1bb7cfccc754dd9f2b1d449e8273abdc08..fed133ac9841c4e6614241104441f99ef0316ec0 100644
925--- a/migrations/000001_init.up.sql
926+++ b/migrations/000001_init.up.sql
927@@ -1,24 +1,29 @@
928 CREATE TABLE IF NOT EXISTS channels (
929- id text NOT NULL PRIMARY KEY,
930- name text NOT NULL,
931- feed_url text NOT NULL
932+ id text NOT NULL PRIMARY KEY
933+ , name text NOT NULL
934 );
935
936 CREATE TABLE IF NOT EXISTS videos (
937- id text NOT NULL PRIMARY KEY,
938- title text NOT NULL,
939- published_timestamp timestamp with time zone NOT NULL,
940- channel_id text NOT NULL REFERENCES channels(id)
941+ id text NOT NULL PRIMARY KEY
942+ , title text NOT NULL
943+ , published_timestamp timestamp with time zone NOT NULL
944+ , channel_id text NOT NULL REFERENCES channels(id)
945 );
946
947 CREATE TABLE IF NOT EXISTS users (
948- username text NOT NULL PRIMARY KEY,
949- password text NOT NULL
950+ username text NOT NULL PRIMARY KEY
951+ , password text NOT NULL
952 );
953
954 CREATE TABLE IF NOT EXISTS user_videos (
955- id SERIAL PRIMARY KEY,
956- username text NOT NULL REFERENCES users(username),
957- video_id text NOT NULL REFERENCES videos(id),
958- watch_timestamp timestamp with time zone
959+ username text NOT NULL REFERENCES users(username)
960+ , video_id text NOT NULL REFERENCES videos(id)
961+ , watch_timestamp timestamp with time zone
962+ , CONSTRAINT user_videos_pkey PRIMARY KEY (username, video_id)
963+);
964+
965+CREATE TABLE IF NOT EXISTS user_subscriptions (
966+ username text NOT NULL REFERENCES users(username)
967+ , channel_id text NOT NULL REFERENCES channels(id)
968+ , CONSTRAINT user_subscriptions_pkey PRIMARY KEY (channel_id, username)
969 );
970diff --git a/mocks/db/db.go b/mocks/db/db.go
971index e2ed9ca7a9469f8df886ebc215a4affbbbe61234..d88b2e151bdd5dc13bf9e1dc6a9e66c54710ed53 100644
972--- a/mocks/db/db.go
973+++ b/mocks/db/db.go
974@@ -20,15 +20,39 @@ var _ db.DB = &DBMock{}
975 //
976 // // make and configure a mocked db.DB
977 // mockedDB := &DBMock{
978-// AuthenticateUserFunc: func(ctx context.Context, username string, password string) (bool, error) {
979+// AddVideoToUserFunc: func(ctx context.Context, username string, videoID string) error {
980+// panic("mock out the AddVideoToUser method")
981+// },
982+// AuthenticateUserFunc: func(ctx context.Context, user models.User) (bool, error) {
983 // panic("mock out the AuthenticateUser method")
984 // },
985+// CreateChannelFunc: func(ctx context.Context, channel models.Channel) error {
986+// panic("mock out the CreateChannel method")
987+// },
988 // CreateUserFunc: func(ctx context.Context, user models.User) error {
989 // panic("mock out the CreateUser method")
990 // },
991-// GetNewVideosFunc: func(ctx context.Context, username string) ([]*models.Video, error) {
992+// CreateVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
993+// panic("mock out the CreateVideo method")
994+// },
995+// DeleteUserFunc: func(ctx context.Context, username string) error {
996+// panic("mock out the DeleteUser method")
997+// },
998+// GetChannelSubscribersFunc: func(ctx context.Context, channelID string) ([]string, error) {
999+// panic("mock out the GetChannelSubscribers method")
1000+// },
1001+// GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
1002 // panic("mock out the GetNewVideos method")
1003 // },
1004+// ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
1005+// panic("mock out the ListChannels method")
1006+// },
1007+// SubscribeUserToChannelFunc: func(ctx context.Context, username string, channelID string) error {
1008+// panic("mock out the SubscribeUserToChannel method")
1009+// },
1010+// WatchVideoFunc: func(ctx context.Context, username string, videoID string) error {
1011+// panic("mock out the WatchVideo method")
1012+// },
1013 // }
1014 //
1015 // // use mockedDB in code that requires db.DB
1016@@ -36,25 +60,63 @@ var _ db.DB = &DBMock{}
1017 //
1018 // }
1019 type DBMock struct {
1020+ // AddVideoToUserFunc mocks the AddVideoToUser method.
1021+ AddVideoToUserFunc func(ctx context.Context, username string, videoID string) error
1022+
1023 // AuthenticateUserFunc mocks the AuthenticateUser method.
1024- AuthenticateUserFunc func(ctx context.Context, username string, password string) (bool, error)
1025+ AuthenticateUserFunc func(ctx context.Context, user models.User) (bool, error)
1026+
1027+ // CreateChannelFunc mocks the CreateChannel method.
1028+ CreateChannelFunc func(ctx context.Context, channel models.Channel) error
1029
1030 // CreateUserFunc mocks the CreateUser method.
1031 CreateUserFunc func(ctx context.Context, user models.User) error
1032
1033+ // CreateVideoFunc mocks the CreateVideo method.
1034+ CreateVideoFunc func(ctx context.Context, video models.Video, channelID string) error
1035+
1036+ // DeleteUserFunc mocks the DeleteUser method.
1037+ DeleteUserFunc func(ctx context.Context, username string) error
1038+
1039+ // GetChannelSubscribersFunc mocks the GetChannelSubscribers method.
1040+ GetChannelSubscribersFunc func(ctx context.Context, channelID string) ([]string, error)
1041+
1042 // GetNewVideosFunc mocks the GetNewVideos method.
1043- GetNewVideosFunc func(ctx context.Context, username string) ([]*models.Video, error)
1044+ GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
1045+
1046+ // ListChannelsFunc mocks the ListChannels method.
1047+ ListChannelsFunc func(ctx context.Context) ([]models.Channel, error)
1048+
1049+ // SubscribeUserToChannelFunc mocks the SubscribeUserToChannel method.
1050+ SubscribeUserToChannelFunc func(ctx context.Context, username string, channelID string) error
1051+
1052+ // WatchVideoFunc mocks the WatchVideo method.
1053+ WatchVideoFunc func(ctx context.Context, username string, videoID string) error
1054
1055 // calls tracks calls to the methods.
1056 calls struct {
1057- // AuthenticateUser holds details about calls to the AuthenticateUser method.
1058- AuthenticateUser []struct {
1059+ // AddVideoToUser holds details about calls to the AddVideoToUser method.
1060+ AddVideoToUser []struct {
1061 // Ctx is the ctx argument value.
1062 Ctx context.Context
1063 // Username is the username argument value.
1064 Username string
1065- // Password is the password argument value.
1066- Password string
1067+ // VideoID is the videoID argument value.
1068+ VideoID string
1069+ }
1070+ // AuthenticateUser holds details about calls to the AuthenticateUser method.
1071+ AuthenticateUser []struct {
1072+ // Ctx is the ctx argument value.
1073+ Ctx context.Context
1074diff --git a/models/channel.go b/models/channel.go
1075index 0d779cb22e1e3728e0727d316647526f3a032fe3..095ae8b725b11213ef5af87d10496633c3c70245 100644
1076--- a/models/channel.go
1077+++ b/models/channel.go
1078@@ -2,9 +2,7 @@ package models
1079
1080 type Channel struct {
1081 // YouTube ID of the channel
1082- ChannelID string `json:"channel_id" dynamodbav:"channel_id"`
1083+ ID string `json:"channel_id" uri:"channel_id" binding:"required"`
1084 // Name of the channel
1085- Name string `json:"name" dynamodbav:"name"`
1086- // Feed is the URL for the RSS feed
1087- FeedURL string `json:"feed_url" dynamodbav:"feed_url"`
1088+ Name string `json:"name"`
1089 }
1090diff --git a/models/http.go b/models/http.go
1091index c907f98ea25ff66d8cf4022fe9d6fe301fc764e2..d59ad499aaf2b916498d80c948c4595aadb0756e 100644
1092--- a/models/http.go
1093+++ b/models/http.go
1094@@ -1,5 +1,5 @@
1095 package models
1096
1097 type GetNewVideosResponse struct {
1098- Videos []*Video `json:"videos"`
1099+ Videos []Video `json:"videos"`
1100 }
1101diff --git a/models/video.go b/models/video.go
1102index b3a3fb1bc1ee5837c4bd71f5df683abc22eac915..581795ad1a77f8b4869299e5784f7a8b35c185f6 100644
1103--- a/models/video.go
1104+++ b/models/video.go
1105@@ -6,13 +6,13 @@ import (
1106
1107 type Video struct {
1108 // YouTube ID of the video
1109- VideoID string `json:"video_id" db:"video_id"`
1110+ ID string `json:"video_id"`
1111 // Name of the channel the video belongs to
1112- ChannelName string `json:"channel_name" db:"channel_name"`
1113+ ChannelName string `json:"channel_name"`
1114 // Title of the video
1115- Title string `json:"title" db:"title"`
1116+ Title string `json:"title"`
1117 // Video publish timestamp
1118- PublishedTime time.Time `json:"published_timestamp" db:"published_timestamp"`
1119+ PublishedTime time.Time `json:"published_timestamp"`
1120 // Video watch timestamp
1121- WatchTime *time.Time `json:"watch_timestamp" db:"watch_timestamp"`
1122+ WatchTime *time.Time `json:"watch_timestamp"`
1123 }