fa2a3cccec7ecad6ca580f355b6b521e6f4087f9

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

Message

Parse feeds and fetch new videos

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 }