2a71367c9a5a2f6c172788b645b45988354ee592

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

Message

Implement downloads

Diff

This diff is truncated to protect this page.

   1diff --git a/.air.toml b/.air.toml
   2index 9f8c40e45aa3722347d1504ffe7101de03c32764..63f8898352330f6a96b975d53e63aec4da389ad2 100644
   3--- a/.air.toml
   4+++ b/.air.toml
   5@@ -5,6 +5,7 @@ tmp_dir = "/tmp"
   6   args_bin = [
   7     "--db-uri=postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable",
   8     "--auth-token=foo",
   9+    "--downloads-dir=/tmp/ytrssil",
  10     "--dev",
  11   ]
  12   entrypoint = "./dist/ytrssil"
  13diff --git a/CLAUDE.md b/CLAUDE.md
  14index 3f47d715450989fea81355bcac156960e8b7e4a7..0dcd946a8f1875ce7916e7c6119898dee31e1f82 100644
  15--- a/CLAUDE.md
  16+++ b/CLAUDE.md
  17@@ -1,3 +1,21 @@
  18 - Don't use serena for templ files, it only supports normal go code.
  19 - Don't attempt to edit the generated templ code
  20 - Don't invoke ANY go commands other than `go vet` and `go mod tidy`
  21+
  22+## Architecture
  23+
  24+**Ytrssil** is a YouTube RSS feed aggregator with video download capabilities built with Go, Gin, PostgreSQL, and Templ for server-side rendering.
  25+
  26+**Tech Stack**: Go 1.25, Gin (HTTP), Templ (SSR), PostgreSQL (pgx), yt-dlp (downloads), HTMX (frontend interactivity), Bootstrap 5 (UI)
  27+
  28+**Layers**:
  29+- **HTTP**: Gin router with dual interfaces (HTML pages + JSON API), authentication middleware, HTMX-enabled templates
  30+- **Handler**: Business logic layer orchestrating channels, videos, downloads, and cleanup routines
  31+- **Database**: PostgreSQL with pgx connection pooling, 7 migrations tracking schema evolution
  32+- **External**: YouTube API client, RSS feed parser, yt-dlp downloader wrapper
  33+
  34+**Key Features**: Subscribe to YouTube channels via RSS, track watched/unwatched videos with progress, async video downloads with status polling, automatic file cleanup (2 days after watched), YouTube Shorts detection
  35+
  36+**Background Jobs**: Video fetcher (5min intervals), cleanup routine (1hr intervals) - both disabled in dev mode
  37+
  38+**Directory Structure**: `cmd/` (main), `config/`, `db/`, `models/`, `handler/`, `httpserver/`, `pages/` (templ), `lib/clients/`, `lib/downloader/`, `feedparser/`, `migrations/`, `assets/`
  39diff --git a/Dockerfile b/Dockerfile
  40index 3376c084f843a32837229e58d982dfe9949b260a..80bc2024c516e5ad9df178c164b2c363ea995905 100644
  41--- a/Dockerfile
  42+++ b/Dockerfile
  43@@ -5,24 +5,25 @@ ENV GOCACHE="/cache"
  44 ENV PORT="80"
  45 
  46 COPY go.mod go.sum /app/
  47-RUN --mount=type=cache,target="/cache" go mod download
  48+RUN --mount=type=cache,target="/cache" go mod download;
  49 
  50 COPY . /app/
  51-RUN --mount=type=cache,target="/cache" go build -o dist/ytrssil-api cmd/main.go
  52+RUN --mount=type=cache,target="/cache" go build -o dist/ytrssil-api cmd/main.go;
  53 
  54 FROM debian:trixie-slim AS api
  55 
  56 HEALTHCHECK --start-period=2s --start-interval=2s CMD exec curl -sf localhost:$PORT/healthz
  57 WORKDIR /app/
  58 ENTRYPOINT ["./ytrssil-api"]
  59+VOLUME /var/lib/ytrssil/downloads
  60 
  61 RUN apt update \
  62 	&& apt install -y ca-certificates curl \
  63 	&& apt clean \
  64-	&& rm -rf /var/lib/apt/lists/*
  65+	&& rm -rf /var/lib/apt/lists/*;
  66 
  67 COPY ./assets/ ./assets/
  68-RUN assets/load.sh && rm assets/load.sh
  69+RUN assets/load.sh && rm assets/load.sh;
  70 
  71 COPY --from=builder /app/dist/ ./
  72 
  73diff --git a/api_docs.md b/api_docs.md
  74deleted file mode 100644
  75index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
  76--- a/api_docs.md
  77+++ /dev/null
  78diff --git a/cmd/main.go b/cmd/main.go
  79index 5fa2689505f1fcc021062ac57ba60e164aa0a9e9..a0325ba9f3772396643d6753f985165d74749127 100644
  80--- a/cmd/main.go
  81+++ b/cmd/main.go
  82@@ -19,6 +19,7 @@ import (
  83 	"github.com/TheEdgeOfRage/ytrssil-api/handler"
  84 	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
  85 	"github.com/TheEdgeOfRage/ytrssil-api/lib/clients/youtube"
  86+	"github.com/TheEdgeOfRage/ytrssil-api/lib/downloader"
  87 )
  88 
  89 func init() {
  90@@ -62,7 +63,12 @@ func main() {
  91 	defer db.Close()
  92 	parser := feedparser.NewParser(logger)
  93 	youTubeClient := youtube.NewYouTubeClient(logger, cfg.YouTubeAPIKey)
  94-	handler := handler.New(logger, db, parser, youTubeClient)
  95+	downloader := downloader.NewYtdlpDownloader(logger)
  96+	if err := downloader.ValidateInstallation(); err != nil {
  97+		logger.Error("yt-dlp validation failed", "error", err)
  98+		return
  99+	}
 100+	handler := handler.New(logger, db, parser, youTubeClient, downloader, cfg.DownloadsDir)
 101 	if cfg.Dev {
 102 		gin.SetMode(gin.DebugMode)
 103 	} else {
 104@@ -95,6 +101,13 @@ func main() {
 105 		})
 106 	}
 107 
 108+	cleanupContext, cancelCleanup := context.WithCancel(context.Background())
 109+	if !cfg.Dev {
 110+		wg.Go(func() {
 111+			handler.CleanupRoutine(cleanupContext)
 112+		})
 113+	}
 114+
 115 	wg.Go(func() {
 116 		logger.Info("ytrssil API is starting up", "port", cfg.Port)
 117 		if err := server.ListenAndServe(); err != nil {
 118@@ -116,6 +129,9 @@ func main() {
 119 		)
 120 	}
 121 	cancelFetcher()
 122+	if !cfg.Dev {
 123+		cancelCleanup()
 124+	}
 125 
 126 	wg.Wait()
 127 	logger.Info("exit complete")
 128diff --git a/compose.yaml b/compose.yaml
 129index 82c4bf76cd7941ec2f5dfc099dd59fbe794bab32..12ef6cdf4a85f99d32fe4c51027ae780273dc307 100644
 130--- a/compose.yaml
 131+++ b/compose.yaml
 132@@ -28,6 +28,8 @@ services:
 133       PORT: "80"
 134     ports:
 135       - "8080:80"
 136+    volumes:
 137+      - data:/var/lib/ytrssil
 138 
 139   migrations:
 140     image: theedgeofrage/ytrssil:migrations
 141@@ -42,3 +44,4 @@ services:
 142 
 143 volumes:
 144   postgres:
 145+  data:
 146diff --git a/config/config.go b/config/config.go
 147index 762ebf76bae42339e8767eabc123d620127be740..b445a586d04b1245a98d6511667b53af4a1cf211 100644
 148--- a/config/config.go
 149+++ b/config/config.go
 150@@ -14,6 +14,7 @@ type Config struct {
 151 	DBURI         string `long:"db-uri" env:"DB_URI"`
 152 	AuthToken     string `long:"auth-token" env:"AUTH_TOKEN"`
 153 	YouTubeAPIKey string `long:"youtube-api-key" env:"YOUTUBE_API_KEY"`
 154+	DownloadsDir  string `long:"downloads-dir" env:"DOWNLOADS_DIR" default:"/var/lib/ytrssil/downloads"`
 155 }
 156 
 157 func getenvOrDefault(key string, defaultValue string) string {
 158@@ -41,6 +42,13 @@ func Parse() (Config, error) {
 159 		return config, fmt.Errorf("missing YOUTUBE_API_KEY env var")
 160 	}
 161 
 162+	if config.DownloadsDir == "" {
 163+		return config, fmt.Errorf("missing DOWNLOADS_DIR env var")
 164+	}
 165+	if err := os.MkdirAll(config.DownloadsDir, 0o755); err != nil {
 166+		return config, fmt.Errorf("failed to create downloads directory: %w", err)
 167+	}
 168+
 169 	return config, nil
 170 }
 171 
 172@@ -52,9 +60,10 @@ func TestConfig() Config {
 173 	}
 174 
 175 	config := Config{
 176-		Port:      8080,
 177-		DBURI:     dbURI,
 178-		AuthToken: "foo",
 179+		Port:         8080,
 180+		DBURI:        dbURI,
 181+		AuthToken:    "foo",
 182+		DownloadsDir: "/tmp/ytrssil-test-downloads",
 183 	}
 184 
 185 	return config
 186diff --git a/db/db.go b/db/db.go
 187index 02b74cb1ce8e9cd14d5793cf5a937047fb0e9422..37d5fc435c24b31fa65cc6546fe0af7b3581dabb 100644
 188--- a/db/db.go
 189+++ b/db/db.go
 190@@ -36,6 +36,18 @@ type DB interface {
 191 	SetVideoWatchTime(ctx context.Context, videoID string, watchTime *time.Time) error
 192 	// SetVideoProgress sets or unsets the watch progress of a video
 193 	SetVideoProgress(ctx context.Context, videoID string, progress int) (*models.Video, error)
 194+	// GetVideo returns a single video by ID
 195+	GetVideo(ctx context.Context, videoID string) (*models.Video, error)
 196+	// SetVideoDownloadStatus sets the download status for a video
 197+	SetVideoDownloadStatus(ctx context.Context, videoID string, status string) error
 198+	// SetVideoDownloadCompleted marks a video download as completed
 199+	SetVideoDownloadCompleted(ctx context.Context, videoID string, filePath string) error
 200+	// SetVideoDownloadFailed marks a video download as failed with an error message
 201+	SetVideoDownloadFailed(ctx context.Context, videoID string, errorMsg string) error
 202+	// GetVideosForCleanup returns videos that were downloaded and watched older than the given duration
 203+	GetVideosForCleanup(ctx context.Context, olderThan time.Duration) ([]models.Video, error)
 204+	// DeleteVideoFile clears the download fields for a video
 205+	DeleteVideoFile(ctx context.Context, videoID string) error
 206 
 207 	// Close closes the DB connection
 208 	Close()
 209diff --git a/db/videos.go b/db/videos.go
 210index 700868a76f9411a178379709e0ac9da56e533b1a..888b9553c0c9a6c71a9a3631f6b4c9894f277c6f 100644
 211--- a/db/videos.go
 212+++ b/db/videos.go
 213@@ -16,6 +16,10 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
 214 			, is_short
 215 			, duration
 216 			, progress
 217+			, downloaded_at
 218+			, file_path
 219+			, download_status
 220+			, download_error
 221 			, channels.name
 222 			, channels.id
 223 		FROM videos
 224@@ -44,6 +48,10 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
 225 			&video.IsShort,
 226 			&video.DurationSeconds,
 227 			&video.ProgressSeconds,
 228+			&video.DownloadedAt,
 229+			&video.FilePath,
 230+			&video.DownloadStatus,
 231+			&video.DownloadError,
 232 			&video.ChannelName,
 233 			&video.ChannelID,
 234 		)
 235@@ -69,6 +77,10 @@ func (db *postgresDB) GetWatchedVideos(
 236 			, is_short
 237 			, duration
 238 			, progress
 239+			, downloaded_at
 240+			, file_path
 241+			, download_status
 242+			, download_error
 243 			, channels.name
 244 			, channels.id
 245 		FROM videos
 246@@ -99,6 +111,10 @@ func (db *postgresDB) GetWatchedVideos(
 247 			&video.IsShort,
 248 			&video.DurationSeconds,
 249 			&video.ProgressSeconds,
 250+			&video.DownloadedAt,
 251+			&video.FilePath,
 252+			&video.DownloadStatus,
 253+			&video.DownloadError,
 254 			&video.ChannelName,
 255 			&video.ChannelID,
 256 		)
 257@@ -212,3 +228,143 @@ func (db *postgresDB) SetVideoProgress(ctx context.Context, videoID string, prog
 258 
 259 	return &video, nil
 260 }
 261+
 262+func (db *postgresDB) GetVideo(ctx context.Context, videoID string) (*models.Video, error) {
 263+	query := `
 264+		SELECT
 265+			videos.id
 266+			, title
 267+			, published_timestamp
 268+			, is_short
 269+			, duration
 270+			, progress
 271+			, watch_timestamp
 272+			, downloaded_at
 273+			, file_path
 274+			, download_status
 275+			, download_error
 276+			, channels.name
 277+			, channels.id
 278+		FROM videos
 279+		LEFT JOIN channels ON videos.channel_id = channels.id
 280+		WHERE videos.id = $1
 281+	`
 282+
 283+	row := db.db.QueryRow(ctx, query, videoID)
 284+	var video models.Video
 285+	err := row.Scan(
 286+		&video.ID,
 287+		&video.Title,
 288+		&video.PublishedTime,
 289+		&video.IsShort,
 290+		&video.DurationSeconds,
 291+		&video.ProgressSeconds,
 292+		&video.WatchTime,
 293+		&video.DownloadedAt,
 294+		&video.FilePath,
 295+		&video.DownloadStatus,
 296+		&video.DownloadError,
 297+		&video.ChannelName,
 298+		&video.ChannelID,
 299+	)
 300+	if err != nil {
 301+		db.l.Error("Failed to query video", "call", "sql.QueryRow", "error", err)
 302+		return nil, err
 303+	}
 304+
 305+	return &video, nil
 306+}
 307+
 308+func (db *postgresDB) SetVideoDownloadStatus(ctx context.Context, videoID string, status string) error {
 309+	query := `UPDATE videos SET download_status = $1 WHERE id = $2`
 310+	_, err := db.db.Exec(ctx, query, status, videoID)
 311+	if err != nil {
 312+		db.l.Error("Failed to set video download status", "error", err)
 313diff --git a/handler/cleanup.go b/handler/cleanup.go
 314new file mode 100644
 315index 0000000000000000000000000000000000000000..4c1fdf2ed628766c78ff395c2a8cc7dbdd60b750
 316--- /dev/null
 317+++ b/handler/cleanup.go
 318@@ -0,0 +1,58 @@
 319+package handler
 320+
 321+import (
 322+	"context"
 323+	"os"
 324+	"time"
 325+)
 326+
 327+const (
 328+	CleanupInterval = 1 * time.Hour
 329+	CleanupAge      = 48 * time.Hour
 330+)
 331+
 332+func (h *handler) CleanupRoutine(ctx context.Context) {
 333+	ticker := time.NewTicker(CleanupInterval)
 334+	defer ticker.Stop()
 335+
 336+	h.log.Info("Starting cleanup goroutine", "interval", CleanupInterval, "age", CleanupAge)
 337+
 338+	for {
 339+		select {
 340+		case <-ctx.Done():
 341+			h.log.Info("Cleanup context done, stopping cleanup goroutine")
 342+			return
 343+		case <-ticker.C:
 344+			h.performCleanup(ctx)
 345+		}
 346+	}
 347+}
 348+
 349+func (h *handler) performCleanup(ctx context.Context) {
 350+	videos, err := h.db.GetVideosForCleanup(ctx, CleanupAge)
 351+	if err != nil {
 352+		h.log.Error("Failed to get videos for cleanup", "error", err)
 353+		return
 354+	}
 355+
 356+	for _, video := range videos {
 357+		if video.FilePath == nil {
 358+			continue
 359+		}
 360+
 361+		h.log.Info("Cleaning up video file", "video_id", video.ID, "path", *video.FilePath)
 362+
 363+		if err := os.Remove(*video.FilePath); err != nil {
 364+			h.log.Error("Failed to delete file", "path", *video.FilePath, "error", err)
 365+			continue
 366+		}
 367+
 368+		if err := h.db.DeleteVideoFile(ctx, video.ID); err != nil {
 369+			h.log.Error("Failed to update DB after cleanup", "video_id", video.ID, "error", err)
 370+		}
 371+	}
 372+
 373+	if len(videos) > 0 {
 374+		h.log.Info("Cleanup completed", "files_deleted", len(videos))
 375+	}
 376+}
 377diff --git a/handler/downloads.go b/handler/downloads.go
 378new file mode 100644
 379index 0000000000000000000000000000000000000000..968b4d9c0c707bc92a9f98b1bc5b8e0fabee3bd3
 380--- /dev/null
 381+++ b/handler/downloads.go
 382@@ -0,0 +1,106 @@
 383+package handler
 384+
 385+import (
 386+	"context"
 387+	"fmt"
 388+	"os"
 389+	"path/filepath"
 390+	"regexp"
 391+	"strings"
 392+)
 393+
 394+func sanitizeFilename(title string) string {
 395+	title = strings.ReplaceAll(title, " ", "_")
 396+
 397+	reg := regexp.MustCompile(`[^a-zA-Z0-9_\-\.]`)
 398+	title = reg.ReplaceAllString(title, "")
 399+
 400+	if len(title) > 200 {
 401+		title = title[:200]
 402+	}
 403+
 404+	return title
 405+}
 406+
 407+func (h *handler) DownloadVideo(ctx context.Context, videoID string) error {
 408+	exists, err := h.db.HasVideo(ctx, videoID)
 409+	if err != nil {
 410+		return fmt.Errorf("failed to check video existence: %w", err)
 411+	}
 412+	if !exists {
 413+		return fmt.Errorf("video not found")
 414+	}
 415+
 416+	if err := h.db.SetVideoDownloadStatus(ctx, videoID, "pending"); err != nil {
 417+		return fmt.Errorf("failed to set download status: %w", err)
 418+	}
 419+
 420+	go h.performDownload(videoID)
 421+
 422+	return nil
 423+}
 424+
 425+func (h *handler) performDownload(videoID string) {
 426+	ctx := context.Background()
 427+
 428+	video, err := h.db.GetVideo(ctx, videoID)
 429+	if err != nil {
 430+		h.log.Error("Failed to get video for download", "video_id", videoID, "error", err)
 431+		if dbErr := h.db.SetVideoDownloadFailed(ctx, videoID, "Failed to get video info"); dbErr != nil {
 432+			h.log.Error("Failed to update download status to failed", "video_id", videoID, "error", dbErr)
 433+		}
 434+		return
 435+	}
 436+
 437+	if err := h.db.SetVideoDownloadStatus(ctx, videoID, "downloading"); err != nil {
 438+		h.log.Error("Failed to update download status to downloading", "video_id", videoID, "error", err)
 439+		return
 440+	}
 441+
 442+	h.log.Info("Starting video download", "video_id", videoID, "title", video.Title)
 443+
 444+	filePath, err := h.downloader.Download(ctx, videoID, video.Title, h.downloadsDir)
 445+	if err != nil {
 446+		h.log.Error("Video download failed", "video_id", videoID, "error", err)
 447+		if dbErr := h.db.SetVideoDownloadFailed(ctx, videoID, err.Error()); dbErr != nil {
 448+			h.log.Error("Failed to update download status to failed", "video_id", videoID, "error", dbErr)
 449+		}
 450+		return
 451+	}
 452+
 453+	if err := h.db.SetVideoDownloadCompleted(ctx, videoID, filePath); err != nil {
 454+		h.log.Error("Failed to mark video as downloaded", "video_id", videoID, "error", err)
 455+		os.Remove(filePath)
 456+		if dbErr := h.db.SetVideoDownloadFailed(ctx, videoID, "Failed to update database"); dbErr != nil {
 457+			h.log.Error("Failed to update download status to failed", "video_id", videoID, "error", dbErr)
 458+		}
 459+		return
 460+	}
 461+
 462+	h.log.Info("Video downloaded successfully", "video_id", videoID, "path", filePath)
 463+}
 464+
 465+func (h *handler) ServeVideoFile(ctx context.Context, videoID string) (filePath string, filename string, err error) {
 466+	video, err := h.db.GetVideo(ctx, videoID)
 467+	if err != nil {
 468+		return "", "", fmt.Errorf("video not found: %w", err)
 469+	}
 470+
 471+	if video.FilePath == nil {
 472+		return "", "", fmt.Errorf("video not downloaded")
 473+	}
 474+
 475+	if _, err := os.Stat(*video.FilePath); os.IsNotExist(err) {
 476+		h.db.DeleteVideoFile(ctx, videoID)
 477+		return "", "", fmt.Errorf("file not found on disk")
 478+	}
 479+
 480+	sanitizedTitle := sanitizeFilename(video.Title)
 481+	if sanitizedTitle == "" {
 482diff --git a/handler/handler.go b/handler/handler.go
 483index 0557c14080f60cb742810b5c4b38494a1dd04405..2eb89efaaf7e4a543149a179cffbbc253490493e 100644
 484--- a/handler/handler.go
 485+++ b/handler/handler.go
 486@@ -7,6 +7,7 @@ import (
 487 	"github.com/TheEdgeOfRage/ytrssil-api/db"
 488 	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 489 	"github.com/TheEdgeOfRage/ytrssil-api/lib/clients/youtube"
 490+	"github.com/TheEdgeOfRage/ytrssil-api/lib/downloader"
 491 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 492 )
 493 
 494@@ -21,6 +22,9 @@ type Handler interface {
 495 	MarkVideoAsUnwatched(ctx context.Context, videoID string) error
 496 	SetVideoProgress(ctx context.Context, videoID string, progressTime string) (*models.Video, error)
 497 	AddCustomVideo(ctx context.Context, videoID string) error
 498+	DownloadVideo(ctx context.Context, videoID string) error
 499+	ServeVideoFile(ctx context.Context, videoID string) (filePath string, filename string, err error)
 500+	CleanupRoutine(ctx context.Context)
 501 }
 502 
 503 type handler struct {
 504@@ -28,6 +32,8 @@ type handler struct {
 505 	db            db.DB
 506 	parser        feedparser.Parser
 507 	youTubeClient youtube.Client
 508+	downloader    downloader.Downloader
 509+	downloadsDir  string
 510 }
 511 
 512 func New(
 513@@ -35,11 +41,15 @@ func New(
 514 	db db.DB,
 515 	parser feedparser.Parser,
 516 	youTubeClient youtube.Client,
 517+	downloader downloader.Downloader,
 518+	downloadsDir string,
 519 ) *handler {
 520 	return &handler{
 521 		log:           log,
 522 		db:            db,
 523 		parser:        parser,
 524 		youTubeClient: youTubeClient,
 525+		downloader:    downloader,
 526+		downloadsDir:  downloadsDir,
 527 	}
 528 }
 529diff --git a/handler/handler_test.go b/handler/handler_test.go
 530index f45df88affb0d3dc5c3a6c211465bf998e91ae18..3d6221a220ee5616c59b69bc31f4618a0a42feae 100644
 531--- a/handler/handler_test.go
 532+++ b/handler/handler_test.go
 533@@ -40,6 +40,8 @@ func TestGetNewVideos(t *testing.T) {
 534 		},
 535 		&parser_mock.ParserMock{},
 536 		&youtube_mock.ClientMock{},
 537+		nil,
 538+		testConfig.DownloadsDir,
 539 	)
 540 	resp, err := handler.GetNewVideos(context.TODO(), false)
 541 
 542diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
 543index ca2feec515848e82681cff627709eb489733190d..1af3595293a223325cba5ee94394cb2b334fb635 100644
 544--- a/httpserver/ytrssil/api_setup_test.go
 545+++ b/httpserver/ytrssil/api_setup_test.go
 546@@ -37,7 +37,7 @@ func (s *APITestSuite) SetupSuite() {
 547 	l := slog.New(slog.NewTextHandler(io.Discard, nil))
 548 	s.cfg = config.TestConfig()
 549 
 550-	handler := handler.New(l, nil, nil, nil)
 551+	handler := handler.New(l, nil, nil, nil, nil, s.cfg.DownloadsDir)
 552 
 553 	gin.SetMode(gin.TestMode)
 554 	router, err := ytrssil.SetupGinRouter(l, s.cfg, handler)
 555diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
 556index ea87f204a7bb3e0c39bb0ec88730334ce2bb0886..04df4d71926e625cb0c18442597fa65ae113ae9f 100644
 557--- a/httpserver/ytrssil/server.go
 558+++ b/httpserver/ytrssil/server.go
 559@@ -82,6 +82,9 @@ func SetupGinRouter(
 560 		pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
 561 		pages.PATCH("/videos/:video_id/unwatch", srv.MarkVideoAsUnwatchedPage)
 562 		pages.PATCH("/videos/:video_id/progress", srv.SetVideoProgressPage)
 563+		pages.POST("/videos/:video_id/download", srv.DownloadVideoPage)
 564+		pages.GET("/videos/:video_id/card", srv.GetVideoCardPage)
 565+		pages.GET("/videos/:video_id/file", srv.ServeVideoFilePage)
 566 	}
 567 
 568 	api := engine.Group("/api")
 569diff --git a/httpserver/ytrssil/suite_test.go b/httpserver/ytrssil/suite_test.go
 570index 365f19e694d9e69101eda9e30735cc4e3f7fcfc8..6c3976554ccee258978a39d55c4bbfea348df2e6 100644
 571--- a/httpserver/ytrssil/suite_test.go
 572+++ b/httpserver/ytrssil/suite_test.go
 573@@ -108,7 +108,7 @@ func (s *EndpointsTestSuite) SetupSuite() {
 574 		},
 575 	}
 576 
 577-	h := handler.New(l, s.db, s.parser, s.youtubeClient)
 578+	h := handler.New(l, s.db, s.parser, s.youtubeClient, nil, s.cfg.DownloadsDir)
 579 
 580 	gin.SetMode(gin.TestMode)
 581 	router, err := ytrssil.SetupGinRouter(l, s.cfg, h)
 582diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
 583index f4044398c5cec2aef7b232b107724d65b7f05fae..03f3f6a6f7fef321860dfc295a5906fe082114a1 100644
 584--- a/httpserver/ytrssil/videos_pages.go
 585+++ b/httpserver/ytrssil/videos_pages.go
 586@@ -1,11 +1,13 @@
 587 package ytrssil
 588 
 589 import (
 590+	"fmt"
 591 	"net/http"
 592 	"strconv"
 593 
 594 	"github.com/gin-gonic/gin"
 595 
 596+	"github.com/TheEdgeOfRage/ytrssil-api/models"
 597 	"github.com/TheEdgeOfRage/ytrssil-api/pages"
 598 )
 599 
 600@@ -96,3 +98,78 @@ func (srv server) AddVideoPage(c *gin.Context) {
 601 
 602 	returnMsg(c, http.StatusAccepted, "")
 603 }
 604+
 605+func (srv server) DownloadVideoPage(c *gin.Context) {
 606+	videoID := c.Param("video_id")
 607+
 608+	err := srv.handler.DownloadVideo(c.Request.Context(), videoID)
 609+	if err != nil {
 610+		returnErr(c, http.StatusInternalServerError, err)
 611+		return
 612+	}
 613+
 614+	videos, err := srv.handler.GetNewVideos(c.Request.Context(), true)
 615+	if err != nil {
 616+		returnErr(c, http.StatusInternalServerError, err)
 617+		return
 618+	}
 619+
 620+	var targetVideo *models.Video
 621+	for _, v := range videos {
 622+		if v.ID == videoID {
 623+			targetVideo = &v
 624+			break
 625+		}
 626+	}
 627+
 628+	if targetVideo == nil {
 629+		returnErr(c, http.StatusNotFound, fmt.Errorf("video not found"))
 630+		return
 631+	}
 632+
 633+	c.Render(http.StatusOK, pages.TemplRenderer{
 634+		Ctx:       c.Request.Context(),
 635+		Component: pages.VideoCard(*targetVideo),
 636+	})
 637+}
 638+
 639+func (srv server) GetVideoCardPage(c *gin.Context) {
 640+	videoID := c.Param("video_id")
 641+
 642+	videos, err := srv.handler.GetNewVideos(c.Request.Context(), true)
 643+	if err != nil {
 644+		returnErr(c, http.StatusInternalServerError, err)
 645+		return
 646+	}
 647+
 648+	var targetVideo *models.Video
 649+	for _, v := range videos {
 650+		if v.ID == videoID {
 651+			targetVideo = &v
 652+			break
 653+		}
 654+	}
 655+
 656+	if targetVideo == nil {
 657+		returnErr(c, http.StatusNotFound, fmt.Errorf("video not found"))
 658+		return
 659+	}
 660+
 661+	c.Render(http.StatusOK, pages.TemplRenderer{
 662+		Ctx:       c.Request.Context(),
 663+		Component: pages.VideoCard(*targetVideo),
 664+	})
 665+}
 666+
 667+func (srv server) ServeVideoFilePage(c *gin.Context) {
 668+	videoID := c.Param("video_id")
 669+
 670+	filePath, filename, err := srv.handler.ServeVideoFile(c.Request.Context(), videoID)
 671+	if err != nil {
 672+		returnErr(c, http.StatusNotFound, err)
 673+		return
 674+	}
 675+
 676+	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
 677+	c.File(filePath)
 678+}
 679diff --git a/lib/downloader/downloader.go b/lib/downloader/downloader.go
 680new file mode 100644
 681index 0000000000000000000000000000000000000000..ef1f4e8f5e3d182fca8686078d8ab44027449d43
 682--- /dev/null
 683+++ b/lib/downloader/downloader.go
 684@@ -0,0 +1,65 @@
 685+package downloader
 686+
 687+import (
 688+	"context"
 689+	"fmt"
 690+	"log/slog"
 691+	"os/exec"
 692+	"path/filepath"
 693+)
 694+
 695+const ytDlpFormat = "bestvideo[height<=1080][vcodec^=vp9]+bestaudio/bestvideo[height<=1080][vcodec^=av01]+bestaudio/bestvideo[height<=1080]+bestaudio/best[height<=1080]/best" // nolint:lll
 696+
 697+type Downloader interface {
 698+	Download(ctx context.Context, videoID string, title string, outputDir string) (string, error)
 699+	ValidateInstallation() error
 700+}
 701+
 702+type ytdlpDownloader struct {
 703+	log *slog.Logger
 704+}
 705+
 706+func NewYtdlpDownloader(log *slog.Logger) *ytdlpDownloader {
 707+	return &ytdlpDownloader{log: log}
 708+}
 709+
 710+func (d *ytdlpDownloader) ValidateInstallation() error {
 711+	cmd := exec.Command("yt-dlp", "--version")
 712+	if err := cmd.Run(); err != nil {
 713+		return fmt.Errorf("yt-dlp not found or not executable: %w", err)
 714+	}
 715+	return nil
 716+}
 717+
 718+func (d *ytdlpDownloader) Download(
 719+	ctx context.Context,
 720+	videoID string,
 721+	title string,
 722+	outputDir string,
 723+) (string, error) {
 724+	outputTemplate := filepath.Join(outputDir, fmt.Sprintf("%s.%%(ext)s", videoID))
 725+
 726+	cmd := exec.CommandContext(ctx,
 727+		"yt-dlp",
 728+		"--format", ytDlpFormat,
 729+		"--output", outputTemplate,
 730+		"--no-playlist",
 731+		"--no-warnings",
 732+		"--quiet",
 733+		"--progress",
 734+		fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID),
 735+	)
 736+
 737+	output, err := cmd.CombinedOutput()
 738+	if err != nil {
 739+		d.log.Error("yt-dlp failed", "output", string(output), "error", err)
 740+		return "", fmt.Errorf("download failed: %w", err)
 741+	}
 742+
 743+	matches, err := filepath.Glob(filepath.Join(outputDir, fmt.Sprintf("%s.*", videoID)))
 744+	if err != nil || len(matches) == 0 {
 745+		return "", fmt.Errorf("downloaded file not found")
 746+	}
 747+
 748+	return matches[0], nil
 749+}
 750diff --git a/migrations/000007_add_download_fields.down.sql b/migrations/000007_add_download_fields.down.sql
 751new file mode 100644
 752index 0000000000000000000000000000000000000000..06df96eb6aa8e80555efddd522400fa323529b25
 753--- /dev/null
 754+++ b/migrations/000007_add_download_fields.down.sql
 755@@ -0,0 +1,4 @@
 756+ALTER TABLE videos DROP COLUMN IF EXISTS download_error;
 757+ALTER TABLE videos DROP COLUMN IF EXISTS download_status;
 758+ALTER TABLE videos DROP COLUMN IF EXISTS file_path;
 759+ALTER TABLE videos DROP COLUMN IF EXISTS downloaded_at;
 760diff --git a/migrations/000007_add_download_fields.up.sql b/migrations/000007_add_download_fields.up.sql
 761new file mode 100644
 762index 0000000000000000000000000000000000000000..30bbde7e1adcc5ed1bf2562bdc265aa9c48abd0c
 763--- /dev/null
 764+++ b/migrations/000007_add_download_fields.up.sql
 765@@ -0,0 +1,4 @@
 766+ALTER TABLE videos ADD COLUMN IF NOT EXISTS downloaded_at TIMESTAMP;
 767+ALTER TABLE videos ADD COLUMN IF NOT EXISTS file_path TEXT;
 768+ALTER TABLE videos ADD COLUMN IF NOT EXISTS download_status TEXT;
 769+ALTER TABLE videos ADD COLUMN IF NOT EXISTS download_error TEXT;
 770diff --git a/mocks/db/db.go b/mocks/db/db.go
 771index cda629a259f99d03d23571b27e9c22753c020ea1..2faf144416978753ff49ceea74394d9a6d9d3c2a 100644
 772--- a/mocks/db/db.go
 773+++ b/mocks/db/db.go
 774@@ -27,9 +27,18 @@ var _ db.DB = &DBMock{}
 775 //			CloseFunc: func()  {
 776 //				panic("mock out the Close method")
 777 //			},
 778+//			DeleteVideoFileFunc: func(ctx context.Context, videoID string) error {
 779+//				panic("mock out the DeleteVideoFile method")
 780+//			},
 781 //			GetNewVideosFunc: func(ctx context.Context, sortDesc bool) ([]models.Video, error) {
 782 //				panic("mock out the GetNewVideos method")
 783 //			},
 784+//			GetVideoFunc: func(ctx context.Context, videoID string) (*models.Video, error) {
 785+//				panic("mock out the GetVideo method")
 786+//			},
 787+//			GetVideosForCleanupFunc: func(ctx context.Context, olderThan time.Duration) ([]models.Video, error) {
 788+//				panic("mock out the GetVideosForCleanup method")
 789+//			},
 790 //			GetWatchedVideosFunc: func(ctx context.Context, sortDesc bool, limit int, offset int) ([]models.Video, error) {
 791 //				panic("mock out the GetWatchedVideos method")
 792 //			},
 793@@ -39,6 +48,15 @@ var _ db.DB = &DBMock{}
 794 //			ListChannelsFunc: func(ctx context.Context) ([]models.Channel, error) {
 795 //				panic("mock out the ListChannels method")
 796 //			},
 797+//			SetVideoDownloadCompletedFunc: func(ctx context.Context, videoID string, filePath string) error {
 798+//				panic("mock out the SetVideoDownloadCompleted method")
 799+//			},
 800+//			SetVideoDownloadFailedFunc: func(ctx context.Context, videoID string, errorMsg string) error {
 801+//				panic("mock out the SetVideoDownloadFailed method")
 802+//			},
 803+//			SetVideoDownloadStatusFunc: func(ctx context.Context, videoID string, status string) error {
 804+//				panic("mock out the SetVideoDownloadStatus method")
 805+//			},
 806 //			SetVideoProgressFunc: func(ctx context.Context, videoID string, progress int) (*models.Video, error) {
 807 //				panic("mock out the SetVideoProgress method")
 808 //			},
 809@@ -64,9 +82,18 @@ type DBMock struct {
 810 	// CloseFunc mocks the Close method.
 811 	CloseFunc func()
 812 
 813+	// DeleteVideoFileFunc mocks the DeleteVideoFile method.
 814+	DeleteVideoFileFunc func(ctx context.Context, videoID string) error
 815+
 816 	// GetNewVideosFunc mocks the GetNewVideos method.
 817 	GetNewVideosFunc func(ctx context.Context, sortDesc bool) ([]models.Video, error)
 818 
 819+	// GetVideoFunc mocks the GetVideo method.
 820+	GetVideoFunc func(ctx context.Context, videoID string) (*models.Video, error)
 821+
 822+	// GetVideosForCleanupFunc mocks the GetVideosForCleanup method.
 823+	GetVideosForCleanupFunc func(ctx context.Context, olderThan time.Duration) ([]models.Video, error)
 824+
 825 	// GetWatchedVideosFunc mocks the GetWatchedVideos method.
 826 	GetWatchedVideosFunc func(ctx context.Context, sortDesc bool, limit int, offset int) ([]models.Video, error)
 827 
 828@@ -76,6 +103,15 @@ type DBMock struct {
 829 	// ListChannelsFunc mocks the ListChannels method.
 830 	ListChannelsFunc func(ctx context.Context) ([]models.Channel, error)
 831 
 832+	// SetVideoDownloadCompletedFunc mocks the SetVideoDownloadCompleted method.
 833+	SetVideoDownloadCompletedFunc func(ctx context.Context, videoID string, filePath string) error
 834+
 835+	// SetVideoDownloadFailedFunc mocks the SetVideoDownloadFailed method.
 836+	SetVideoDownloadFailedFunc func(ctx context.Context, videoID string, errorMsg string) error
 837+
 838+	// SetVideoDownloadStatusFunc mocks the SetVideoDownloadStatus method.
 839+	SetVideoDownloadStatusFunc func(ctx context.Context, videoID string, status string) error
 840+
 841 	// SetVideoProgressFunc mocks the SetVideoProgress method.
 842 	SetVideoProgressFunc func(ctx context.Context, videoID string, progress int) (*models.Video, error)
 843 
 844@@ -102,6 +138,13 @@ type DBMock struct {
 845 		// Close holds details about calls to the Close method.
 846 		Close []struct {
 847 		}
 848+		// DeleteVideoFile holds details about calls to the DeleteVideoFile method.
 849+		DeleteVideoFile []struct {
 850+			// Ctx is the ctx argument value.
 851+			Ctx context.Context
 852+			// VideoID is the videoID argument value.
 853+			VideoID string
 854+		}
 855 		// GetNewVideos holds details about calls to the GetNewVideos method.
 856 		GetNewVideos []struct {
 857 			// Ctx is the ctx argument value.
 858@@ -109,6 +152,20 @@ type DBMock struct {
 859 			// SortDesc is the sortDesc argument value.
 860 			SortDesc bool
 861 		}
 862+		// GetVideo holds details about calls to the GetVideo method.
 863+		GetVideo []struct {
 864+			// Ctx is the ctx argument value.
 865+			Ctx context.Context
 866+			// VideoID is the videoID argument value.
 867+			VideoID string
 868+		}
 869+		// GetVideosForCleanup holds details about calls to the GetVideosForCleanup method.
 870+		GetVideosForCleanup []struct {
 871+			// Ctx is the ctx argument value.
 872+			Ctx context.Context
 873+			// OlderThan is the olderThan argument value.
 874diff --git a/models/video.go b/models/video.go
 875index 61c559c180230305cced0a23d0afe97ae9925de6..82b0d6312344d4a775d97c45e9af9304aedbde06 100644
 876--- a/models/video.go
 877+++ b/models/video.go
 878@@ -26,6 +26,14 @@ type Video struct {
 879 	ProgressSeconds int `json:"progress"`
 880 	// IsShort indicates if a video is a YouTube short
 881 	IsShort bool `json:"short"`
 882+	// DownloadedAt is the timestamp when the video was downloaded to the server
 883+	DownloadedAt *time.Time `json:"downloaded_at"`
 884+	// FilePath is the path to the downloaded video file on the server
 885+	FilePath *string `json:"-"`
 886+	// DownloadStatus tracks the download state: "", "pending", "downloading", "completed", "failed"
 887+	DownloadStatus *string `json:"download_status"`
 888+	// DownloadError stores the error message if download failed
 889+	DownloadError *string `json:"download_error"`
 890 }
 891 
 892 // ProgressPercentage returns the current progress of the video as an integer from 0-100
 893@@ -58,6 +66,18 @@ func (v Video) HumanizedPublishTime() string {
 894 	return humanize.Time(v.PublishedTime)
 895 }
 896 
 897+func (v Video) IsDownloaded() bool {
 898+	return v.DownloadStatus != nil && *v.DownloadStatus == "completed"
 899+}
 900+
 901+func (v Video) IsDownloading() bool {
 902+	return v.DownloadStatus != nil && (*v.DownloadStatus == "pending" || *v.DownloadStatus == "downloading")
 903+}
 904+
 905+func (v Video) DownloadFailed() bool {
 906+	return v.DownloadStatus != nil && *v.DownloadStatus == "failed"
 907+}
 908+
 909 type PaginatedVideos struct {
 910 	Videos     []Video
 911 	NextOffset int
 912diff --git a/pages/new_videos.templ b/pages/new_videos.templ
 913index ef5d11a6301500c829a8d81595ef08c2c53ea511..3b29b1d7500f5dd533edbe1ee472f3778232183a 100644
 914--- a/pages/new_videos.templ
 915+++ b/pages/new_videos.templ
 916@@ -61,39 +61,82 @@ templ thumbnail(video models.Video) {
 917 	@ProgressBar(video)
 918 }
 919 
 920-templ videoCard(video models.Video) {
 921-	<div class="card">
 922-		@thumbnail(video)
 923-		<div class="card-body">
 924-			<p class="card-title text-truncate mb-0">{ video.Title }</p>
 925-			<p class="d-flex justify-content-between">
 926-				<a
 927-					target="blank"
 928-					href={ fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID) }
 929-					class="card-text fw-light link-light link-underline-opacity-0"
 930-				>{ video.ChannelName }</a>
 931-				<span>
 932-					{ video.HumanizedPublishTime() }
 933-				</span>
 934-			</p>
 935-			<div class="d-flex justify-content-between">
 936-				<form
 937-					hx-patch={ fmt.Sprintf("/videos/%s/progress", video.ID) }
 938-					hx-target={ fmt.Sprintf("#progress-%s", video.ID) }
 939-					hx-swap="outerHTML"
 940-					class="d-flex"
 941-				>
 942-					<input type="text" name="progress" placeholder="mm:ss/11m22s" class="form-control" style="width: 50%"/>
 943-					<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i></button>
 944-				</form>
 945-				<button
 946-					hx-patch={ fmt.Sprintf("/videos/%s/watch", video.ID) }
 947-					hx-target="closest .video-card"
 948-					hx-swap="outerHTML"
 949-					class="btn btn-danger"
 950-				>
 951-					<i class="bi bi-check-circle"></i>
 952-				</button>
 953+templ VideoCard(video models.Video) {
 954+	<div class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
 955+		<div class="card">
 956+			@thumbnail(video)
 957+			<div class="card-body">
 958+				<p class="card-title text-truncate mb-0">{ video.Title }</p>
 959+				<p class="d-flex justify-content-between">
 960+					<a
 961+						target="blank"
 962+						href={ fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID) }
 963+						class="card-text fw-light link-light link-underline-opacity-0"
 964+					>{ video.ChannelName }</a>
 965+					<span>
 966+						{ video.HumanizedPublishTime() }
 967+					</span>
 968+				</p>
 969+				<div class="d-flex justify-content-between">
 970+					<form
 971+						hx-patch={ fmt.Sprintf("/videos/%s/progress", video.ID) }
 972+						hx-target={ fmt.Sprintf("#progress-%s", video.ID) }
 973+						hx-swap="outerHTML"
 974+						class="d-flex"
 975+					>
 976+						<input type="text" name="progress" placeholder="mm:ss/11m22s" class="form-control" style="width: 50%"/>
 977+						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-clock"></i></button>
 978+					</form>
 979+					<div class="d-flex gap-2">
 980+						if video.IsDownloaded() {
 981+							<a
 982+								href={ templ.SafeURL(fmt.Sprintf("/videos/%s/file", video.ID)) }
 983+								class="btn btn-success"
 984+								download
 985+							>
 986+								<i class="bi bi-download"></i>
 987+							</a>
 988+						} else if video.IsDownloading() {
 989+							<button
 990+								class="btn btn-primary"
 991+								disabled
 992+								hx-get={ fmt.Sprintf("/videos/%s/card", video.ID) }
 993+								hx-trigger="every 2s"
 994+								hx-target="closest .video-card"
 995+								hx-swap="outerHTML"
 996+							>
 997+								<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
 998+							</button>
 999+						} else if video.DownloadFailed() {
1000+							<button
1001+								class="btn btn-danger"
1002+								title={ fmt.Sprintf("Download failed: %s", *video.DownloadError) }
1003+								hx-post={ fmt.Sprintf("/videos/%s/download", video.ID) }
1004+								hx-target="closest .video-card"
1005+								hx-swap="outerHTML"
1006+							>
1007+								<i class="bi bi-exclamation-triangle"></i>
1008+							</button>
1009+						} else {
1010+							<button
1011+								hx-post={ fmt.Sprintf("/videos/%s/download", video.ID) }
1012+								hx-target="closest .video-card"
1013+								hx-swap="outerHTML"
1014+								class="btn btn-outline-primary"
1015+							>
1016diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
1017index 6e3c8ad3291fdc0b89a5d53a74f17110ae50751d..de5e3c7a84e9098f88178eb8687f54e594325450 100644
1018--- a/pages/new_videos_templ.go
1019+++ b/pages/new_videos_templ.go
1020@@ -225,7 +225,7 @@ func thumbnail(video models.Video) templ.Component {
1021 	})
1022 }
1023 
1024-func videoCard(video models.Video) templ.Component {
1025+func VideoCard(video models.Video) templ.Component {
1026 	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1027 		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1028 		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1029@@ -246,106 +246,222 @@ func videoCard(video models.Video) templ.Component {
1030 			templ_7745c5c3_Var12 = templ.NopComponent
1031 		}
1032 		ctx = templ.ClearChildren(ctx)
1033-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card\">")
1034-		if templ_7745c5c3_Err != nil {
1035-			return templ_7745c5c3_Err
1036-		}
1037-		templ_7745c5c3_Err = thumbnail(video).Render(ctx, templ_7745c5c3_Buffer)
1038-		if templ_7745c5c3_Err != nil {
1039-			return templ_7745c5c3_Err
1040-		}
1041-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"card-body\"><p class=\"card-title text-truncate mb-0\">")
1042+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"video-card col-md-3 p-2\" data-title=\"")
1043 		if templ_7745c5c3_Err != nil {
1044 			return templ_7745c5c3_Err
1045 		}
1046 		var templ_7745c5c3_Var13 string
1047 		templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
1048 		if templ_7745c5c3_Err != nil {
1049-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 68, Col: 57}
1050+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 62}
1051 		}
1052 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
1053 		if templ_7745c5c3_Err != nil {
1054 			return templ_7745c5c3_Err
1055 		}
1056-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</p><p class=\"d-flex justify-content-between\"><a target=\"blank\" href=\"")
1057+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-channel-name=\"")
1058 		if templ_7745c5c3_Err != nil {
1059 			return templ_7745c5c3_Err
1060 		}
1061-		var templ_7745c5c3_Var14 templ.SafeURL
1062-		templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID))
1063+		var templ_7745c5c3_Var14 string
1064+		templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
1065 		if templ_7745c5c3_Err != nil {
1066-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 72, Col: 78}
1067+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 65, Col: 102}
1068 		}
1069 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
1070 		if templ_7745c5c3_Err != nil {
1071 			return templ_7745c5c3_Err
1072 		}
1073-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"card-text fw-light link-light link-underline-opacity-0\">")
1074+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><div class=\"card\">")
1075+		if templ_7745c5c3_Err != nil {
1076+			return templ_7745c5c3_Err
1077+		}
1078+		templ_7745c5c3_Err = thumbnail(video).Render(ctx, templ_7745c5c3_Buffer)
1079+		if templ_7745c5c3_Err != nil {
1080+			return templ_7745c5c3_Err
1081+		}
1082+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card-body\"><p class=\"card-title text-truncate mb-0\">")
1083 		if templ_7745c5c3_Err != nil {
1084 			return templ_7745c5c3_Err
1085 		}
1086 		var templ_7745c5c3_Var15 string
1087-		templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(video.ChannelName)
1088+		templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(video.Title)
1089 		if templ_7745c5c3_Err != nil {
1090-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 74, Col: 24}
1091+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 69, Col: 58}
1092 		}
1093 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
1094 		if templ_7745c5c3_Err != nil {
1095 			return templ_7745c5c3_Err
1096 		}
1097-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</a> <span>")
1098+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</p><p class=\"d-flex justify-content-between\"><a target=\"blank\" href=\"")
1099 		if templ_7745c5c3_Err != nil {
1100 			return templ_7745c5c3_Err
1101 		}
1102-		var templ_7745c5c3_Var16 string
1103-		templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(video.HumanizedPublishTime())
1104+		var templ_7745c5c3_Var16 templ.SafeURL
1105+		templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID))
1106 		if templ_7745c5c3_Err != nil {
1107-			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 76, Col: 35}
1108+			return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/new_videos.templ`, Line: 73, Col: 79}
1109 		}
1110 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
1111 		if templ_7745c5c3_Err != nil {
1112 			return templ_7745c5c3_Err
1113 		}
1114-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span></p><div class=\"d-flex justify-content-between\"><form hx-patch=\"")
1115+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"card-text fw-light link-light link-underline-opacity-0\">")
1116 		if templ_7745c5c3_Err != nil {
1117 			return templ_7745c5c3_Err
1118 		}
1119 		var templ_7745c5c3_Var17 string