d2caed5e613ff0167b949a27fcaf07ee0da72d8f

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

Message

Add new video page and flag shorts

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