d2caed5e613ff0167b949a27fcaf07ee0da72d8f
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.air.toml b/.air.toml
2new file mode 100644
3index 0000000000000000000000000000000000000000..1d3745f0e4a16dc45ab0667ff503cbfe0e1d4d8b
4--- /dev/null
5+++ b/.air.toml
6@@ -0,0 +1,41 @@
7+root = "."
8+tmp_dir = "/tmp"
9+
10+[build]
11+ args_bin = [
12+ "--db-uri=postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable",
13+ ]
14+ entrypoint = "./dist/ytrssil"
15+ cmd = "make build"
16+ delay = 500
17+ exclude_dir = ["migrations", "bin"]
18+ exclude_file = []
19+ exclude_regex = ["_test\\.go", "_templ\\.go"]
20+ exclude_unchanged = false
21+ follow_symlink = false
22+ full_bin = ""
23+ include_dir = []
24+ include_ext = ["go", "templ"]
25+ include_file = []
26+ kill_delay = "0s"
27+ log = "build-errors.log"
28+ poll = false
29+ poll_interval = 0
30+ post_cmd = []
31+ pre_cmd = []
32+ rerun = false
33+ rerun_delay = 500
34+ send_interrupt = false
35+ stop_on_error = false
36+
37+[log]
38+ main_only = true
39+ time = false
40+ silent = true
41+
42+[misc]
43+ clean_on_exit = false
44+
45+[screen]
46+ clear_on_rebuild = false
47+ keep_scroll = true
48diff --git a/.editorconfig b/.editorconfig
49new file mode 100644
50index 0000000000000000000000000000000000000000..99551cda9d40bcd800ecfc4915e817879d06211a
51--- /dev/null
52+++ b/.editorconfig
53@@ -0,0 +1,17 @@
54+root = true
55+
56+[*]
57+charset = utf-8
58+end_of_line = lf
59+insert_final_newline = true
60+trim_trailing_whitespace = true
61+indent_style = tab
62+indent_size = 4
63+
64+[*.yaml]
65+indent_style = space
66+indent_size = 2
67+
68+[*.toml]
69+indent_style = space
70+indent_size = 2
71diff --git a/Makefile b/Makefile
72index 5157b5ce5c2a3a9415d533d60d9f9ef1464d2c43..0639f09406195992dd31a434d77eb2ab43770106 100644
73--- a/Makefile
74+++ b/Makefile
75@@ -5,9 +5,11 @@ DB_URI ?= postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable
76 bin/moq:
77 GOBIN=$(PWD)/bin go install github.com/matryer/moq@v0.6.0
78 bin/golangci-lint:
79- GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0
80+ GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.1
81 bin/migrate: bin
82 GOBIN=$(PWD)/bin go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.0
83+bin/air: bin
84+ GOBIN=$(PWD)/bin go install github.com/air-verse/air@v1.63.4
85
86 lint: bin/golangci-lint
87 go mod tidy
88@@ -19,6 +21,13 @@ test:
89 go mod tidy
90 go test -timeout=10s -race -benchmem ./...
91
92+air: bin/air
93+ @./bin/air -c .air.toml
94+
95+build:
96+ templ generate
97+ go build -o dist/ytrssil ./cmd/main.go
98+
99 gen-mocks: bin/moq
100 ./bin/moq -pkg db_mock -out ./mocks/db/db.go ./db DB
101 ./bin/moq -pkg parser_mock -out ./mocks/feedparser/feedparser.go ./feedparser Parser
102diff --git a/cmd/main.go b/cmd/main.go
103index 0a49766e1f711f9a014d05c14d79ee3821267641..728a32ca960f9c812dfc566e2437f21b3bd89698 100644
104--- a/cmd/main.go
105+++ b/cmd/main.go
106@@ -7,17 +7,18 @@ import (
107 "net/http"
108 "os"
109 "os/signal"
110+ "sync"
111 "syscall"
112 "time"
113
114 "github.com/gin-gonic/gin"
115
116- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
117- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
118- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
119- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
120- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
121- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
122+ "github.com/TheEdgeOfRage/ytrssil-api/config"
123+ "github.com/TheEdgeOfRage/ytrssil-api/db"
124+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
125+ "github.com/TheEdgeOfRage/ytrssil-api/handler"
126+ "github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
127+ "github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
128 )
129
130 const LevelFatal slog.Level = slog.LevelError + 4
131@@ -27,13 +28,19 @@ func init() {
132 time.Local = time.UTC
133 }
134
135-func fetcherRoutine(l *slog.Logger, h handler.Handler) {
136+func fetcherRoutine(ctx context.Context, wg *sync.WaitGroup, l *slog.Logger, h handler.Handler) {
137+ ticker := time.NewTicker(5 * time.Minute)
138 for {
139- err := h.FetchVideos(context.Background())
140- if err != nil {
141- l.Error("Failed to fetch videos", "call", "handler.FetchVideos", "err", err)
142+ select {
143+ case <-ctx.Done():
144+ l.Info("Fetcher context done, stopping fetcher")
145+ return
146+ case <-ticker.C:
147+ err := h.FetchVideos(context.Background())
148+ if err != nil {
149+ l.Error("Failed to fetch videos", "call", "handler.FetchVideos", "err", err)
150+ }
151 }
152- time.Sleep(5 * time.Minute)
153 }
154 }
155
156@@ -77,30 +84,39 @@ func main() {
157 Addr: fmt.Sprintf(":%v", config.Gin.Port),
158 Handler: router,
159 }
160+
161+ wg := &sync.WaitGroup{}
162 quit := make(chan os.Signal, 1)
163- // handle Interrupt (ctrl-c) Term, used by `kill` et al, HUP which is commonly used to reload configs
164 signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
165
166- go func() {
167- s := <-quit
168- logger.Info("Received signal, shutting down", "signal", s)
169- if err := server.Shutdown(context.Background()); err != nil {
170- logger.Error(
171- "Failed to shutdown server",
172- "call", "server.Shutdown",
173- "error", err,
174- )
175- }
176- }()
177-
178 // start periodic fetch videos routine
179- go fetcherRoutine(logger, handler)
180+ fetcherContext, cancelFetcher := context.WithCancel(context.Background())
181+ wg.Go(func() {
182+ fetcherRoutine(fetcherContext, wg, logger, handler)
183+ })
184
185- logger.Info("ytrssil API is starting up", "port", config.Gin.Port)
186- if err := server.ListenAndServe(); err != nil {
187- if err != http.ErrServerClosed {
188- logger.Error("Server crashed", "call", "server.ListenAndServe", "error", err)
189+ wg.Go(func() {
190+ logger.Info("ytrssil API is starting up", "port", config.Gin.Port)
191+ if err := server.ListenAndServe(); err != nil {
192+ if err != http.ErrServerClosed {
193+ logger.Error("Server crashed", "call", "server.ListenAndServe", "error", err)
194+ }
195 }
196+ })
197+
198+ s := <-quit
199+ logger.Info("Received signal, shutting down", "signal", s)
200+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
201+ defer cancel()
202+ if err := server.Shutdown(ctx); err != nil {
203+ logger.Error(
204+ "Failed to shutdown server",
205+ "call", "server.Shutdown",
206diff --git a/cmd/main_test.go b/cmd/main_test.go
207index 04f4f31ca58d38f3682aed461e9d6a16b0a32a59..2d1f25576870a5b0de424928cb1e8f3b3cc558df 100644
208--- a/cmd/main_test.go
209+++ b/cmd/main_test.go
210@@ -14,13 +14,13 @@ import (
211 "github.com/gin-gonic/gin"
212 "github.com/stretchr/testify/assert"
213
214- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
215- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
216- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
217- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
218- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
219- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
220- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
221+ "github.com/TheEdgeOfRage/ytrssil-api/config"
222+ "github.com/TheEdgeOfRage/ytrssil-api/db"
223+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
224+ "github.com/TheEdgeOfRage/ytrssil-api/handler"
225+ "github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
226+ "github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
227+ "github.com/TheEdgeOfRage/ytrssil-api/models"
228 )
229
230 var testConfig config.Config
231diff --git a/compose.yaml b/compose.yaml
232index 04a2cdda90178cdc837b889ba70fc98ff37d82d5..82c4bf76cd7941ec2f5dfc099dd59fbe794bab32 100644
233--- a/compose.yaml
234+++ b/compose.yaml
235@@ -2,7 +2,7 @@
236 name: ytrssil
237 services:
238 postgres:
239- image: postgres:14-alpine
240+ image: postgres:18
241 restart: unless-stopped
242 environment:
243 POSTGRES_PASSWORD: ytrssil
244@@ -11,7 +11,7 @@ services:
245 ports:
246 - 5432:5432
247 volumes:
248- - postgres-data:/var/lib/postgresql/data
249+ - postgres:/var/lib/postgresql
250 healthcheck:
251 test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
252 interval: 5s
253@@ -41,4 +41,4 @@ services:
254 - "up"
255
256 volumes:
257- postgres-data:
258+ postgres:
259diff --git a/db/channels.go b/db/channels.go
260index 4eafc1942cbdfbd7ec46c95f70a3e53ff7fa03d4..d18ce7034ff8097f4db8105a6a052d27675c60f7 100644
261--- a/db/channels.go
262+++ b/db/channels.go
263@@ -3,7 +3,7 @@ package db
264 import (
265 "context"
266
267- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
268+ "github.com/TheEdgeOfRage/ytrssil-api/models"
269 )
270
271 var createChannelQuery = `INSERT INTO channels (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`
272diff --git a/db/db.go b/db/db.go
273index c79f451b06277152a15ca1e7788ac2abaf351dfa..65473339e28cf91c687310040727c123a876b388 100644
274--- a/db/db.go
275+++ b/db/db.go
276@@ -5,7 +5,7 @@ import (
277 "errors"
278 "time"
279
280- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
281+ "github.com/TheEdgeOfRage/ytrssil-api/models"
282 )
283
284 var (
285@@ -37,7 +37,7 @@ type DB interface {
286 UnsubscribeUserFromChannel(ctx context.Context, username string, channelID string) error
287
288 // GetNewVideos returns a list of unwatched videos from all subscribed channels
289- GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
290+ GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error)
291 // GetWatchedVideos returns a list of all watched videos for a user
292 GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
293 // AddVideo adds a newly published video to the database
294diff --git a/db/psql.go b/db/psql.go
295index 8695a3ceac434be9c167bf183aec9e2195b5e2f2..ea3b6e1dc419910bacf911f40794875e68014173 100644
296--- a/db/psql.go
297+++ b/db/psql.go
298@@ -6,7 +6,7 @@ import (
299
300 _ "github.com/lib/pq"
301
302- ytrssilConfig "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
303+ ytrssilConfig "github.com/TheEdgeOfRage/ytrssil-api/config"
304 )
305
306 type postgresDB struct {
307diff --git a/db/users.go b/db/users.go
308index 8faae60a58febab79053662a7a58e900237463ee..535de5952e168b9ab615659b279bdd92bbeb0348 100644
309--- a/db/users.go
310+++ b/db/users.go
311@@ -7,7 +7,7 @@ import (
312
313 "github.com/alexedwards/argon2id"
314
315- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
316+ "github.com/TheEdgeOfRage/ytrssil-api/models"
317 )
318
319 var authenticateUserQuery = `SELECT password FROM users WHERE username = $1`
320diff --git a/db/videos.go b/db/videos.go
321index 05b5f6665c41b62d1e4436cc6d64c34e9ace2391..70ea0e8a20b3db14a0b2bcbc4ccf3670bf22c35b 100644
322--- a/db/videos.go
323+++ b/db/videos.go
324@@ -4,27 +4,31 @@ import (
325 "context"
326 "time"
327
328- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
329+ "github.com/TheEdgeOfRage/ytrssil-api/models"
330 )
331
332-var getNewVideosQuery = `
333- SELECT
334- video_id
335- , title
336- , published_timestamp
337- , watch_timestamp
338- , name as channel_name
339- FROM user_videos
340- LEFT JOIN videos ON video_id=videos.id
341- LEFT JOIN channels ON channel_id=channels.id
342- WHERE
343- 1=1
344- AND watch_timestamp IS NULL
345- AND username=$1
346- ORDER BY published_timestamp
347-`
348+func (d *postgresDB) GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
349+ getNewVideosQuery := `
350+ SELECT
351+ videos.id
352+ , videos.title
353+ , videos.published_timestamp
354+ , videos.is_short
355+ , channels.name as channel_name
356+ , channels.id as channel_id
357+ FROM user_videos
358+ LEFT JOIN videos ON video_id=videos.id
359+ LEFT JOIN channels ON channel_id=channels.id
360+ WHERE
361+ 1=1
362+ AND watch_timestamp IS NULL
363+ AND username=$1
364+ ORDER BY published_timestamp
365+ `
366+ if sortDesc {
367+ getNewVideosQuery += "DESC"
368+ }
369
370-func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
371 rows, err := d.db.QueryContext(ctx, getNewVideosQuery, username)
372 if err != nil {
373 d.l.Error("Failed to query new videos", "call", "sql.QueryContext", "error", err)
374@@ -39,8 +43,9 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]model
375 &video.ID,
376 &video.Title,
377 &video.PublishedTime,
378- &video.WatchTime,
379+ &video.IsShort,
380 &video.ChannelName,
381+ &video.ChannelID,
382 )
383 if err != nil {
384 d.l.Error("Failed to scan rows for get new videos", "call", "sql.Scan", "error", err)
385@@ -52,13 +57,15 @@ func (d *postgresDB) GetNewVideos(ctx context.Context, username string) ([]model
386 return videos, nil
387 }
388
389-var getWatchedVideosQuery = `
390+const getWatchedVideosQuery = `
391 SELECT
392- video_id
393- , title
394- , published_timestamp
395- , watch_timestamp
396- , name as channel_name
397+ videos.id
398+ , videos.title
399+ , videos.published_timestamp
400+ , videos.watch_timestamp
401+ , videos.is_short
402+ , channels.name as channel_name
403+ , channels.id as channel_id
404 FROM user_videos
405 LEFT JOIN videos ON video_id=videos.id
406 LEFT JOIN channels ON channel_id=channels.id
407@@ -85,6 +92,7 @@ func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]m
408 &video.Title,
409 &video.PublishedTime,
410 &video.WatchTime,
411+ &video.IsShort,
412 &video.ChannelName,
413 )
414 if err != nil {
415@@ -97,18 +105,27 @@ func (d *postgresDB) GetWatchedVideos(ctx context.Context, username string) ([]m
416 return videos, nil
417 }
418
419-var addVideoQuery = `
420+const addVideoQuery = `
421 INSERT INTO videos (
422 id
423 , title
424diff --git a/go.mod b/go.mod
425index 732fe29c1dcf7f4c4d40d0d676c4fcd3e41fb866..1e455d6a77b8aeb8ecf0f3a9ac3a74e933943f54 100644
426--- a/go.mod
427+++ b/go.mod
428@@ -1,8 +1,9 @@
429-module gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api
430+module github.com/TheEdgeOfRage/ytrssil-api
431
432 go 1.25
433
434 require (
435+ github.com/a-h/templ v0.3.960
436 github.com/alexedwards/argon2id v1.0.0
437 github.com/gin-gonic/gin v1.11.0
438 github.com/jessevdk/go-flags v1.6.1
439@@ -38,12 +39,12 @@ require (
440 go.uber.org/mock v0.5.0 // indirect
441 golang.org/x/arch v0.20.0 // indirect
442 golang.org/x/crypto v0.40.0 // indirect
443- golang.org/x/mod v0.25.0 // indirect
444+ golang.org/x/mod v0.26.0 // indirect
445 golang.org/x/net v0.42.0 // indirect
446 golang.org/x/sync v0.16.0 // indirect
447 golang.org/x/sys v0.35.0 // indirect
448 golang.org/x/text v0.27.0 // indirect
449- golang.org/x/tools v0.34.0 // indirect
450+ golang.org/x/tools v0.35.0 // indirect
451 google.golang.org/protobuf v1.36.9 // indirect
452 gopkg.in/yaml.v3 v3.0.1 // indirect
453 )
454diff --git a/go.sum b/go.sum
455index c3639762fd4126bd74dfbf152345cd608530a08b..adb41e85d1ec16ccf21216c6647335362075c2d7 100644
456--- a/go.sum
457+++ b/go.sum
458@@ -1,3 +1,5 @@
459+github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
460+github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
461 github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
462 github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
463 github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
464@@ -82,8 +84,8 @@ golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
465 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
466 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
467 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
468-golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
469-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
470+golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
471+golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
472 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
473 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
474 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
475@@ -124,8 +126,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
476 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
477 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
478 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
479-golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
480-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
481+golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
482+golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
483 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
484 google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
485 google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
486diff --git a/handler/channels.go b/handler/channels.go
487index afe5928ee56f5502faa66761cdba5013505ebc92..1a92a9e38f594391cc8d1b033dfca28cbea86fca 100644
488--- a/handler/channels.go
489+++ b/handler/channels.go
490@@ -4,8 +4,8 @@ import (
491 "context"
492 "errors"
493
494- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
495- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
496+ "github.com/TheEdgeOfRage/ytrssil-api/db"
497+ "github.com/TheEdgeOfRage/ytrssil-api/models"
498 )
499
500 func (h *handler) SubscribeToChannel(ctx context.Context, username string, channelID string) error {
501diff --git a/handler/handler.go b/handler/handler.go
502index 7f1a22de4e57002b6741345957b59cb10f5fe3e8..5da10ab2147d27ad4224c0fc0022ac7c8dd39db1 100644
503--- a/handler/handler.go
504+++ b/handler/handler.go
505@@ -4,16 +4,16 @@ import (
506 "context"
507 "log/slog"
508
509- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
510- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
511- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
512+ "github.com/TheEdgeOfRage/ytrssil-api/db"
513+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
514+ "github.com/TheEdgeOfRage/ytrssil-api/models"
515 )
516
517 type Handler interface {
518 CreateUser(ctx context.Context, user models.User) error
519 SubscribeToChannel(ctx context.Context, username string, channelID string) error
520 UnsubscribeFromChannel(ctx context.Context, username string, channelID string) error
521- GetNewVideos(ctx context.Context, username string) ([]models.Video, error)
522+ GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error)
523 GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error)
524 FetchVideos(ctx context.Context) error
525 MarkVideoAsWatched(ctx context.Context, username string, videoID string) error
526diff --git a/handler/handler_test.go b/handler/handler_test.go
527index 902bc00157c42c16a57fc2ac1b7c0751f4a4637d..ec522f3353906e152cc26ada26ad9482202dc143 100644
528--- a/handler/handler_test.go
529+++ b/handler/handler_test.go
530@@ -9,10 +9,10 @@ import (
531
532 "github.com/stretchr/testify/assert"
533
534- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
535- db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
536- parser_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/feedparser"
537- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
538+ "github.com/TheEdgeOfRage/ytrssil-api/config"
539+ db_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/db"
540+ parser_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/feedparser"
541+ "github.com/TheEdgeOfRage/ytrssil-api/models"
542 )
543
544 var testConfig config.Config
545@@ -24,19 +24,18 @@ func init() {
546 func TestGetNewVideos(t *testing.T) {
547 l := slog.New(slog.NewTextHandler(io.Discard, nil))
548 handler := New(l, &db_mock.DBMock{
549- GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
550+ GetNewVideosFunc: func(ctx context.Context, username string, _ bool) ([]models.Video, error) {
551 return []models.Video{
552 {
553 ID: "test",
554 ChannelName: "test",
555 Title: "test",
556 PublishedTime: time.Now(),
557- WatchTime: nil,
558 },
559 }, nil
560 },
561 }, &parser_mock.ParserMock{})
562- resp, err := handler.GetNewVideos(context.TODO(), "username")
563+ resp, err := handler.GetNewVideos(context.TODO(), "username", false)
564
565 if assert.NoError(t, err) {
566 if assert.NotNil(t, resp) {
567diff --git a/handler/users.go b/handler/users.go
568index 715713856da9af36528dac9d32c9012d75b3e449..913889547dfb1671704226d3f262bc38820a325a 100644
569--- a/handler/users.go
570+++ b/handler/users.go
571@@ -5,7 +5,7 @@ import (
572
573 "github.com/alexedwards/argon2id"
574
575- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
576+ "github.com/TheEdgeOfRage/ytrssil-api/models"
577 )
578
579 func (h *handler) CreateUser(ctx context.Context, user models.User) error {
580diff --git a/handler/videos.go b/handler/videos.go
581index 8936945698a298115716b0f1299526d74f045387..5a91db50daa6e3a03ffc7d69955ca9c4a5e845a1 100644
582--- a/handler/videos.go
583+++ b/handler/videos.go
584@@ -3,17 +3,19 @@ package handler
585 import (
586 "context"
587 "errors"
588+ "fmt"
589+ "net/http"
590 "strings"
591 "sync"
592 "time"
593
594- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
595- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
596- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
597+ "github.com/TheEdgeOfRage/ytrssil-api/db"
598+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
599+ "github.com/TheEdgeOfRage/ytrssil-api/models"
600 )
601
602-func (h *handler) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
603- return h.db.GetNewVideos(ctx, username)
604+func (h *handler) GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
605+ return h.db.GetNewVideos(ctx, username, sortDesc)
606 }
607
608 func (h *handler) GetWatchedVideos(ctx context.Context, username string) ([]models.Video, error) {
609@@ -38,6 +40,32 @@ func (h *handler) addVideoToAllSubscribers(ctx context.Context, channelID string
610 return nil
611 }
612
613+func (h *handler) isShort(ctx context.Context, videoID string) (bool, error) {
614+ url := fmt.Sprintf("https://www.youtube.com/shorts/%s", videoID)
615+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
616+ if err != nil {
617+ h.log.Error(
618+ "Failed to create request to check if a video is a short",
619+ "videoID", videoID,
620+ "error", err,
621+ )
622+ return false, err
623+ }
624+
625+ client := &http.Client{
626+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
627+ return http.ErrUseLastResponse
628+ },
629+ }
630+ resp, err := client.Do(req)
631+ if err != nil {
632+ h.log.Error("Failed to check if a video is a short", "videoID", videoID, "error", err)
633+ return false, err
634+ }
635+
636+ return resp.StatusCode == http.StatusOK, nil
637+}
638+
639 func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedparser.Channel) {
640 for _, parsedVideo := range parsedChannel.Videos {
641 date, err := parsedVideo.Published.Parse()
642@@ -47,10 +75,15 @@ func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedpa
643 }
644
645 videoID := strings.Split(parsedVideo.ID, ":")[2]
646+ isShort, err := h.isShort(ctx, videoID)
647+ if err != nil {
648+ isShort = false
649+ }
650 video := models.Video{
651 ID: videoID,
652 Title: parsedVideo.Title,
653 PublishedTime: date,
654+ IsShort: isShort,
655 }
656 err = h.db.AddVideo(ctx, video, parsedChannel.ID)
657 if err != nil {
658diff --git a/httpserver/auth/auth.go b/httpserver/auth/auth.go
659index 16dbe7883359136c388d7c511f2d988c4289e715..0445cbcb98345951beb3e509c9e7c5b927c6eb85 100644
660--- a/httpserver/auth/auth.go
661+++ b/httpserver/auth/auth.go
662@@ -5,8 +5,8 @@ import (
663
664 "github.com/gin-gonic/gin"
665
666- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
667- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
668+ "github.com/TheEdgeOfRage/ytrssil-api/db"
669+ "github.com/TheEdgeOfRage/ytrssil-api/models"
670 )
671
672 // AuthMiddleware will authenticate against a static API key
673diff --git a/httpserver/auth/auth_test.go b/httpserver/auth/auth_test.go
674index 4b8f814503aac3da02687e969ed5821108255d43..141a150c11b06ee4e3c8cfee8c4b3a522ea2c409 100644
675--- a/httpserver/auth/auth_test.go
676+++ b/httpserver/auth/auth_test.go
677@@ -9,8 +9,8 @@ import (
678 "github.com/gin-gonic/gin"
679 "github.com/stretchr/testify/assert"
680
681- db_mock "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/mocks/db"
682- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
683+ db_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/db"
684+ "github.com/TheEdgeOfRage/ytrssil-api/models"
685 )
686
687 func setupTestServer() *http.Server {
688diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
689index f4a230d1774d971960d52c7dad40f1e84c867373..5506873c0dc52e0fc9aa8ba1fcff31aa22b8c488 100644
690--- a/httpserver/ytrssil/api_setup_test.go
691+++ b/httpserver/ytrssil/api_setup_test.go
692@@ -12,10 +12,10 @@ import (
693 "github.com/gin-gonic/gin"
694 "github.com/stretchr/testify/assert"
695
696- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/config"
697- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
698- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
699- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
700+ "github.com/TheEdgeOfRage/ytrssil-api/config"
701+ "github.com/TheEdgeOfRage/ytrssil-api/handler"
702+ "github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
703+ "github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
704 )
705
706 var testConfig config.Config
707diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
708index 78645179129ccce462b16aff7f0f22c5c7dd44c8..a726a86ff02960af6e167c467ee3db309c71160e 100644
709--- a/httpserver/ytrssil/channels.go
710+++ b/httpserver/ytrssil/channels.go
711@@ -6,12 +6,12 @@ import (
712
713 "github.com/gin-gonic/gin"
714
715- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
716- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
717- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
718+ "github.com/TheEdgeOfRage/ytrssil-api/db"
719+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
720+ "github.com/TheEdgeOfRage/ytrssil-api/models"
721 )
722
723-func (s *server) SubscribeToChannel(c *gin.Context) {
724+func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
725 var channel models.Channel
726 err := c.ShouldBindUri(&channel)
727 if err != nil {
728@@ -20,7 +20,7 @@ func (s *server) SubscribeToChannel(c *gin.Context) {
729 }
730 username := c.GetString("username")
731
732- err = s.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
733+ err = srv.handler.SubscribeToChannel(c.Request.Context(), username, channel.ID)
734 if err != nil {
735 if errors.Is(err, db.ErrAlreadySubscribed) {
736 c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
737@@ -38,7 +38,7 @@ func (s *server) SubscribeToChannel(c *gin.Context) {
738 c.JSON(http.StatusOK, gin.H{"msg": "subscribed to channel successfully"})
739 }
740
741-func (s *server) UnsubscribeFromChannel(c *gin.Context) {
742+func (srv *server) UnsubscribeFromChannelJSON(c *gin.Context) {
743 var channel models.Channel
744 err := c.ShouldBindUri(&channel)
745 if err != nil {
746@@ -47,7 +47,7 @@ func (s *server) UnsubscribeFromChannel(c *gin.Context) {
747 }
748 username := c.GetString("username")
749
750- err = s.handler.UnsubscribeFromChannel(c.Request.Context(), username, channel.ID)
751+ err = srv.handler.UnsubscribeFromChannel(c.Request.Context(), username, channel.ID)
752 if err != nil {
753 if errors.Is(err, db.ErrChannelNotFound) {
754 c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
755diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
756index 995a394597855ec3b07d418a5f37c834b6301cee..a6c54a8f042819c97619c254d909d14a35d7950f 100644
757--- a/httpserver/ytrssil/server.go
758+++ b/httpserver/ytrssil/server.go
759@@ -8,7 +8,7 @@ import (
760
761 "github.com/gin-gonic/gin"
762
763- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/handler"
764+ "github.com/TheEdgeOfRage/ytrssil-api/handler"
765 )
766
767 type server struct {
768@@ -32,7 +32,7 @@ func ginLogFormatter(param gin.LogFormatterParams) string {
769 )
770 }
771
772-func (s *server) Healthz(c *gin.Context) {
773+func (srv *server) Healthz(c *gin.Context) {
774 c.String(http.StatusOK, "healthy")
775 }
776
777@@ -44,25 +44,38 @@ func SetupGinRouter(l *slog.Logger, handler handler.Handler, authMiddleware func
778 gin.LoggerWithFormatter(ginLogFormatter),
779 gin.Recovery(), // Recovery needs to go before other middlewares to catch panics
780 )
781+ engine.HandleMethodNotAllowed = true
782+ engine.NoRoute(func(c *gin.Context) {
783+ c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "URL not found"})
784+ })
785+ engine.NoMethod(func(c *gin.Context) {
786+ c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"error": "HTTP method not allowed"})
787+ })
788
789 srv, err := NewServer(l, handler)
790 if err != nil {
791 return nil, err
792 }
793 engine.GET("/healthz", srv.Healthz)
794- engine.POST("/register", srv.CreateUser)
795- engine.POST("/fetch", srv.FetchVideos)
796+ engine.POST("/register", srv.CreateUserJSON)
797+ engine.POST("/fetch", srv.FetchVideosJSON)
798+
799+ pages := engine.Group("")
800+ pages.Use(authMiddleware)
801+
802+ pages.GET("/", srv.NewVideosPage)
803+ pages.POST("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
804
805 // all APIs go in this routing group and require authentication
806 api := engine.Group("/api")
807 api.Use(authMiddleware)
808 {
809- api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannel)
810- api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannel)
811- api.GET("videos/new", srv.GetNewVideos)
812- api.GET("videos/watched", srv.GetWatchedVideos)
813- api.POST("videos/:video_id/watch", srv.MarkVideoAsWatched)
814- api.POST("videos/:video_id/unwatch", srv.MarkVideoAsUnwatched)
815+ api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannelJSON)
816+ api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelJSON)
817+ api.GET("videos/new", srv.GetNewVideosJSON)
818+ api.GET("videos/watched", srv.GetWatchedVideosJSON)
819+ api.POST("videos/:video_id/watch", srv.MarkVideoAsWatchedJSON)
820+ api.POST("videos/:video_id/unwatch", srv.MarkVideoAsUnwatchedJSON)
821 }
822
823 return engine, nil
824diff --git a/httpserver/ytrssil/users.go b/httpserver/ytrssil/users.go
825index c9a426a3487697fc94a25786bfc801191c9f65ad..e9a475e0716834c4ccf2faf45cffd804000a3e3c 100644
826--- a/httpserver/ytrssil/users.go
827+++ b/httpserver/ytrssil/users.go
828@@ -6,11 +6,11 @@ import (
829
830 "github.com/gin-gonic/gin"
831
832- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
833- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
834+ "github.com/TheEdgeOfRage/ytrssil-api/db"
835+ "github.com/TheEdgeOfRage/ytrssil-api/models"
836 )
837
838-func (s *server) CreateUser(c *gin.Context) {
839+func (srv *server) CreateUserJSON(c *gin.Context) {
840 var user models.User
841 err := c.ShouldBindJSON(&user)
842 if err != nil {
843@@ -18,7 +18,7 @@ func (s *server) CreateUser(c *gin.Context) {
844 return
845 }
846
847- err = s.handler.CreateUser(c.Request.Context(), user)
848+ err = srv.handler.CreateUser(c.Request.Context(), user)
849 if err != nil {
850 if errors.Is(err, db.ErrUserExists) {
851 c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
852diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
853index c46106d6cca434fd66b12117f98ae51bef56875e..d4fe61635c8506c79d69d1a9faeab60663806503 100644
854--- a/httpserver/ytrssil/videos.go
855+++ b/httpserver/ytrssil/videos.go
856@@ -5,12 +5,12 @@ import (
857
858 "github.com/gin-gonic/gin"
859
860- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
861+ "github.com/TheEdgeOfRage/ytrssil-api/models"
862 )
863
864-func (s *server) GetNewVideos(c *gin.Context) {
865+func (srv *server) GetNewVideosJSON(c *gin.Context) {
866 username := c.GetString("username")
867- videos, err := s.handler.GetNewVideos(c.Request.Context(), username)
868+ videos, err := srv.handler.GetNewVideos(c.Request.Context(), username, false)
869 if err != nil {
870 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
871 return
872@@ -21,9 +21,9 @@ func (s *server) GetNewVideos(c *gin.Context) {
873 })
874 }
875
876-func (s *server) GetWatchedVideos(c *gin.Context) {
877+func (srv *server) GetWatchedVideosJSON(c *gin.Context) {
878 username := c.GetString("username")
879- videos, err := s.handler.GetWatchedVideos(c.Request.Context(), username)
880+ videos, err := srv.handler.GetWatchedVideos(c.Request.Context(), username)
881 if err != nil {
882 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
883 return
884@@ -34,8 +34,8 @@ func (s *server) GetWatchedVideos(c *gin.Context) {
885 })
886 }
887
888-func (s *server) FetchVideos(c *gin.Context) {
889- err := s.handler.FetchVideos(c.Request.Context())
890+func (srv *server) FetchVideosJSON(c *gin.Context) {
891+ err := srv.handler.FetchVideos(c.Request.Context())
892 if err != nil {
893 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
894 return
895@@ -44,7 +44,7 @@ func (s *server) FetchVideos(c *gin.Context) {
896 c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"})
897 }
898
899-func (s *server) MarkVideoAsWatched(c *gin.Context) {
900+func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
901 username := c.GetString("username")
902 var req models.VideoURIRequest
903 err := c.ShouldBindUri(&req)
904@@ -53,7 +53,7 @@ func (s *server) MarkVideoAsWatched(c *gin.Context) {
905 return
906 }
907
908- err = s.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID)
909+ err = srv.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID)
910 if err != nil {
911 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
912 return
913@@ -62,7 +62,7 @@ func (s *server) MarkVideoAsWatched(c *gin.Context) {
914 c.JSON(http.StatusOK, gin.H{"msg": "marked video as watched"})
915 }
916
917-func (s *server) MarkVideoAsUnwatched(c *gin.Context) {
918+func (srv *server) MarkVideoAsUnwatchedJSON(c *gin.Context) {
919 username := c.GetString("username")
920 var req models.VideoURIRequest
921 err := c.ShouldBindUri(&req)
922@@ -71,7 +71,7 @@ func (s *server) MarkVideoAsUnwatched(c *gin.Context) {
923 return
924 }
925
926- err = s.handler.MarkVideoAsUnwatched(c.Request.Context(), username, req.VideoID)
927+ err = srv.handler.MarkVideoAsUnwatched(c.Request.Context(), username, req.VideoID)
928 if err != nil {
929 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
930 return
931diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
932new file mode 100644
933index 0000000000000000000000000000000000000000..288f7294af3bbe2c815ebc4e06658fc06e833eb3
934--- /dev/null
935+++ b/httpserver/ytrssil/videos_pages.go
936@@ -0,0 +1,49 @@
937+package ytrssil
938+
939+import (
940+ "net/http"
941+
942+ "github.com/gin-gonic/gin"
943+
944+ "github.com/TheEdgeOfRage/ytrssil-api/models"
945+ "github.com/TheEdgeOfRage/ytrssil-api/pages"
946+)
947+
948+func (srv server) NewVideosPage(c *gin.Context) {
949+ username := c.GetString("username")
950+ videos, err := srv.handler.GetNewVideos(c.Request.Context(), username, true)
951+ if err != nil {
952+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
953+ return
954+ }
955+
956+ r := pages.TemplRenderer{
957+ Ctx: c.Request.Context(),
958+ Component: pages.NewVideosPage(videos),
959+ }
960+ c.Render(http.StatusOK, r)
961+}
962+
963+func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
964+ r := pages.TemplRenderer{
965+ Ctx: c.Request.Context(),
966+ }
967+
968+ username := c.GetString("username")
969+ var req models.VideoURIRequest
970+ err := c.ShouldBindUri(&req)
971+ if err != nil {
972+ r.Component = pages.ErrorPage(err)
973+ c.Render(http.StatusBadRequest, r)
974+ return
975+ }
976+
977+ err = srv.handler.MarkVideoAsWatched(c.Request.Context(), username, req.VideoID)
978+ if err != nil {
979+ r.Component = pages.ErrorPage(err)
980+ c.Render(http.StatusInternalServerError, r)
981+ return
982+ }
983+
984+ c.String(http.StatusOK, "")
985+}
986diff --git a/migrations/000002_add_is_short_column.down.sql b/migrations/000002_add_is_short_column.down.sql
987new file mode 100644
988index 0000000000000000000000000000000000000000..43f4ef277112c12974ab921fb22a8896c18056fc
989--- /dev/null
990+++ b/migrations/000002_add_is_short_column.down.sql
991@@ -0,0 +1 @@
992+ALTER TABLE videos DROP COLUMN IF EXISTS is_short;
993diff --git a/migrations/000002_add_is_short_column.up.sql b/migrations/000002_add_is_short_column.up.sql
994new file mode 100644
995index 0000000000000000000000000000000000000000..c301b43401aaa4301cfcf8053ba5b2725649ea75
996--- /dev/null
997+++ b/migrations/000002_add_is_short_column.up.sql
998@@ -0,0 +1 @@
999+ALTER TABLE videos ADD COLUMN IF NOT EXISTS is_short BOOLEAN DEFAULT false;
1000diff --git a/mocks/db/db.go b/mocks/db/db.go
1001index 4291a0015ebf421af8769d0942d6331f60cbee96..31c171fec25e6f06997a80fc7eb97f917d43b45b 100644
1002--- a/mocks/db/db.go
1003+++ b/mocks/db/db.go
1004@@ -5,8 +5,8 @@ package db_mock
1005
1006 import (
1007 "context"
1008- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/db"
1009- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/models"
1010+ "github.com/TheEdgeOfRage/ytrssil-api/db"
1011+ "github.com/TheEdgeOfRage/ytrssil-api/models"
1012 "sync"
1013 "time"
1014 )
1015@@ -42,7 +42,7 @@ var _ db.DB = &DBMock{}
1016 // GetChannelSubscribersFunc: func(ctx context.Context, channelID string) ([]string, error) {
1017 // panic("mock out the GetChannelSubscribers method")
1018 // },
1019-// GetNewVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
1020+// GetNewVideosFunc: func(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
1021 // panic("mock out the GetNewVideos method")
1022 // },
1023 // GetWatchedVideosFunc: func(ctx context.Context, username string) ([]models.Video, error) {
1024@@ -89,7 +89,7 @@ type DBMock struct {
1025 GetChannelSubscribersFunc func(ctx context.Context, channelID string) ([]string, error)
1026
1027 // GetNewVideosFunc mocks the GetNewVideos method.
1028- GetNewVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
1029+ GetNewVideosFunc func(ctx context.Context, username string, sortDesc bool) ([]models.Video, error)
1030
1031 // GetWatchedVideosFunc mocks the GetWatchedVideos method.
1032 GetWatchedVideosFunc func(ctx context.Context, username string) ([]models.Video, error)
1033@@ -167,6 +167,8 @@ type DBMock struct {
1034 Ctx context.Context
1035 // Username is the username argument value.
1036 Username string
1037+ // SortDesc is the sortDesc argument value.
1038+ SortDesc bool
1039 }
1040 // GetWatchedVideos holds details about calls to the GetWatchedVideos method.
1041 GetWatchedVideos []struct {
1042@@ -486,21 +488,23 @@ func (mock *DBMock) GetChannelSubscribersCalls() []struct {
1043 }
1044
1045 // GetNewVideos calls GetNewVideosFunc.
1046-func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]models.Video, error) {
1047+func (mock *DBMock) GetNewVideos(ctx context.Context, username string, sortDesc bool) ([]models.Video, error) {
1048 if mock.GetNewVideosFunc == nil {
1049 panic("DBMock.GetNewVideosFunc: method is nil but DB.GetNewVideos was just called")
1050 }
1051 callInfo := struct {
1052 Ctx context.Context
1053 Username string
1054+ SortDesc bool
1055 }{
1056 Ctx: ctx,
1057 Username: username,
1058+ SortDesc: sortDesc,
1059 }
1060 mock.lockGetNewVideos.Lock()
1061 mock.calls.GetNewVideos = append(mock.calls.GetNewVideos, callInfo)
1062 mock.lockGetNewVideos.Unlock()
1063- return mock.GetNewVideosFunc(ctx, username)
1064+ return mock.GetNewVideosFunc(ctx, username, sortDesc)
1065 }
1066
1067 // GetNewVideosCalls gets all the calls that were made to GetNewVideos.
1068@@ -510,10 +514,12 @@ func (mock *DBMock) GetNewVideos(ctx context.Context, username string) ([]models
1069 func (mock *DBMock) GetNewVideosCalls() []struct {
1070 Ctx context.Context
1071 Username string
1072+ SortDesc bool
1073 } {
1074 var calls []struct {
1075 Ctx context.Context
1076 Username string
1077+ SortDesc bool
1078 }
1079 mock.lockGetNewVideos.RLock()
1080 calls = mock.calls.GetNewVideos
1081diff --git a/mocks/feedparser/feedparser.go b/mocks/feedparser/feedparser.go
1082index 64502bf73e3f70fcbe6e78c762d2907d4a2defc7..d0506dae58080e1d8915488f73cd4c8e2641e6a5 100644
1083--- a/mocks/feedparser/feedparser.go
1084+++ b/mocks/feedparser/feedparser.go
1085@@ -4,7 +4,7 @@
1086 package parser_mock
1087
1088 import (
1089- "gitea.theedgeofrage.com/TheEdgeOfRage/ytrssil-api/feedparser"
1090+ "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
1091 "sync"
1092 )
1093
1094diff --git a/models/video.go b/models/video.go
1095index 581795ad1a77f8b4869299e5784f7a8b35c185f6..9b50e06fea2ba9d4123c8eb2401fee56e51a92cf 100644
1096--- a/models/video.go
1097+++ b/models/video.go
1098@@ -9,10 +9,19 @@ type Video struct {
1099 ID string `json:"video_id"`
1100 // Name of the channel the video belongs to
1101 ChannelName string `json:"channel_name"`
1102+ // ID of the channel the video belongs to
1103+ ChannelID string `json:"-"`
1104 // Title of the video
1105 Title string `json:"title"`
1106 // Video publish timestamp
1107 PublishedTime time.Time `json:"published_timestamp"`
1108 // Video watch timestamp
1109 WatchTime *time.Time `json:"watch_timestamp"`
1110+ // IsShort indicates if a video is a YouTube short
1111+ IsShort bool `json:"short"`
1112+}
1113+
1114+type PaginatedVideos struct {
1115+ Videos []Video
1116+ NextOffset int
1117 }
1118diff --git a/pages/error.templ b/pages/error.templ
1119new file mode 100644
1120index 0000000000000000000000000000000000000000..35b8b02c43f9362612896555244b3003fa9c6dad
1121--- /dev/null
1122+++ b/pages/error.templ
1123@@ -0,0 +1,14 @@
1124+package pages
1125+
1126+import "fmt"
1127+
1128+templ ErrorPage(err error) {
1129+ <div class="toast align-items-center text-bg-danger border-0" role="alert">
1130+ <div class="d-flex">
1131+ <div class="toast-body">
1132+ { fmt.Sprintf("Error: %s", err.Error()) }
1133+ </div>
1134+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
1135+ </div>
1136+ </div>
1137+}
1138diff --git a/pages/error_templ.go b/pages/error_templ.go
1139new file mode 100644
1140index 0000000000000000000000000000000000000000..04e3ca881c58552b3b83f4e37bd3bd8e31719608
1141--- /dev/null
1142+++ b/pages/error_templ.go
1143@@ -0,0 +1,55 @@
1144+// Code generated by templ - DO NOT EDIT.
1145+
1146+// templ: version: v0.3.960
1147+package pages
1148+
1149+//lint:file-ignore SA4006 This context is only used if a nested component is present.
1150+
1151+import "github.com/a-h/templ"
1152+import templruntime "github.com/a-h/templ/runtime"
1153+
1154+import "fmt"
1155+
1156+func ErrorPage(err error) templ.Component {
1157+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1158+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1159+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1160+ return templ_7745c5c3_CtxErr
1161+ }
1162+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1163+ if !templ_7745c5c3_IsBuffer {
1164+ defer func() {
1165+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1166+ if templ_7745c5c3_Err == nil {
1167+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1168+ }
1169+ }()
1170+ }
1171+ ctx = templ.InitializeContext(ctx)
1172+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
1173+ if templ_7745c5c3_Var1 == nil {
1174+ templ_7745c5c3_Var1 = templ.NopComponent
1175+ }
1176+ ctx = templ.ClearChildren(ctx)
1177+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"toast align-items-center text-bg-danger border-0\" role=\"alert\"><div class=\"d-flex\"><div class=\"toast-body\">")
1178+ if templ_7745c5c3_Err != nil {
1179+ return templ_7745c5c3_Err
1180+ }
1181+ var templ_7745c5c3_Var2 string
1182+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Error: %s", err.Error()))
1183+ if templ_7745c5c3_Err != nil {
1184+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/error.templ`, Line: 9, Col: 43}
1185+ }
1186+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
1187+ if templ_7745c5c3_Err != nil {
1188+ return templ_7745c5c3_Err
1189+ }
1190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div><button type=\"button\" class=\"btn-close btn-close-white me-2 m-auto\" data-bs-dismiss=\"toast\"></button></div></div>")
1191+ if templ_7745c5c3_Err != nil {
1192+ return templ_7745c5c3_Err
1193+ }
1194+ return nil
1195+ })
1196+}
1197+
1198+var _ = templruntime.GeneratedTemplate
1199diff --git a/pages/templrenderer.go b/pages/templrenderer.go
1200new file mode 100644
1201index 0000000000000000000000000000000000000000..43cf3111a75ae35eed3a0692eefdb4a249e3b767
1202--- /dev/null
1203+++ b/pages/templrenderer.go
1204@@ -0,0 +1,25 @@
1205+package pages
1206+
1207+import (
1208+ "context"
1209+ "net/http"
1210+
1211+ "github.com/a-h/templ"
1212+)
1213+
1214+type TemplRenderer struct {
1215+ Ctx context.Context
1216+ Component templ.Component
1217+}
1218+
1219+func (t TemplRenderer) Render(w http.ResponseWriter) error {
1220+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
1221+ if t.Component != nil {
1222+ return t.Component.Render(t.Ctx, w)
1223+ }
1224+ return nil
1225+}
1226+
1227+func (t TemplRenderer) WriteContentType(w http.ResponseWriter) {
1228+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
1229+}
1230diff --git a/pages/videos.templ b/pages/videos.templ
1231new file mode 100644
1232index 0000000000000000000000000000000000000000..854942952d693f7a189779bc60b171e7f719e448
1233--- /dev/null
1234+++ b/pages/videos.templ
1235@@ -0,0 +1,115 @@
1236+package pages
1237+
1238+import (
1239+ "fmt"
1240+
1241+ "github.com/TheEdgeOfRage/ytrssil-api/models"
1242+)
1243+
1244+templ thumbnail(video models.Video) {
1245+ <a
1246+ target="blank"
1247+ href={ templ.SafeURL(fmt.Sprintf("https://youtube.com/watch?v=%s", video.ID)) }
1248+ style="display: block; overflow: hidden;"
1249+ if video.IsShort {
1250+ class="pillarbox"
1251+ }
1252+ >
1253+ if video.IsShort {
1254+ <img
1255+ src={ fmt.Sprintf("https://img.youtube.com/vi/%s/0.jpg", video.ID) }
1256+ class="card-img-top"
1257+ alt={ video.Title }
1258+ style="width: 100%; aspect-ratio: 16/9; object-fit: contain;"
1259+ />
1260+ } else {
1261+ <img
1262+ src={ fmt.Sprintf("https://img.youtube.com/vi/%s/0.jpg", video.ID) }
1263+ class="card-img-top"
1264+ alt={ video.Title }
1265+ style="width: 100%; aspect-ratio: 16/9; object-fit: cover;"
1266+ />
1267+ }
1268+ </a>
1269+ // <div class="progress" role="progressbar" style="height: 6px">
1270+ // <div class="progress-bar w-75" style="background: #b11"></div>
1271+ // </div>
1272+}
1273+
1274+templ videoCard(video models.Video) {
1275+ <div class="card">
1276+ @thumbnail(video)
1277+ <div class="card-body">
1278+ if video.IsShort {
1279+ <p class="card-title text-truncate mb-0">
1280+ <svg
1281+ xmlns="http://www.w3.org/2000/svg"
1282+ height="24"
1283+ viewBox="0 0 24 24"
1284+ width="24"
1285+ focusable="false"
1286+ aria-hidden="true"
1287+ style="pointer-events: none; display: inherit; width: 2rem; height: 2rem;"
1288+ class="float-end"
1289+ fill="white"
1290+ ><path d="m13.974 2.052-8 4.7a4 4 0 00.385 7.097l.942.423-1.327.78a4 4 0 004.052 6.897l8-4.7a4.001 4.001 0 00-.384-7.096L16.7 9.73l1.326-.78a4 4 0 10-4.052-6.897ZM10 15V9l5 3-5 3Z"></path></svg>
1291+ { video.Title }
1292+ </p>
1293+ } else {
1294+ <p class="card-title text-truncate">{ video.Title }</p>
1295+ }
1296+ <p>
1297+ <a
1298+ target="blank"
1299+ href={ fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID) }
1300+ class="card-text fw-light link-light link-underline-opacity-0"
1301+ >{ video.ChannelName }</a>
1302+ </p>
1303+ <div class="d-flex justify-content-between">
1304+ // <form hx-post="/submit-form" hx-target="#response" class="d-flex">
1305+ // <input type="text" name="time" placeholder="hh:mm:ss" class="form-control "/>
1306+ // <button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i> </button>
1307+ // </form>
1308+ <button
1309+ hx-post={ fmt.Sprintf("/videos/%s/watch", video.ID) }
1310+ hx-target="closest .video-card"
1311+ hx-swap="outerHTML"
1312+ class="btn btn-danger"
1313+ >
1314+ <i class="bi bi-check-circle"></i>
1315+ </button>
1316+ </div>
1317+ </div>
1318+ </div>
1319+}
1320+
1321+templ NewVideosPage(videos []models.Video) {
1322+ <!DOCTYPE html>
1323+ <html lang="en" data-bs-theme="dark">
1324+ <head>
1325+ <title>ytrssil</title>
1326+ <meta charset="utf-8"/>
1327+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
1328+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"/>
1329+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css"/>
1330+ <style>
1331+ .pillarbox { position: relative; }
1332+ .pillarbox::after { right: 0; }
1333+ .pillarbox::before,
1334+ .pillarbox::after { content: ''; position: absolute; top: 0; bottom: 0%; width: 36%; background: black; z-index: 1; }
1335diff --git a/pages/videos_templ.go b/pages/videos_templ.go
1336new file mode 100644
1337index 0000000000000000000000000000000000000000..705ec8e28973ace4410bb278f750b08f2b92ecbf
1338--- /dev/null
1339+++ b/pages/videos_templ.go
1340@@ -0,0 +1,300 @@
1341+// Code generated by templ - DO NOT EDIT.
1342+
1343+// templ: version: v0.3.960
1344+package pages
1345+
1346+//lint:file-ignore SA4006 This context is only used if a nested component is present.
1347+
1348+import "github.com/a-h/templ"
1349+import templruntime "github.com/a-h/templ/runtime"
1350+
1351+import (
1352+ "fmt"
1353+
1354+ "github.com/TheEdgeOfRage/ytrssil-api/models"
1355+)
1356+
1357+func thumbnail(video models.Video) templ.Component {
1358+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1359+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1360+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1361+ return templ_7745c5c3_CtxErr
1362+ }
1363+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1364+ if !templ_7745c5c3_IsBuffer {
1365+ defer func() {
1366+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1367+ if templ_7745c5c3_Err == nil {
1368+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1369+ }
1370+ }()
1371+ }
1372+ ctx = templ.InitializeContext(ctx)
1373+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
1374+ if templ_7745c5c3_Var1 == nil {
1375+ templ_7745c5c3_Var1 = templ.NopComponent
1376+ }
1377+ ctx = templ.ClearChildren(ctx)
1378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a target=\"blank\" href=\"")
1379+ if templ_7745c5c3_Err != nil {
1380+ return templ_7745c5c3_Err
1381+ }
1382+ var templ_7745c5c3_Var2 templ.SafeURL
1383+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("https://youtube.com/watch?v=%s", video.ID)))
1384+ if templ_7745c5c3_Err != nil {
1385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 12, Col: 79}
1386+ }
1387+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
1388+ if templ_7745c5c3_Err != nil {
1389+ return templ_7745c5c3_Err
1390+ }
1391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" style=\"display: block; overflow: hidden;\"")
1392+ if templ_7745c5c3_Err != nil {
1393+ return templ_7745c5c3_Err
1394+ }
1395+ if video.IsShort {
1396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " class=\"pillarbox\"")
1397+ if templ_7745c5c3_Err != nil {
1398+ return templ_7745c5c3_Err
1399+ }
1400+ }
1401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, ">")
1402+ if templ_7745c5c3_Err != nil {
1403+ return templ_7745c5c3_Err
1404+ }
1405+ if video.IsShort {
1406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<img src=\"")
1407+ if templ_7745c5c3_Err != nil {
1408+ return templ_7745c5c3_Err
1409+ }
1410+ var templ_7745c5c3_Var3 string
1411+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("https://img.youtube.com/vi/%s/0.jpg", video.ID))
1412+ if templ_7745c5c3_Err != nil {
1413+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 20, Col: 70}
1414+ }
1415+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
1416+ if templ_7745c5c3_Err != nil {
1417+ return templ_7745c5c3_Err
1418+ }
1419+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"card-img-top\" alt=\"")
1420+ if templ_7745c5c3_Err != nil {
1421+ return templ_7745c5c3_Err
1422+ }
1423+ var templ_7745c5c3_Var4 string
1424+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
1425+ if templ_7745c5c3_Err != nil {
1426+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/videos.templ`, Line: 22, Col: 21}
1427+ }
1428+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
1429+ if templ_7745c5c3_Err != nil {
1430+ return templ_7745c5c3_Err
1431+ }
1432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"width: 100%; aspect-ratio: 16/9; object-fit: contain;\">")
1433+ if templ_7745c5c3_Err != nil {
1434+ return templ_7745c5c3_Err
1435+ }
1436+ } else {
1437+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<img src=\"")
1438+ if templ_7745c5c3_Err != nil {
1439+ return templ_7745c5c3_Err