2a71367c9a5a2f6c172788b645b45988354ee592
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.air.toml b/.air.toml
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