462f74f1418c64d38dcadbf7186c1571a6020d46
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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}