462f74f1418c64d38dcadbf7186c1571a6020d46

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Track video watch progress

Diff

This diff is truncated to protect this page.

  1diff --git a/.gitignore b/.gitignore
  2index 06b499509c8a359b8ed98a137c71f61b9b4b7657..80f7f4b1607b156770526a7cb39ef1c83a6a10ac 100644
  3--- a/.gitignore
  4+++ b/.gitignore
  5@@ -18,7 +18,7 @@ debug
  6 *.orig
  7 
  8 # Local env files
  9-*.env
 10+.env
 11 
 12 # Build artifacts and cache
 13 **/bin
 14diff --git a/cmd/main.go b/cmd/main.go
 15index ef0a991aa8e25d311446a3006cd5438ebb15f107..c45e085be7f2abdb79de5275ac85108f81db4675 100644
 16--- a/cmd/main.go
 17+++ b/cmd/main.go
 18@@ -60,7 +60,7 @@ func main() {
 19 		return
 20 	}
 21 	parser := feedparser.NewParser(logger)
 22-	handler := handler.New(logger, db, parser)
 23+	handler := handler.New(logger, cfg, db, parser)
 24 	gin.SetMode(gin.ReleaseMode)
 25 	router, err := ytrssil.SetupGinRouter(
 26 		logger,
 27diff --git a/config/config.go b/config/config.go
 28index 9374ea34f7a9a4d225f0ae1c25303f3ec3d57116..25c2dc4bab48f28e252921c2c8f39e652c1119e6 100644
 29--- a/config/config.go
 30+++ b/config/config.go
 31@@ -1,6 +1,7 @@
 32 package config
 33 
 34 import (
 35+	"fmt"
 36 	"os"
 37 	"strings"
 38 
 39@@ -8,9 +9,10 @@ import (
 40 )
 41 
 42 type Config struct {
 43-	Port      int    `long:"port" env:"PORT" default:"8080"`
 44-	DBURI     string `long:"db-uri" env:"DB_URI"`
 45-	AuthToken string `long:"auth-token" env:"AUTH_TOKEN"`
 46+	Port          int    `long:"port" env:"PORT" default:"8080"`
 47+	DBURI         string `long:"db-uri" env:"DB_URI"`
 48+	AuthToken     string `long:"auth-token" env:"AUTH_TOKEN"`
 49+	YouTubeAPIKey string `long:"youtube-api-key" env:"YOUTUBE_API_KEY"`
 50 }
 51 
 52 func getenvOrDefault(key string, defaultValue string) string {
 53@@ -27,7 +29,18 @@ func Parse() (Config, error) {
 54 	var config Config
 55 	parser := flags.NewParser(&config, flags.Default)
 56 	_, err := parser.Parse()
 57-	return config, err
 58+	if err != nil {
 59+		return config, err
 60+	}
 61+
 62+	if config.AuthToken == "" {
 63+		return config, fmt.Errorf("missing AUTH_TOKEN env var")
 64+	}
 65+	if config.YouTubeAPIKey == "" {
 66+		return config, fmt.Errorf("missing YOUTUBE_API_KEY env var")
 67+	}
 68+
 69+	return config, nil
 70 }
 71 
 72 // TestConfig returns a mostly hardcoded configuration used for running tests
 73diff --git a/db/channels.go b/db/channels.go
 74index bb55cd89793d0d32ca255eab830610d6cc9065cb..d172deb2249a7756a4cda137174c2d5338c35750 100644
 75--- a/db/channels.go
 76+++ b/db/channels.go
 77@@ -6,10 +6,9 @@ import (
 78 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 79 )
 80 
 81-var createChannelQuery = `INSERT INTO channels (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`
 82-
 83 func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
 84-	resp, err := d.db.ExecContext(ctx, createChannelQuery, channel.ID, channel.Name)
 85+	const query = `INSERT INTO channels (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`
 86+	resp, err := d.db.ExecContext(ctx, query, channel.ID, channel.Name)
 87 	if err != nil {
 88 		d.l.Error("Failed to create channel", "call", "sql.ExecContext", "error", err)
 89 		return err
 90@@ -21,10 +20,9 @@ func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Chan
 91 	return nil
 92 }
 93 
 94-var listChannelsQuery = `SELECT id, name FROM channels`
 95-
 96 func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
 97-	rows, err := d.db.QueryContext(ctx, listChannelsQuery)
 98+	const query = `SELECT id, name FROM channels`
 99+	rows, err := d.db.QueryContext(ctx, query)
100 	if err != nil {
101 		d.l.Error("Failed to list channels", "call", "sql.QueryContext", "error", err)
102 		return nil, err
103@@ -46,7 +44,7 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
104 }
105 
106 func (d *postgresDB) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
107-	query := `DELETE FROM channels WHERE id = $1`
108+	const query = `DELETE FROM channels WHERE id = $1`
109 	resp, err := d.db.ExecContext(ctx, query, channelID)
110 	if err != nil {
111 		d.l.Error("Failed to unsubscribe from channel", "call", "sql.ExecContext", "error", err)
112diff --git a/db/db.go b/db/db.go
113index a451ad811262762f616dcea2f55e0870b658125a..c125121e19e6a3a41894f5c6d3b5652dd83f3096 100644
114--- a/db/db.go
115+++ b/db/db.go
116@@ -28,8 +28,12 @@ type DB interface {
117 	GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
118 	// GetWatchedVideos returns a list of all watched videos
119 	GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
120+	// HasVideo returns true if the video with the given ID exists in the DB
121+	HasVideo(ctx context.Context, videoID string) (bool, error)
122 	// AddVideo adds a newly published video to the database
123 	AddVideo(ctx context.Context, video models.Video, channelID string) error
124 	// SetVideoWatchTime sets or unsets the watch timestamp of a video
125 	SetVideoWatchTime(ctx context.Context, videoID string, watchTime *time.Time) error
126+	// SetVideoProgress sets or unsets the watch progress of a video
127+	SetVideoProgress(ctx context.Context, videoID string, progress int) (*models.Video, error)
128 }
129diff --git a/db/videos.go b/db/videos.go
130index 522f5f06e5a47f7b9a8bd5bf9a3cdea4fb9fc6e2..7cc3309c1eea425387856fbfa0f6fef85ae0abda 100644
131--- a/db/videos.go
132+++ b/db/videos.go
133@@ -14,6 +14,8 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.
134 			, title
135 			, published_timestamp
136 			, is_short
137+			, duration
138+			, progress
139 			, channels.name
140 			, channels.id
141 		FROM videos
142@@ -40,6 +42,8 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.
143 			&video.Title,
144 			&video.PublishedTime,
145 			&video.IsShort,
146+			&video.DurationSeconds,
147+			&video.ProgressSeconds,
148 			&video.ChannelName,
149 			&video.ChannelID,
150 		)
151@@ -61,6 +65,8 @@ func (d *postgresDB) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]mod
152 			, published_timestamp
153 			, watch_timestamp
154 			, is_short
155+			, duration
156+			, progress
157 			, channels.name
158 			, channels.id
159 		FROM videos
160@@ -88,7 +94,10 @@ func (d *postgresDB) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]mod
161 			&video.PublishedTime,
162 			&video.WatchTime,
163 			&video.IsShort,
164+			&video.DurationSeconds,
165+			&video.ProgressSeconds,
166 			&video.ChannelName,
167+			&video.ChannelID,
168 		)
169 		if err != nil {
170 			d.l.Error("Failed to scan rows for watched videos", "call", "sql.Scan", "error", err)
171@@ -100,15 +109,30 @@ func (d *postgresDB) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]mod
172 	return videos, nil
173 }
174 
175+func (d *postgresDB) HasVideo(ctx context.Context, videoID string) (bool, error) {
176+	query := `SELECT COUNT(1) FROM videos WHERE id = $1`
177+	row := d.db.QueryRowContext(ctx, query, videoID)
178+
179+	var count int
180+	err := row.Scan(&count)
181+	if err != nil {
182+		d.l.Error("Failed to query for video", "call", "sql.QueryRowContext", "error", err)
183+		return false, err
184+	}
185+
186+	return count == 1, nil
187+}
188+
189 func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string) error {
190 	query := `
191 		INSERT INTO videos (
192 			id
193 			, title
194 			, published_timestamp
195+			, duration
196 			, is_short
197 			, channel_id
198-		) VALUES ($1, $2, $3, $4, $5)
199+		) VALUES ($1, $2, $3, $4, $5, $6)
200 		ON CONFLICT DO NOTHING
201 	`
202 
203@@ -118,6 +142,7 @@ func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID
204 		video.ID,
205 		video.Title,
206 		video.PublishedTime,
207+		video.DurationSeconds,
208 		video.IsShort,
209 		channelID,
210 	)
211@@ -132,14 +157,13 @@ func (d *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID
212 	return nil
213 }
214 
215-const setVideoWatchTimeQuery = `UPDATE videos SET watch_timestamp = $1 WHERE id = $2`
216-
217 func (d *postgresDB) SetVideoWatchTime(
218 	ctx context.Context,
219 	videoID string,
220 	watchTime *time.Time,
221 ) error {
222-	_, err := d.db.ExecContext(ctx, setVideoWatchTimeQuery, watchTime, videoID)
223+	const query = `UPDATE videos SET watch_timestamp = $1 WHERE id = $2`
224+	_, err := d.db.ExecContext(ctx, query, watchTime, videoID)
225 	if err != nil {
226 		d.l.Error("", "error", err)
227 		return err
228@@ -147,3 +171,41 @@ func (d *postgresDB) SetVideoWatchTime(
229 
230 	return nil
231 }
232+
233diff --git a/feedparser/feedparser.go b/feedparser/feedparser.go
234index 853304c59734a085c9ff4bf9d118955e60d87ee4..cd2a1672cbd36669c1ff71042b6ad338c963e5bd 100644
235--- a/feedparser/feedparser.go
236+++ b/feedparser/feedparser.go
237@@ -7,6 +7,7 @@ import (
238 	"io"
239 	"log/slog"
240 	"net/http"
241+	"strings"
242 	"sync"
243 
244 	"github.com/paulrosania/go-charset/charset"
245@@ -74,6 +75,10 @@ func (p *parser) Parse(channelID string) (*Channel, error) {
246 		return nil, fmt.Errorf("%w: %s", ErrParseFailed, err.Error())
247 	}
248 	channel.ID = channelID
249+	for _, video := range channel.Videos {
250+		video.IsShort = strings.Contains(video.Link.Href, "/shorts/")
251+	}
252+
253 	return &channel, nil
254 }
255 
256diff --git a/feedparser/models.go b/feedparser/models.go
257index 54a1c45532e340e2720068e4b5693c7560b5a14e..f4892a0c1411f9cc3d52703d7bb0b7e2ea0f54ca 100644
258--- a/feedparser/models.go
259+++ b/feedparser/models.go
260@@ -1,15 +1,22 @@
261 package feedparser
262 
263+// Link struct for the link element
264+type Link struct {
265+	Href string `xml:"href,attr"`
266+}
267+
268 // Video struct for each video in the feed
269 type Video struct {
270 	ID        string `xml:"id"`
271 	Title     string `xml:"title"`
272 	Published Date   `xml:"published"`
273+	Link      Link   `xml:"link"`
274+	IsShort   bool
275 }
276 
277 // Channel struct for RSS
278 type Channel struct {
279 	ID     string
280-	Name   string  `xml:"title"`
281-	Videos []Video `xml:"entry"`
282+	Name   string   `xml:"title"`
283+	Videos []*Video `xml:"entry"`
284 }
285diff --git a/handler/handler.go b/handler/handler.go
286index 17426a27e10b168c7b56cd03dea399eb9cc0ef82..e4858e633808da96d18d9f66a858c19ba3812b90 100644
287--- a/handler/handler.go
288+++ b/handler/handler.go
289@@ -4,6 +4,7 @@ import (
290 	"context"
291 	"log/slog"
292 
293+	"github.com/TheEdgeOfRage/ytrssil-api/config"
294 	"github.com/TheEdgeOfRage/ytrssil-api/db"
295 	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
296 	"github.com/TheEdgeOfRage/ytrssil-api/models"
297@@ -17,18 +18,26 @@ type Handler interface {
298 	FetchVideos(ctx context.Context) error
299 	MarkVideoAsWatched(ctx context.Context, videoID string) error
300 	MarkVideoAsUnwatched(ctx context.Context, videoID string) error
301+	SetVideoProgress(ctx context.Context, videoID string, progressTime string) (*models.Video, error)
302 }
303 
304 type handler struct {
305-	log    *slog.Logger
306-	db     db.DB
307-	parser feedparser.Parser
308+	log           *slog.Logger
309+	db            db.DB
310+	parser        feedparser.Parser
311+	youTubeAPIKey string
312 }
313 
314-func New(log *slog.Logger, db db.DB, parser feedparser.Parser) *handler {
315+func New(
316+	log *slog.Logger,
317+	cfg config.Config,
318+	db db.DB,
319+	parser feedparser.Parser,
320+) *handler {
321 	return &handler{
322-		log:    log,
323-		db:     db,
324-		parser: parser,
325+		log:           log,
326+		db:            db,
327+		parser:        parser,
328+		youTubeAPIKey: cfg.YouTubeAPIKey,
329 	}
330 }
331diff --git a/handler/handler_test.go b/handler/handler_test.go
332index e5d789d55f9929ac0d6d0bd84e99582af3ac80b7..d1b66eeaa454667aba060884e2f78ad4284e73bb 100644
333--- a/handler/handler_test.go
334+++ b/handler/handler_test.go
335@@ -23,7 +23,7 @@ func init() {
336 
337 func TestGetNewVideos(t *testing.T) {
338 	l := slog.New(slog.NewTextHandler(io.Discard, nil))
339-	handler := New(l, &db_mock.DBMock{
340+	handler := New(l, testConfig, &db_mock.DBMock{
341 		GetNewVideosFunc: func(ctx context.Context, _ bool) ([]models.Video, error) {
342 			return []models.Video{
343 				{
344diff --git a/handler/videos.go b/handler/videos.go
345index f6ec420bbdeb023351dc5301011b35697be2c393..6cef6d64c69fc6a7c4debaf7e66e6af77702109d 100644
346--- a/handler/videos.go
347+++ b/handler/videos.go
348@@ -2,9 +2,13 @@ package handler
349 
350 import (
351 	"context"
352+	"encoding/json"
353 	"errors"
354 	"fmt"
355+	"io"
356+	"math"
357 	"net/http"
358+	"net/url"
359 	"strings"
360 	"sync"
361 	"time"
362@@ -14,6 +18,8 @@ import (
363 	"github.com/TheEdgeOfRage/ytrssil-api/models"
364 )
365 
366+var ErrInvalidProgress = errors.New("invalid progress time")
367+
368 func (h *handler) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
369 	return h.db.GetNewVideos(ctx, sortDesc)
370 }
371@@ -22,55 +28,116 @@ func (h *handler) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models
372 	return h.db.GetWatchedVideos(ctx, sortDesc)
373 }
374 
375-func (h *handler) isShort(ctx context.Context, videoID string) (bool, error) {
376-	url := fmt.Sprintf("https://www.youtube.com/shorts/%s", videoID)
377-	req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
378+type APIVideoListResponse struct {
379+	Items []APIVideo `json:"items"`
380+}
381+
382+type APIVideo struct {
383+	ID             string            `json:"id"`
384+	ContentDetails APIContentDetails `json:"contentDetails"`
385+}
386+
387+type APIContentDetails struct {
388+	Duration string `json:"duration"`
389+}
390+
391+func (h *handler) getVideoDurations(ctx context.Context, videos map[string]*models.Video) error {
392+	ids := strings.Builder{}
393+	for id := range videos {
394+		ids.WriteString(id)
395+		ids.WriteString(",")
396+	}
397+	query := url.Values{}
398+	query.Add("id", ids.String())
399+	query.Add("part", "contentDetails")
400+	query.Add("key", h.youTubeAPIKey)
401+
402+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
403 	if err != nil {
404-		h.log.Error(
405-			"Failed to create request to check if a video is a short",
406-			"videoID", videoID,
407-			"error", err,
408-		)
409-		return false, err
410+		return fmt.Errorf("failed to set up request: %w", err)
411+	}
412+	req.URL = &url.URL{
413+		Scheme:   "https",
414+		Host:     "www.googleapis.com",
415+		Path:     "/youtube/v3/videos",
416+		RawQuery: query.Encode(),
417 	}
418 
419-	client := &http.Client{
420-		CheckRedirect: func(req *http.Request, via []*http.Request) error {
421-			return http.ErrUseLastResponse
422-		},
423+	resp, err := http.DefaultClient.Do(req)
424+	if err != nil {
425+		return fmt.Errorf("failed to fetch video details: %w", err)
426 	}
427-	resp, err := client.Do(req)
428+	defer resp.Body.Close()
429+
430+	if resp.StatusCode != http.StatusOK {
431+		body, err := io.ReadAll(resp.Body)
432+		var bodyStr string
433+		if err != nil {
434+			h.log.Error("failed to decode error body", "error", err)
435+			bodyStr = "failed to decode body"
436+		} else {
437+			bodyStr = string(body)
438+		}
439+		return fmt.Errorf("got non-200 status from YouTube API [%d]: %v", resp.StatusCode, bodyStr)
440+	}
441+
442+	decoder := json.NewDecoder(resp.Body)
443+	var respData APIVideoListResponse
444+	err = decoder.Decode(&respData)
445 	if err != nil {
446-		h.log.Error("Failed to check if a video is a short", "videoID", videoID, "error", err)
447-		return false, err
448diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
449index 23b098c4a77a0f7d6da56b113c25e2c27cd0b480..b35b191cc670eb6c32837c3d5c423e92256ffdfd 100644
450--- a/httpserver/ytrssil/api_setup_test.go
451+++ b/httpserver/ytrssil/api_setup_test.go
452@@ -10,7 +10,7 @@ import (
453 	"time"
454 
455 	"github.com/gin-gonic/gin"
456-	"github.com/stretchr/testify/assert"
457+	"github.com/stretchr/testify/suite"
458 
459 	"github.com/TheEdgeOfRage/ytrssil-api/config"
460 	"github.com/TheEdgeOfRage/ytrssil-api/handler"
461@@ -18,18 +18,27 @@ import (
462 	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
463 )
464 
465-var testConfig config.Config
466-
467 func init() {
468 	// always use UTC
469 	time.Local = time.UTC
470-	testConfig = config.TestConfig()
471 }
472 
473-func setupTestServer(t *testing.T) *http.Server {
474+type APITestSuite struct {
475+	suite.Suite
476+
477+	cfg    config.Config
478+	server *http.Server
479+}
480+
481+func TestAPITestSuite(t *testing.T) {
482+	suite.Run(t, new(APITestSuite))
483+}
484+
485+func (s *APITestSuite) SetupSuite() {
486 	l := slog.New(slog.NewTextHandler(io.Discard, nil))
487+	s.cfg = config.TestConfig()
488 
489-	handler := handler.New(l, nil, nil)
490+	handler := handler.New(l, s.cfg, nil, nil)
491 
492 	gin.SetMode(gin.TestMode)
493 	router, err := ytrssil.SetupGinRouter(
494@@ -38,21 +47,19 @@ func setupTestServer(t *testing.T) *http.Server {
495 		auth.APIAuthMiddleware(""),
496 		auth.PageAuthMiddleware(""),
497 	)
498-	assert.Nil(t, err)
499+	s.Require().NoError(err)
500 
501-	return &http.Server{
502-		Addr:    fmt.Sprintf(":%v", testConfig.Port),
503+	s.server = &http.Server{
504+		Addr:    fmt.Sprintf(":%v", s.cfg.Port),
505 		Handler: router,
506 	}
507 }
508 
509-func TestHealthz(t *testing.T) {
510-	server := setupTestServer(t)
511-
512+func (s *APITestSuite) TestHealthz() {
513 	w := httptest.NewRecorder()
514 	req, _ := http.NewRequest("GET", "/healthz", nil)
515-	server.Handler.ServeHTTP(w, req)
516+	s.server.Handler.ServeHTTP(w, req)
517 
518-	assert.Equal(t, 200, w.Code)
519-	assert.Equal(t, "healthy", w.Body.String())
520+	s.Equal(http.StatusOK, w.Code)
521+	s.Equal("healthy", w.Body.String())
522 }
523diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
524index 6e0df289bf3bdad6de5edef4a7e09c2a1bd024af..17882aadef829c1d30e09689136edd49171d8b67 100644
525--- a/httpserver/ytrssil/server.go
526+++ b/httpserver/ytrssil/server.go
527@@ -66,9 +66,11 @@ func SetupGinRouter(
528 
529 	pages := engine.Group("")
530 	pages.Use(pageAuthMiddleware)
531-
532-	pages.GET("/", srv.NewVideosPage)
533-	pages.POST("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
534+	{
535+		pages.GET("/", srv.NewVideosPage)
536+		pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
537+		pages.PATCH("/videos/:video_id/progress", srv.SetVideoProgressPage)
538+	}
539 
540 	// all APIs go in this routing group and require authentication
541 	api := engine.Group("/api")
542diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
543index 85978d9a0be99a996a9b673b9bbdd03b4de7fd10..0351c41b6d41cf33a8b7a0b65a6b46d3355e14bf 100644
544--- a/httpserver/ytrssil/videos_pages.go
545+++ b/httpserver/ytrssil/videos_pages.go
546@@ -45,3 +45,35 @@ func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
547 
548 	c.String(http.StatusOK, "")
549 }
550+
551+func (srv server) SetVideoProgressPage(c *gin.Context) {
552+	r := pages.TemplRenderer{
553+		Ctx: c.Request.Context(),
554+	}
555+	var req struct {
556+		models.VideoURIRequest
557+		models.SetVideoProgressRequest
558+	}
559+	err := c.ShouldBindUri(&req.VideoURIRequest)
560+	if err != nil {
561+		r.Component = pages.ErrorPage(err)
562+		c.Render(http.StatusBadRequest, r)
563+		return
564+	}
565+	err = c.ShouldBind(&req.SetVideoProgressRequest)
566+	if err != nil {
567+		r.Component = pages.ErrorPage(err)
568+		c.Render(http.StatusBadRequest, r)
569+		return
570+	}
571+
572+	video, err := srv.handler.SetVideoProgress(c.Request.Context(), req.VideoID, req.Progress)
573+	if err != nil {
574+		r.Component = pages.ErrorPage(err)
575+		c.Render(http.StatusInternalServerError, r)
576+		return
577+	}
578+
579+	r.Component = pages.ProgressBar(*video)
580+	c.Render(http.StatusOK, r)
581+}
582diff --git a/migrations/000004_add_duration.down.sql b/migrations/000004_add_duration.down.sql
583new file mode 100644
584index 0000000000000000000000000000000000000000..29cd80e042b728e63e348ccd9a8ae79f945c06dc
585--- /dev/null
586+++ b/migrations/000004_add_duration.down.sql
587@@ -0,0 +1,2 @@
588+ALTER TABLE videos DROP COLUMN IF EXISTS duration;
589+ALTER TABLE videos DROP COLUMN IF EXISTS progress;
590diff --git a/migrations/000004_add_duration.up.sql b/migrations/000004_add_duration.up.sql
591new file mode 100644
592index 0000000000000000000000000000000000000000..5451a91e27e11aed04e914de1da2b818233581ad
593--- /dev/null
594+++ b/migrations/000004_add_duration.up.sql
595@@ -0,0 +1,2 @@
596+ALTER TABLE videos ADD COLUMN IF NOT EXISTS duration INTEGER DEFAULT 0;
597+ALTER TABLE videos ADD COLUMN IF NOT EXISTS progress INTEGER DEFAULT 0;
598diff --git a/mocks/db/db.go b/mocks/db/db.go
599index 0f0bb6475f14f597f0c416421d6debd75833875a..324291c1beaaa65ba316db256d5a0935b14870f7 100644
600--- a/mocks/db/db.go
601+++ b/mocks/db/db.go
602@@ -30,9 +30,15 @@ var _ db.DB = &DBMock{}
603 //			GetWatchedVideosFunc: func(ctx context.Context, sortDesc bool) ([]models.Video, error) {
604 //				panic("mock out the GetWatchedVideos method")
605 //			},
606+//			HasVideoFunc: func(ctx context.Context, videoID string) (bool, error) {
607+//				panic("mock out the HasVideo method")
608+//			},
609 //			ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
610 //				panic("mock out the ListChannels method")
611 //			},
612+//			SetVideoProgressFunc: func(ctx context.Context, videoID string, progress int) (*models.Video, error) {
613+//				panic("mock out the SetVideoProgress method")
614+//			},
615 //			SetVideoWatchTimeFunc: func(ctx context.Context, videoID string, watchTime *time.Time) error {
616 //				panic("mock out the SetVideoWatchTime method")
617 //			},
618@@ -58,9 +64,15 @@ type DBMock struct {
619 	// GetWatchedVideosFunc mocks the GetWatchedVideos method.
620 	GetWatchedVideosFunc func(ctx context.Context, sortDesc bool) ([]models.Video, error)
621 
622+	// HasVideoFunc mocks the HasVideo method.
623+	HasVideoFunc func(ctx context.Context, videoID string) (bool, error)
624+
625 	// ListChannelsFunc mocks the ListChannels method.
626 	ListChannelsFunc func(ctx context.Context) ([]models.Channel, error)
627 
628+	// SetVideoProgressFunc mocks the SetVideoProgress method.
629+	SetVideoProgressFunc func(ctx context.Context, videoID string, progress int) (*models.Video, error)
630+
631 	// SetVideoWatchTimeFunc mocks the SetVideoWatchTime method.
632 	SetVideoWatchTimeFunc func(ctx context.Context, videoID string, watchTime *time.Time) error
633 
634@@ -95,11 +107,27 @@ type DBMock struct {
635 			// SortDesc is the sortDesc argument value.
636 			SortDesc bool
637 		}
638+		// HasVideo holds details about calls to the HasVideo method.
639+		HasVideo []struct {
640+			// Ctx is the ctx argument value.
641+			Ctx context.Context
642+			// VideoID is the videoID argument value.
643+			VideoID string
644+		}
645 		// ListChannels holds details about calls to the ListChannels method.
646 		ListChannels []struct {
647 			// Ctx is the ctx argument value.
648 			Ctx context.Context
649 		}
650+		// SetVideoProgress holds details about calls to the SetVideoProgress method.
651+		SetVideoProgress []struct {
652+			// Ctx is the ctx argument value.
653+			Ctx context.Context
654+			// VideoID is the videoID argument value.
655+			VideoID string
656+			// Progress is the progress argument value.
657+			Progress int
658+		}
659 		// SetVideoWatchTime holds details about calls to the SetVideoWatchTime method.
660 		SetVideoWatchTime []struct {
661 			// Ctx is the ctx argument value.
662@@ -127,7 +155,9 @@ type DBMock struct {
663 	lockAddVideo               sync.RWMutex
664 	lockGetNewVideos           sync.RWMutex
665 	lockGetWatchedVideos       sync.RWMutex
666+	lockHasVideo               sync.RWMutex
667 	lockListChannels           sync.RWMutex
668+	lockSetVideoProgress       sync.RWMutex
669 	lockSetVideoWatchTime      sync.RWMutex
670 	lockSubscribeToChannel     sync.RWMutex
671 	lockUnsubscribeFromChannel sync.RWMutex
672@@ -245,6 +275,42 @@ func (mock *DBMock) GetWatchedVideosCalls() []struct {
673 	return calls
674 }
675 
676+// HasVideo calls HasVideoFunc.
677+func (mock *DBMock) HasVideo(ctx context.Context, videoID string) (bool, error) {
678+	if mock.HasVideoFunc == nil {
679+		panic("DBMock.HasVideoFunc: method is nil but DB.HasVideo was just called")
680+	}
681+	callInfo := struct {
682+		Ctx     context.Context
683+		VideoID string
684+	}{
685+		Ctx:     ctx,
686+		VideoID: videoID,
687+	}
688+	mock.lockHasVideo.Lock()
689+	mock.calls.HasVideo = append(mock.calls.HasVideo, callInfo)
690+	mock.lockHasVideo.Unlock()
691+	return mock.HasVideoFunc(ctx, videoID)
692+}
693+
694+// HasVideoCalls gets all the calls that were made to HasVideo.
695+// Check the length with:
696+//
697+//	len(mockedDB.HasVideoCalls())
698+func (mock *DBMock) HasVideoCalls() []struct {
699+	Ctx     context.Context
700+	VideoID string
701+} {
702diff --git a/models/http.go b/models/http.go
703index 9c26731d57e23e44981fe505bc837c611fcc51a1..f65eb00e5c22fa1b049e1dd4f4c416e2fd40e48b 100644
704--- a/models/http.go
705+++ b/models/http.go
706@@ -7,3 +7,7 @@ type VideosResponse struct {
707 type VideoURIRequest struct {
708 	VideoID string `uri:"video_id" binding:"required"`
709 }
710+
711+type SetVideoProgressRequest struct {
712+	Progress string `form:"progress" binding:"required"`
713+}
714diff --git a/models/video.go b/models/video.go
715index 9b50e06fea2ba9d4123c8eb2401fee56e51a92cf..7ad73c99783e4df54fdd1aa2f5e8e77c31cf16ce 100644
716--- a/models/video.go
717+++ b/models/video.go
718@@ -1,6 +1,7 @@
719 package models
720 
721 import (
722+	"math"
723 	"time"
724 )
725 
726@@ -17,10 +18,18 @@ type Video struct {
727 	PublishedTime time.Time `json:"published_timestamp"`
728 	// Video watch timestamp
729 	WatchTime *time.Time `json:"watch_timestamp"`
730+	// ProgressSeconds is the total duration of the video
731+	DurationSeconds int `json:"duration"`
732+	// ProgressSeconds is the saved progress of the video
733+	ProgressSeconds int `json:"progress"`
734 	// IsShort indicates if a video is a YouTube short
735 	IsShort bool `json:"short"`
736 }
737 
738+func (v Video) ProgressPercentage() int {
739+	return int(math.Floor(100 * float64(v.ProgressSeconds) / float64(v.DurationSeconds)))
740+}
741+
742 type PaginatedVideos struct {
743 	Videos     []Video
744 	NextOffset int
745diff --git a/pages/videos.templ b/pages/videos.templ
746index 854942952d693f7a189779bc60b171e7f719e448..81aa9d0c1c04fcc21db6a4993f84af6c859cb244 100644
747--- a/pages/videos.templ
748+++ b/pages/videos.templ
749@@ -6,6 +6,16 @@ import (
750 	"github.com/TheEdgeOfRage/ytrssil-api/models"
751 )
752 
753+templ ProgressBar(video models.Video) {
754+	if video.ProgressSeconds > 0 {
755+		<div id={ fmt.Sprintf("progress-%s", video.ID) } class="progress" role="progressbar" style="height: 6px">
756+			<div class="progress-bar" style={ fmt.Sprintf("background: #b11; width: %d%%", video.ProgressPercentage()) }></div>
757+		</div>
758+	} else {
759+		<div id={ fmt.Sprintf("progress-%s", video.ID) } style="height: 6px"></div>
760+	}
761+}
762+
763 templ thumbnail(video models.Video) {
764 	<a
765 		target="blank"
766@@ -31,9 +41,7 @@ templ thumbnail(video models.Video) {
767 			/>
768 		}
769 	</a>
770-	// <div class="progress" role="progressbar" style="height: 6px">
771-	// <div class="progress-bar w-75" style="background: #b11"></div>
772-	// </div>
773+	@ProgressBar(video)
774 }
775 
776 templ videoCard(video models.Video) {
777@@ -66,12 +74,17 @@ templ videoCard(video models.Video) {
778 				>{ video.ChannelName }</a>
779 			</p>
780 			<div class="d-flex justify-content-between">
781-				// <form hx-post="/submit-form" hx-target="#response" class="d-flex">
782-				// 	<input type="text" name="time" placeholder="hh:mm:ss" class="form-control "/>
783-				// 	<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i> </button>
784-				// </form>
785+				<form
786+					hx-patch={ fmt.Sprintf("/videos/%s/progress", video.ID) }
787+					hx-target={ fmt.Sprintf("#progress-%s", video.ID) }
788+					hx-swap="outerHTML"
789+					class="d-flex"
790+				>
791+					<input type="text" name="progress" placeholder="hh:mm:ss" class="form-control "/>
792+					<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i> </button>
793+				</form>
794 				<button
795-					hx-post={ fmt.Sprintf("/videos/%s/watch", video.ID) }
796+					hx-patch={ fmt.Sprintf("/videos/%s/watch", video.ID) }
797 					hx-target="closest .video-card"
798 					hx-swap="outerHTML"
799 					class="btn btn-danger"
800diff --git a/pages/videos_templ.go b/pages/videos_templ.go
801index 705ec8e28973ace4410bb278f750b08f2b92ecbf..aabcfef72e4874ab0c22b7b8699fd608801a6eba 100644
802--- a/pages/videos_templ.go
803+++ b/pages/videos_templ.go
804@@ -14,7 +14,7 @@ import (
805 	"github.com/TheEdgeOfRage/ytrssil-api/models"
806 )
807 
808-func thumbnail(video models.Video) templ.Component {
809+func ProgressBar(video models.Video) templ.Component {
810 	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
811 		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
812 		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
813@@ -35,97 +35,176 @@ func thumbnail(video models.Video) templ.Component {
814 			templ_7745c5c3_Var1 = templ.NopComponent
815 		}
816 		ctx = templ.ClearChildren(ctx)
817-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a target=\"blank\" href=\"")
818+		if video.ProgressSeconds > 0 {
819+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"")
820+			if templ_7745c5c3_Err != nil {
821+				return templ_7745c5c3_Err
822+			}
823+			var templ_7745c5c3_Var2 string
824+			templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("progress-%s", video.ID))
825+			if templ_7745c5c3_Err != nil {
826+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 11, Col: 48}
827+			}
828+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
829+			if templ_7745c5c3_Err != nil {
830+				return templ_7745c5c3_Err
831+			}
832+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"progress\" role=\"progressbar\" style=\"height: 6px\"><div class=\"progress-bar\" style=\"")
833+			if templ_7745c5c3_Err != nil {
834+				return templ_7745c5c3_Err
835+			}
836+			var templ_7745c5c3_Var3 string
837+			templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("background: #b11; width: %d%%", video.ProgressPercentage()))
838+			if templ_7745c5c3_Err != nil {
839+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 12, Col: 109}
840+			}
841+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
842+			if templ_7745c5c3_Err != nil {
843+				return templ_7745c5c3_Err
844+			}
845+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"></div></div>")
846+			if templ_7745c5c3_Err != nil {
847+				return templ_7745c5c3_Err
848+			}
849+		} else {
850+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div id=\"")
851+			if templ_7745c5c3_Err != nil {
852+				return templ_7745c5c3_Err
853+			}
854+			var templ_7745c5c3_Var4 string
855+			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("progress-%s", video.ID))
856+			if templ_7745c5c3_Err != nil {
857+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 15, Col: 48}
858+			}
859+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
860+			if templ_7745c5c3_Err != nil {
861+				return templ_7745c5c3_Err
862+			}
863+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" style=\"height: 6px\"></div>")
864+			if templ_7745c5c3_Err != nil {
865+				return templ_7745c5c3_Err
866+			}
867+		}
868+		return nil
869+	})
870+}
871+
872+func thumbnail(video models.Video) templ.Component {
873+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
874+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
875+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
876+			return templ_7745c5c3_CtxErr
877+		}
878+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
879+		if !templ_7745c5c3_IsBuffer {
880+			defer func() {
881+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
882+				if templ_7745c5c3_Err == nil {
883+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
884+				}
885+			}()
886+		}
887+		ctx = templ.InitializeContext(ctx)
888+		templ_7745c5c3_Var5 := templ.GetChildren(ctx)
889+		if templ_7745c5c3_Var5 == nil {
890+			templ_7745c5c3_Var5 = templ.NopComponent
891+		}
892+		ctx = templ.ClearChildren(ctx)
893+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a target=\"blank\" href=\"")
894 		if templ_7745c5c3_Err != nil {
895 			return templ_7745c5c3_Err
896 		}
897-		var templ_7745c5c3_Var2 templ.SafeURL
898-		templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("https://youtube.com/watch?v=%s", video.ID)))
899+		var templ_7745c5c3_Var6 templ.SafeURL
900+		templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("https://youtube.com/watch?v=%s", video.ID)))
901 		if templ_7745c5c3_Err != nil {
902-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 12, Col: 79}
903+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 22, Col: 79}