42b3f3d05e91d0c0f8955ca426fc4fa3074d06c0
- Author
- TheEdgeOfClaw <openclaw@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
2index b86e01c2cea1d1e228069994b67edf5ad60f17ca..6a0cb887b563cc1143e802f8fbed1d383fa608ca 100644
3--- a/.github/workflows/ci.yaml
4+++ b/.github/workflows/ci.yaml
5@@ -38,7 +38,7 @@ jobs:
6 - name: golangci-lint
7 uses: golangci/golangci-lint-action@v9
8 with:
9- version: v2.9.0
10+ version: v2.11.3
11
12 - name: Tests
13 env:
14diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
15new file mode 100644
16index 0000000000000000000000000000000000000000..8b07144feb6cf26ff9cc105842d1c81feb72121f
17--- /dev/null
18+++ b/.github/workflows/pr.yaml
19@@ -0,0 +1,48 @@
20+---
21+name: "PR CI"
22+
23+on:
24+ pull_request:
25+ branches:
26+ - main
27+
28+jobs:
29+ ci:
30+ name: "Run CI checks"
31+ runs-on: ubuntu-latest
32+ timeout-minutes: 15
33+ services:
34+ postgres:
35+ image: postgres:18
36+ env:
37+ POSTGRES_PASSWORD: postgres
38+ options: >-
39+ --health-cmd "pg_isready -U postgres"
40+ --health-interval 5s
41+ --health-timeout 2s
42+ --health-retries 5
43+ ports:
44+ - 5432:5432
45+
46+ steps:
47+ - uses: actions/checkout@v6
48+
49+ - uses: actions/setup-go@v6
50+ with:
51+ go-version-file: "go.mod"
52+ cache: true
53+
54+ - name: go vet
55+ run: go vet ./...
56+
57+ - name: golangci-lint
58+ uses: golangci/golangci-lint-action@v9
59+ with:
60+ version: v2.11.3
61+
62+ - name: Tests
63+ env:
64+ DB_URI: postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
65+ run: |-
66+ make migrate
67+ make test
68diff --git a/AGENTS.md b/AGENTS.md
69index c6652543dc2ad94207a138b72f7c44e8ca5592fd..87b6476e703f0662516db2f2b125fbb2aed09d2c 100644
70--- a/AGENTS.md
71+++ b/AGENTS.md
72@@ -20,7 +20,19 @@
73
74 **Background Jobs**: Video fetcher (5min intervals), cleanup routine (1hr intervals) - both disabled in dev mode
75
76-**Directory Structure**: `cmd/` (main), `config/`, `db/`, `models/`, `handler/`, `httpserver/`, `pages/` (templ), `lib/clients/`, `lib/downloader/`, `feedparser/`, `migrations/`, `assets/`
77+## Project Structure
78+
79+```
80+ytrssil/
81+├── cmd/ # Application entry point
82+├── handler/ # Business logic
83+├── httpserver/ # HTTP routes (HTML + API)
84+├── pages/ # UI templates
85+├── lib/ # External clients (YouTube, RSS, downloader)
86+├── db/ # Database operations
87+├── migrations/ # Database schema changes
88+└── assets/ # Static files
89+```
90
91 ## Development Workflow
92
93diff --git a/Makefile b/Makefile
94index 8ab24758a78ad7286c70e1bc79eba359db92aae0..a1a4c1025e470d4f5c8c48a76c1d373bce76eb09 100644
95--- a/Makefile
96+++ b/Makefile
97@@ -7,11 +7,13 @@ bin:
98 bin/moq: bin
99 GOBIN=$(PWD)/bin go install github.com/matryer/moq@v0.6.0
100 bin/golangci-lint: bin
101- GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.1
102+ GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.3
103 bin/migrate: bin
104 GOBIN=$(PWD)/bin go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
105 bin/air: bin
106 GOBIN=$(PWD)/bin go install github.com/air-verse/air@v1.64.5
107+bin/templ: bin
108+ GOBIN=$(PWD)/bin go install github.com/a-h/templ/cmd/templ@v0.3.1001
109
110 lint: bin/golangci-lint
111 go mod tidy
112@@ -25,8 +27,8 @@ test:
113 air: bin/air
114 @./bin/air -c .air.toml
115
116-templ:
117- templ generate
118+templ: bin/templ
119+ bin/templ generate
120
121 build: templ
122 go build -o dist/ytrssil ./cmd/main.go
123diff --git a/README.md b/README.md
124index c3f2b88029d0ec65adfc268cb7e38457d3944d42..543ea166e2197fd15441e08a37af66eceafc873f 100644
125--- a/README.md
126+++ b/README.md
127@@ -1,10 +1,80 @@
128 <p align="center">
129 <a href="https://github.com/TheEdgeOfRage/ytrssil">
130 <picture>
131- <img src="assets/ytrssil.svg" alt="ytrssil logo">
132+ <img src="assets/ytrssil.svg" alt="ytrssil logo" width="100">
133 </picture>
134 </a>
135 </p>
136+
137+<h1 align="center">ytrssil</h1>
138 <p align="center">YouTube subscription feed, but better</p>
139
140 ---
141+
142+## What is ytrssil?
143+
144+ytrssil is a self-hosted YouTube subscription manager that gives you a clean feed of your subscribed channels and nothing else. It tracks watched videos, handles downloads, and gives you control over the videos you get served.
145+
146+## Quick Start
147+
148+### Docker (Recommended)
149+
150+```bash
151+git clone https://github.com/TheEdgeOfRage/ytrssil
152+cd ytrssil
153+docker-compose up -d
154+```
155+
156+Visit `http://localhost:8080` in your browser.
157+
158+## Configuration
159+
160+ytrssil is configured via environment variables. Edit the env vars in compose.yaml
161+
162+```bash
163+# PostgreSQL connection (optional, defaults to Docker Compose service)
164+POSTGRES_URL=postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable
165+
166+# Where downloaded videos are saved
167+DOWNLOADS_DIR=/var/lib/ytrssil/downloads
168+
169+# How often to check for new videos (default: 5m)
170+FETCH_INTERVAL=5m
171+
172+# How often to cleanup old downloads (default: 1h)
173+CLEANUP_INTERVAL=1h
174+```
175+
176+## Usage
177+
178+### Adding Channels
179+
180+1. Click the **"Add Channel"** button
181+2. Paste a YouTube channel URL or search by name
182+3. The channel appears in your subscription list
183+
184+### Managing Videos
185+
186+- **Watch status**: Click a video to mark it as watched
187+- **Downloads**: Click the download button to save videos locally
188+- **Shorts filter**: Toggle the shorts switch on each channel to filter out YouTube Shorts
189+- **Progress**: The dashboard shows unwatched counts and recent activity
190+
191+### Download Settings
192+
193+Downloaded videos are automatically cleaned up 2 days (configurable) after marking it as watched.
194+
195+## Features
196+
197+- **Channel subscriptions** - Add channels via channel name or ID
198+- **Watch History** - Keep a list of what you've watched
199+- **Progress Tracking** - Keep track of your watch progress in videos
200+- **Video Downloads** - Save videos locally with automatic cleanup
201+- **Shorts Filter** - Per-channel control over YouTube Shorts
202+- **Clean Interface** - No ads, no recommendations, just your feed
203+- **Auto Updates** - Checks for new videos every 5 minutes
204+- **Docker Ready** - One command to get everything running
205+
206+## Support
207+
208+For development setup, code architecture, or contributing, see [`AGENTS.md`](AGENTS.md).
209diff --git a/cmd/main.go b/cmd/main.go
210index a0325ba9f3772396643d6753f985165d74749127..ed201362e6d11d0408775531d83d9306025da6a3 100644
211--- a/cmd/main.go
212+++ b/cmd/main.go
213@@ -27,8 +27,8 @@ func init() {
214 time.Local = time.UTC
215 }
216
217-func fetcherRoutine(ctx context.Context, l *slog.Logger, h handler.Handler) {
218- ticker := time.NewTicker(5 * time.Minute)
219+func fetcherRoutine(ctx context.Context, l *slog.Logger, h handler.Handler, fetchInterval time.Duration) {
220+ ticker := time.NewTicker(fetchInterval)
221 for {
222 select {
223 case <-ctx.Done():
224@@ -51,6 +51,7 @@ func main() {
225 logger.Error("Failed to parse config", "call", "config.Parse", "error", err)
226 return
227 }
228+
229 db, err := db.NewPostgresDB(logger, cfg.DBURI)
230 if err != nil {
231 logger.Error(
232@@ -68,7 +69,14 @@ func main() {
233 logger.Error("yt-dlp validation failed", "error", err)
234 return
235 }
236- handler := handler.New(logger, db, parser, youTubeClient, downloader, cfg.DownloadsDir)
237+ handler := handler.New(
238+ logger,
239+ db,
240+ parser,
241+ youTubeClient,
242+ downloader,
243+ cfg,
244+ )
245 if cfg.Dev {
246 gin.SetMode(gin.DebugMode)
247 } else {
248@@ -97,7 +105,7 @@ func main() {
249 fetcherContext, cancelFetcher := context.WithCancel(context.Background())
250 if !cfg.Dev {
251 wg.Go(func() {
252- fetcherRoutine(fetcherContext, logger, handler)
253+ fetcherRoutine(fetcherContext, logger, handler, cfg.FetchInterval)
254 })
255 }
256
257diff --git a/config/config.go b/config/config.go
258index b445a586d04b1245a98d6511667b53af4a1cf211..2f786f354bb36c960aa3da15611e418db8e022fa 100644
259--- a/config/config.go
260+++ b/config/config.go
261@@ -4,17 +4,21 @@ import (
262 "fmt"
263 "os"
264 "strings"
265+ "time"
266
267 flags "github.com/jessevdk/go-flags"
268 )
269
270 type Config struct {
271- Dev bool `long:"dev" env:"DEV"`
272- Port int `long:"port" env:"PORT" default:"8080"`
273- DBURI string `long:"db-uri" env:"DB_URI"`
274- AuthToken string `long:"auth-token" env:"AUTH_TOKEN"`
275- YouTubeAPIKey string `long:"youtube-api-key" env:"YOUTUBE_API_KEY"`
276- DownloadsDir string `long:"downloads-dir" env:"DOWNLOADS_DIR" default:"/var/lib/ytrssil/downloads"`
277+ Dev bool `long:"dev" env:"DEV"`
278+ Port int `long:"port" env:"PORT" default:"8080"`
279+ DBURI string `long:"db-uri" env:"DB_URI"`
280+ AuthToken string `long:"auth-token" env:"AUTH_TOKEN"`
281+ YouTubeAPIKey string `long:"youtube-api-key" env:"YOUTUBE_API_KEY"`
282+ DownloadsDir string `long:"downloads-dir" env:"DOWNLOADS_DIR" default:"/var/lib/ytrssil/downloads"`
283+ FetchInterval time.Duration `long:"fetch-interval" env:"FETCH_INTERVAL" default:"5m"`
284+ CleanupInterval time.Duration `long:"cleanup-interval" env:"CLEANUP_INTERVAL" default:"1h"`
285+ CleanupAge time.Duration `long:"cleanup-age" env:"CLEANUP_AGE" default:"48h"`
286 }
287
288 func getenvOrDefault(key string, defaultValue string) string {
289@@ -60,10 +64,13 @@ func TestConfig() Config {
290 }
291
292 config := Config{
293- Port: 8080,
294- DBURI: dbURI,
295- AuthToken: "foo",
296- DownloadsDir: "/tmp/ytrssil-test-downloads",
297+ Port: 8080,
298+ DBURI: dbURI,
299+ AuthToken: "foo",
300+ DownloadsDir: "/tmp/ytrssil-test-downloads",
301+ FetchInterval: 5 * time.Minute,
302+ CleanupInterval: 1 * time.Hour,
303+ CleanupAge: 48 * time.Hour,
304 }
305
306 return config
307diff --git a/db/channels.go b/db/channels.go
308index 7e85ee8769be61bf3bb9506a22cf307d3512ada5..73d96a474c3d98fafb789e9c26ecb6c7e3bb2ce7 100644
309--- a/db/channels.go
310+++ b/db/channels.go
311@@ -9,10 +9,11 @@ import (
312
313 func (db *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
314 const query = `
315- INSERT INTO channels (id, name, subscribed, image_url) VALUES ($1, $2, $3, $4)
316- ON CONFLICT (id) DO UPDATE SET subscribed = $3, image_url = $4
317+ INSERT INTO channels (id, name, subscribed, image_url, enable_shorts) VALUES ($1, $2, $3, $4, $5)
318+ ON CONFLICT (id) DO UPDATE SET subscribed = $3, image_url = $4, enable_shorts = $5
319 `
320- resp, err := db.db.Exec(ctx, query, channel.ID, channel.Name, channel.Subscribed, channel.ImageURL)
321+ resp, err := db.db.Exec(ctx, query, channel.ID, channel.Name, channel.Subscribed,
322+ channel.ImageURL, channel.EnableShorts)
323 if err != nil {
324 db.l.Error("Failed to subscribe to channel", "call", "sql.ExecContext", "error", err)
325 return err
326@@ -33,11 +34,12 @@ func (db *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error
327 channels.name,
328 channels.subscribed,
329 COALESCE(channels.image_url, '') as image_url,
330- COUNT(videos.id) FILTER (WHERE videos.watch_timestamp IS NULL) as unwatched_count
331+ COALESCE(channels.enable_shorts, true) as enable_shorts,
332+ COUNT(videos.id) FILTER (WHERE videos.watch_timestamp IS NULL AND videos.is_discarded = false) as unwatched_count
333 FROM channels
334 LEFT JOIN videos ON channels.id = videos.channel_id
335 WHERE channels.subscribed = true
336- GROUP BY channels.id, channels.name, channels.subscribed, channels.image_url
337+ GROUP BY channels.id, channels.name, channels.subscribed, channels.image_url, channels.enable_shorts
338 ORDER BY channels.name
339 `
340 rows, err := db.db.Query(ctx, query)
341@@ -50,7 +52,8 @@ func (db *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error
342 channels := make([]models.Channel, 0)
343 for rows.Next() {
344 var channel models.Channel
345- err = rows.Scan(&channel.ID, &channel.Name, &channel.Subscribed, &channel.ImageURL, &channel.UnwatchedCount)
346+ err = rows.Scan(&channel.ID, &channel.Name, &channel.Subscribed, &channel.ImageURL,
347+ &channel.EnableShorts, &channel.UnwatchedCount)
348 if err != nil {
349 db.l.Error("Failed to scan rows to list channels", "call", "sql.Scan", "error", err)
350 return nil, err
351@@ -75,3 +78,40 @@ func (db *postgresDB) UnsubscribeFromChannel(ctx context.Context, channelID stri
352
353 return nil
354 }
355+
356+func (db *postgresDB) ToggleChannelShorts(ctx context.Context, channelID string, enableShorts bool) error {
357+ const query = `UPDATE channels SET enable_shorts = $1 WHERE id = $2`
358+ resp, err := db.db.Exec(ctx, query, enableShorts, channelID)
359+ if err != nil {
360+ db.l.Error("Failed to toggle channel shorts", "call", "sql.ExecContext", "error", err)
361+ return err
362+ }
363+
364+ if resp.RowsAffected() != 1 {
365+ return ErrChannelNotFound
366+ }
367+
368+ return nil
369+}
370+
371+func (db *postgresDB) GetChannelByID(ctx context.Context, channelID string) (*models.Channel, error) {
372+ const query = `
373+ SELECT
374+ id,
375+ name,
376+ subscribed,
377+ COALESCE(image_url, '') as image_url,
378+ COALESCE(enable_shorts, true) as enable_shorts
379+ FROM channels
380+ WHERE id = $1
381+ `
382+ row := db.db.QueryRow(ctx, query, channelID)
383+ var channel models.Channel
384+ err := row.Scan(&channel.ID, &channel.Name, &channel.Subscribed, &channel.ImageURL, &channel.EnableShorts)
385+ if err != nil {
386+ db.l.Error("Failed to query channel by ID", "call", "sql.QueryRowContext", "error", err)
387+ return nil, err
388+ }
389+
390+ return &channel, nil
391+}
392diff --git a/db/db.go b/db/db.go
393index 37d5fc435c24b31fa65cc6546fe0af7b3581dabb..05f27bb5e79cb87596b380ff165254fea23a52cb 100644
394--- a/db/db.go
395+++ b/db/db.go
396@@ -23,6 +23,10 @@ type DB interface {
397 SubscribeToChannel(ctx context.Context, channel models.Channel) error
398 // UnsubscribeToChannel will stop fetching videos from that channel
399 UnsubscribeFromChannel(ctx context.Context, channelID string) error
400+ // ToggleChannelShorts enables or disables shorts for a channel
401+ ToggleChannelShorts(ctx context.Context, channelID string, enableShorts bool) error
402+ // GetChannelByID returns a channel by its ID
403+ GetChannelByID(ctx context.Context, channelID string) (*models.Channel, error)
404
405 // GetNewVideos returns a list of unwatched videos from all subscribed channels
406 GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
407@@ -31,7 +35,9 @@ type DB interface {
408 // HasVideo returns true if the video with the given ID exists in the DB
409 HasVideo(ctx context.Context, videoID string) (bool, error)
410 // AddVideo adds a newly published video to the database
411- AddVideo(ctx context.Context, video models.Video, channelID string) error
412+ AddVideo(ctx context.Context, video models.Video, channelID string, isDiscarded bool) error
413+ // DiscardVideo marks a video as discarded (e.g., shorts filtered out)
414+ DiscardVideo(ctx context.Context, videoID string) error
415 // SetVideoWatchTime sets or unsets the watch timestamp of a video
416 SetVideoWatchTime(ctx context.Context, videoID string, watchTime *time.Time) error
417 // SetVideoProgress sets or unsets the watch progress of a video
418diff --git a/db/videos.go b/db/videos.go
419index 888b9553c0c9a6c71a9a3631f6b4c9894f277c6f..58b7aacab8a9923684332416c64d5ac55ff548c2 100644
420--- a/db/videos.go
421+++ b/db/videos.go
422@@ -16,6 +16,7 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
423 , is_short
424 , duration
425 , progress
426+ , is_discarded
427 , downloaded_at
428 , file_path
429 , download_status
430@@ -25,6 +26,7 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
431 FROM videos
432 LEFT JOIN channels ON videos.channel_id=channels.id
433 WHERE watch_timestamp IS NULL
434+ AND videos.is_discarded = false
435 ORDER BY published_timestamp
436 `
437 if sortDesc {
438@@ -48,6 +50,7 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
439 &video.IsShort,
440 &video.DurationSeconds,
441 &video.ProgressSeconds,
442+ &video.IsDiscarded,
443 &video.DownloadedAt,
444 &video.FilePath,
445 &video.DownloadStatus,
446@@ -77,6 +80,7 @@ func (db *postgresDB) GetWatchedVideos(
447 , is_short
448 , duration
449 , progress
450+ , is_discarded
451 , downloaded_at
452 , file_path
453 , download_status
454@@ -84,8 +88,9 @@ func (db *postgresDB) GetWatchedVideos(
455 , channels.name
456 , channels.id
457 FROM videos
458- LEFT JOIN channels ON videos.channel_id=channels.id
459+ LEFT JOIN channels ON videos.channel_id=videos.channel_id
460 WHERE watch_timestamp IS NOT NULL
461+ AND videos.is_discarded = false
462 ORDER BY watch_timestamp
463 `
464 if sortDesc {
465@@ -111,6 +116,7 @@ func (db *postgresDB) GetWatchedVideos(
466 &video.IsShort,
467 &video.DurationSeconds,
468 &video.ProgressSeconds,
469+ &video.IsDiscarded,
470 &video.DownloadedAt,
471 &video.FilePath,
472 &video.DownloadStatus,
473@@ -142,7 +148,7 @@ func (db *postgresDB) HasVideo(ctx context.Context, videoID string) (bool, error
474 return count == 1, nil
475 }
476
477-func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string) error {
478+func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelID string, isDiscarded bool) error {
479 query := `
480 INSERT INTO videos (
481 id
482@@ -151,7 +157,8 @@ func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelI
483 , duration
484 , is_short
485 , channel_id
486- ) VALUES ($1, $2, $3, $4, $5, $6)
487+ , is_discarded
488+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
489 ON CONFLICT DO NOTHING
490 `
491
492@@ -164,6 +171,7 @@ func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelI
493 video.DurationSeconds,
494 video.IsShort,
495 channelID,
496+ isDiscarded,
497 )
498 if err != nil {
499 db.l.Error("Failed to add video", "call", "sql.Exec", "error", err)
500@@ -176,6 +184,16 @@ func (db *postgresDB) AddVideo(ctx context.Context, video models.Video, channelI
501 return nil
502 }
503
504+func (db *postgresDB) DiscardVideo(ctx context.Context, videoID string) error {
505+ query := `UPDATE videos SET is_discarded = true WHERE id = $1`
506+ _, err := db.db.Exec(ctx, query, videoID)
507+ if err != nil {
508+ db.l.Error("Failed to discard video", "call", "sql.Exec", "error", err)
509+ return err
510+ }
511+ return nil
512+}
513+
514 func (db *postgresDB) SetVideoWatchTime(
515 ctx context.Context,
516 videoID string,
517@@ -239,6 +257,7 @@ func (db *postgresDB) GetVideo(ctx context.Context, videoID string) (*models.Vid
518 , duration
519 , progress
520 , watch_timestamp
521+ , is_discarded
522diff --git a/handler/channels.go b/handler/channels.go
523index 5008671a62edf7b5d35bc4f94a22cd42076d5fb5..18d3b2b9e8992d5c0e8d3b12c2e3f9f384809e65 100644
524--- a/handler/channels.go
525+++ b/handler/channels.go
526@@ -40,10 +40,11 @@ func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) (*mo
527 }
528
529 channel := models.Channel{
530- ID: channelID,
531- Name: parsedChannel.Name,
532- Subscribed: true,
533- ImageURL: imageURL,
534+ ID: channelID,
535+ Name: parsedChannel.Name,
536+ Subscribed: true,
537+ ImageURL: imageURL,
538+ EnableShorts: true,
539 }
540
541 err = h.db.SubscribeToChannel(ctx, channel)
542@@ -61,3 +62,11 @@ func (h *handler) UnsubscribeFromChannel(ctx context.Context, channelID string)
543 func (h *handler) ListChannels(ctx context.Context) ([]models.Channel, error) {
544 return h.db.ListChannels(ctx)
545 }
546+
547+func (h *handler) GetChannelByID(ctx context.Context, channelID string) (*models.Channel, error) {
548+ return h.db.GetChannelByID(ctx, channelID)
549+}
550+
551+func (h *handler) ToggleChannelShorts(ctx context.Context, channelID string, enableShorts bool) error {
552+ return h.db.ToggleChannelShorts(ctx, channelID, enableShorts)
553+}
554diff --git a/handler/cleanup.go b/handler/cleanup.go
555index 4c1fdf2ed628766c78ff395c2a8cc7dbdd60b750..98aaa1f54d83c1a5ca07520c03e412e574858d6e 100644
556--- a/handler/cleanup.go
557+++ b/handler/cleanup.go
558@@ -6,16 +6,11 @@ import (
559 "time"
560 )
561
562-const (
563- CleanupInterval = 1 * time.Hour
564- CleanupAge = 48 * time.Hour
565-)
566-
567 func (h *handler) CleanupRoutine(ctx context.Context) {
568- ticker := time.NewTicker(CleanupInterval)
569+ ticker := time.NewTicker(h.config.CleanupInterval)
570 defer ticker.Stop()
571
572- h.log.Info("Starting cleanup goroutine", "interval", CleanupInterval, "age", CleanupAge)
573+ h.log.Info("Starting cleanup goroutine", "interval", h.config.CleanupInterval, "age", h.config.CleanupAge)
574
575 for {
576 select {
577@@ -29,7 +24,7 @@ func (h *handler) CleanupRoutine(ctx context.Context) {
578 }
579
580 func (h *handler) performCleanup(ctx context.Context) {
581- videos, err := h.db.GetVideosForCleanup(ctx, CleanupAge)
582+ videos, err := h.db.GetVideosForCleanup(ctx, h.config.CleanupAge)
583 if err != nil {
584 h.log.Error("Failed to get videos for cleanup", "error", err)
585 return
586diff --git a/handler/downloads.go b/handler/downloads.go
587index 968b4d9c0c707bc92a9f98b1bc5b8e0fabee3bd3..411740972e9e8e1a328836e991884eba1fe2a419 100644
588--- a/handler/downloads.go
589+++ b/handler/downloads.go
590@@ -59,7 +59,7 @@ func (h *handler) performDownload(videoID string) {
591
592 h.log.Info("Starting video download", "video_id", videoID, "title", video.Title)
593
594- filePath, err := h.downloader.Download(ctx, videoID, video.Title, h.downloadsDir)
595+ filePath, err := h.downloader.Download(ctx, videoID, video.Title, h.config.DownloadsDir)
596 if err != nil {
597 h.log.Error("Video download failed", "video_id", videoID, "error", err)
598 if dbErr := h.db.SetVideoDownloadFailed(ctx, videoID, err.Error()); dbErr != nil {
599diff --git a/handler/handler.go b/handler/handler.go
600index 2eb89efaaf7e4a543149a179cffbbc253490493e..10fcef86e3011578b308a065fe36b23bcdea6248 100644
601--- a/handler/handler.go
602+++ b/handler/handler.go
603@@ -4,6 +4,7 @@ import (
604 "context"
605 "log/slog"
606
607+ "github.com/TheEdgeOfRage/ytrssil-api/config"
608 "github.com/TheEdgeOfRage/ytrssil-api/db"
609 "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
610 "github.com/TheEdgeOfRage/ytrssil-api/lib/clients/youtube"
611@@ -15,6 +16,8 @@ type Handler interface {
612 SubscribeToChannel(ctx context.Context, channelID string) (*models.Channel, error)
613 UnsubscribeFromChannel(ctx context.Context, channelID string) error
614 ListChannels(ctx context.Context) ([]models.Channel, error)
615+ GetChannelByID(ctx context.Context, channelID string) (*models.Channel, error)
616+ ToggleChannelShorts(ctx context.Context, channelID string, enableShorts bool) error
617 GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
618 GetWatchedVideos(ctx context.Context, sortDesc bool, page int) ([]models.Video, error)
619 FetchVideos(ctx context.Context) error
620@@ -33,7 +36,7 @@ type handler struct {
621 parser feedparser.Parser
622 youTubeClient youtube.Client
623 downloader downloader.Downloader
624- downloadsDir string
625+ config config.Config
626 }
627
628 func New(
629@@ -42,7 +45,7 @@ func New(
630 parser feedparser.Parser,
631 youTubeClient youtube.Client,
632 downloader downloader.Downloader,
633- downloadsDir string,
634+ cfg config.Config,
635 ) *handler {
636 return &handler{
637 log: log,
638@@ -50,6 +53,6 @@ func New(
639 parser: parser,
640 youTubeClient: youTubeClient,
641 downloader: downloader,
642- downloadsDir: downloadsDir,
643+ config: cfg,
644 }
645 }
646diff --git a/handler/handler_test.go b/handler/handler_test.go
647index 3d6221a220ee5616c59b69bc31f4618a0a42feae..dd082f8adc82c0e4762be7dedcdfd6af4bd1362e 100644
648--- a/handler/handler_test.go
649+++ b/handler/handler_test.go
650@@ -41,7 +41,7 @@ func TestGetNewVideos(t *testing.T) {
651 &parser_mock.ParserMock{},
652 &youtube_mock.ClientMock{},
653 nil,
654- testConfig.DownloadsDir,
655+ testConfig,
656 )
657 resp, err := handler.GetNewVideos(context.TODO(), false)
658
659diff --git a/handler/videos.go b/handler/videos.go
660index 856e994e3ed40a51bf03fd6ff339a1029e4eaf40..a9db2a25fe44977717a6af2c090a135cf6f351d4 100644
661--- a/handler/videos.go
662+++ b/handler/videos.go
663@@ -30,9 +30,9 @@ func (h *handler) GetWatchedVideos(ctx context.Context, sortDesc bool, page int)
664 return h.db.GetWatchedVideos(ctx, sortDesc, WatchedVideosPageSize, offset)
665 }
666
667-func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedparser.Channel) {
668- var err error
669+func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedparser.Channel, enableShorts bool) {
670 videos := make(map[string]*models.Video, len(parsedChannel.Videos))
671+
672 for _, parsedVideo := range parsedChannel.Videos {
673 date, err := parsedVideo.Published.Parse()
674 if err != nil {
675@@ -46,13 +46,16 @@ func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedpa
676 h.log.Error("Failed to check if video already exists", "call", "db.HasVideo", "err", err)
677 continue
678 }
679- if !exists {
680- videos[videoID] = &models.Video{
681- ID: videoID,
682- Title: parsedVideo.Title,
683- PublishedTime: date,
684- IsShort: parsedVideo.IsShort,
685- }
686+
687+ if exists {
688+ continue
689+ }
690+
691+ videos[videoID] = &models.Video{
692+ ID: videoID,
693+ Title: parsedVideo.Title,
694+ PublishedTime: date,
695+ IsShort: parsedVideo.IsShort,
696 }
697 }
698
699@@ -60,14 +63,17 @@ func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedpa
700 return
701 }
702
703- err = h.youTubeClient.GetVideoDurations(ctx, videos)
704+ // Get durations for all videos
705+ err := h.youTubeClient.GetVideoDurations(ctx, videos)
706 if err != nil {
707 h.log.Error("Failed to get video durations", "call", "handler.getVideoDurations", "err", err)
708 return
709 }
710
711+ // Add videos with appropriate discard flag
712 for _, video := range videos {
713- err = h.db.AddVideo(ctx, *video, parsedChannel.ID)
714+ isDiscarded := video.IsShort && !enableShorts
715+ err = h.db.AddVideo(ctx, *video, parsedChannel.ID, isDiscarded)
716 if err != nil {
717 if !errors.Is(err, db.ErrVideoExists) {
718 h.log.Error("Failed to save video to db", "call", "db.AddVideo", "err", err)
719@@ -78,8 +84,9 @@ func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedpa
720 }
721
722 type parseResult struct {
723- channel *feedparser.Channel
724- err error
725+ channel *feedparser.Channel
726+ err error
727+ enableShorts bool
728 }
729
730 func (h *handler) FetchVideos(ctx context.Context) error {
731@@ -95,7 +102,7 @@ func (h *handler) FetchVideos(ctx context.Context) error {
732 for _, channel := range channels {
733 wg.Go(func() {
734 parsedChannel, err := h.parser.Parse(channel.ID)
735- results <- parseResult{channel: parsedChannel, err: err}
736+ results <- parseResult{channel: parsedChannel, err: err, enableShorts: channel.EnableShorts}
737 })
738 }
739
740@@ -109,7 +116,8 @@ func (h *handler) FetchVideos(ctx context.Context) error {
741 h.log.Error("failed to parse channel feed", "error", result.err)
742 continue
743 }
744- h.addVideosForChannel(ctx, result.channel)
745+
746+ h.addVideosForChannel(ctx, result.channel, result.enableShorts)
747 }
748
749 return nil
750@@ -198,9 +206,10 @@ func (h *handler) AddCustomVideo(ctx context.Context, videoID string) error {
751 }
752
753 channel := models.Channel{
754- ID: video.ChannelID,
755- Name: video.ChannelName,
756- Subscribed: false,
757+ ID: video.ChannelID,
758+ Name: video.ChannelName,
759+ Subscribed: false,
760+ EnableShorts: true,
761 }
762 err = h.db.SubscribeToChannel(ctx, channel)
763diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
764index 1af3595293a223325cba5ee94394cb2b334fb635..a8c7eda9c0a1f67674ad9000975eb0fb9b846c29 100644
765--- a/httpserver/ytrssil/api_setup_test.go
766+++ b/httpserver/ytrssil/api_setup_test.go
767@@ -2,10 +2,10 @@ package ytrssil_test
768
769 import (
770 "fmt"
771- "io"
772 "log/slog"
773 "net/http"
774 "net/http/httptest"
775+ "os"
776 "testing"
777 "time"
778
779@@ -34,10 +34,17 @@ func TestAPITestSuite(t *testing.T) {
780 }
781
782 func (s *APITestSuite) SetupSuite() {
783- l := slog.New(slog.NewTextHandler(io.Discard, nil))
784+ l := slog.New(slog.NewTextHandler(os.Stderr, nil))
785 s.cfg = config.TestConfig()
786
787- handler := handler.New(l, nil, nil, nil, nil, s.cfg.DownloadsDir)
788+ handler := handler.New(
789+ l,
790+ nil,
791+ nil,
792+ nil,
793+ nil,
794+ s.cfg,
795+ )
796
797 gin.SetMode(gin.TestMode)
798 router, err := ytrssil.SetupGinRouter(l, s.cfg, handler)
799diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
800index 6a0691efe57a415c09e1ea074a283fded0ae021b..2f73cbb13cb8a4a0c4ef98970788d780c395c29d 100644
801--- a/httpserver/ytrssil/channels.go
802+++ b/httpserver/ytrssil/channels.go
803@@ -43,3 +43,21 @@ func (srv *server) UnsubscribeFromChannelJSON(c *gin.Context) {
804
805 c.JSON(http.StatusOK, gin.H{"msg": "unsubscribed from channel successfully"})
806 }
807+
808+func (srv *server) ToggleChannelShortsJSON(c *gin.Context) {
809+ channelID := c.Param("channel_id")
810+ enable := c.Query("enable") == "true"
811+
812+ err := srv.handler.ToggleChannelShorts(c.Request.Context(), channelID, enable)
813+ if err != nil {
814+ if errors.Is(err, db.ErrChannelNotFound) {
815+ c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
816+ return
817+ }
818+
819+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
820+ return
821+ }
822+
823+ c.JSON(http.StatusOK, gin.H{"msg": "shorts setting updated", "enable_shorts": enable})
824+}
825diff --git a/httpserver/ytrssil/channels_pages.go b/httpserver/ytrssil/channels_pages.go
826index 28373caf18c27da3bcfe975118cd9b9889377920..93360f574c6f3b05935759207cd015f9fbf03c6d 100644
827--- a/httpserver/ytrssil/channels_pages.go
828+++ b/httpserver/ytrssil/channels_pages.go
829@@ -62,3 +62,31 @@ func (srv *server) UnsubscribeFromChannelPage(c *gin.Context) {
830
831 returnMsg(c, http.StatusOK, "")
832 }
833+
834+func (srv *server) ToggleChannelShortsPage(c *gin.Context) {
835+ channelID := c.Param("channel_id")
836+ enable := c.PostForm("enable") == "true"
837+
838+ err := srv.handler.ToggleChannelShorts(c.Request.Context(), channelID, enable)
839+ if err != nil {
840+ if errors.Is(err, db.ErrChannelNotFound) {
841+ returnErr(c, http.StatusNotFound, err)
842+ return
843+ }
844+
845+ returnErr(c, http.StatusInternalServerError, err)
846+ return
847+ }
848+
849+ channel, err := srv.handler.GetChannelByID(c.Request.Context(), channelID)
850+ if err != nil {
851+ returnErr(c, http.StatusInternalServerError, err)
852+ return
853+ }
854+
855+ r := pages.TemplRenderer{
856+ Ctx: c.Request.Context(),
857+ Component: pages.ChannelCard(*channel),
858+ }
859+ c.Render(http.StatusOK, r)
860+}
861diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
862index 04df4d71926e625cb0c18442597fa65ae113ae9f..23bb7d705210b7da8ce50916c21dd1193937665f 100644
863--- a/httpserver/ytrssil/server.go
864+++ b/httpserver/ytrssil/server.go
865@@ -78,6 +78,7 @@ func SetupGinRouter(
866 pages.GET("/channels", srv.ChannelsPage)
867 pages.POST("/subscribe", srv.SubscribeToChannelPage)
868 pages.POST("/channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelPage)
869+ pages.POST("/channels/:channel_id/toggle-shorts", srv.ToggleChannelShortsPage)
870 pages.POST("/videos", srv.AddVideoPage)
871 pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
872 pages.PATCH("/videos/:video_id/unwatch", srv.MarkVideoAsUnwatchedPage)
873diff --git a/httpserver/ytrssil/suite_test.go b/httpserver/ytrssil/suite_test.go
874index 399853a7ae6a938ce913b369efd55cea3c3dfc9c..3aecb894855ca12f476c7065f42c831e00b639ff 100644
875--- a/httpserver/ytrssil/suite_test.go
876+++ b/httpserver/ytrssil/suite_test.go
877@@ -3,9 +3,9 @@ package ytrssil_test
878 import (
879 "context"
880 "fmt"
881- "io"
882 "log/slog"
883 "net/http"
884+ "os"
885 "os/exec"
886 "strings"
887 "time"
888@@ -42,7 +42,7 @@ type EndpointsTestSuite struct {
889
890 func (s *EndpointsTestSuite) SetupSuite() {
891 var err error
892- l := slog.New(slog.NewTextHandler(io.Discard, nil))
893+ l := slog.New(slog.NewTextHandler(os.Stderr, nil))
894 s.cfg = config.TestConfig()
895
896 s.schema = fmt.Sprintf("ytrssil_test_%s", ulid.Make().String())
897@@ -112,7 +112,7 @@ func (s *EndpointsTestSuite) SetupSuite() {
898 },
899 }
900
901- h := handler.New(l, s.db, s.parser, s.youtubeClient, nil, s.cfg.DownloadsDir)
902+ h := handler.New(l, s.db, s.parser, s.youtubeClient, nil, s.cfg)
903
904 gin.SetMode(gin.TestMode)
905 router, err := ytrssil.SetupGinRouter(l, s.cfg, h)
906@@ -135,8 +135,9 @@ func (s *EndpointsTestSuite) TearDownSuite() {
907 }
908
909 func (s *EndpointsTestSuite) SetupTest() {
910- _, err := s.dbConn.Exec(context.Background(), fmt.Sprintf("TRUNCATE TABLE %s.videos", s.schema))
911+ query := fmt.Sprintf("TRUNCATE TABLE %s.videos, %s.channels CASCADE", s.schema, s.schema)
912+ _, err := s.dbConn.Exec(context.Background(), query)
913 if err != nil {
914- panic(fmt.Sprintf("failed to drop test schema: %v", err))
915+ panic(fmt.Sprintf("failed to truncate test tables: %v", err))
916 }
917 }
918diff --git a/httpserver/ytrssil/videos_test.go b/httpserver/ytrssil/videos_test.go
919index ac88c2731a11b4e67215f505a14998099b87e15f..55b946296c08f67b811823180822fde575cdd6ae 100644
920--- a/httpserver/ytrssil/videos_test.go
921+++ b/httpserver/ytrssil/videos_test.go
922@@ -40,7 +40,7 @@ func (s *VideosTestSuite) TestGetNewVideosJSON() {
923 PublishedTime: time.Now().Add(-1 * time.Hour),
924 DurationSeconds: 300,
925 IsShort: false,
926- }, channelID)
927+ }, channelID, false)
928 s.Require().NoError(err)
929
930 w := httptest.NewRecorder()
931@@ -75,7 +75,7 @@ func (s *VideosTestSuite) TestGetWatchedVideosJSON() {
932 PublishedTime: time.Now().Add(-1 * time.Hour),
933 DurationSeconds: 300,
934 IsShort: false,
935- }, channelID)
936+ }, channelID, false)
937 s.Require().NoError(err)
938
939 watchTime := time.Now()
940@@ -114,7 +114,7 @@ func (s *VideosTestSuite) TestMarkVideoAsWatchedJSON() {
941 PublishedTime: time.Now().Add(-1 * time.Hour),
942 DurationSeconds: 300,
943 IsShort: false,
944- }, channelID)
945+ }, channelID, false)
946 s.Require().NoError(err)
947
948 w := httptest.NewRecorder()
949@@ -148,7 +148,7 @@ func (s *VideosTestSuite) TestMarkVideoAsUnwatchedJSON() {
950 PublishedTime: time.Now().Add(-1 * time.Hour),
951 DurationSeconds: 300,
952 IsShort: false,
953- }, channelID)
954+ }, channelID, false)
955 s.Require().NoError(err)
956
957 watchTime := time.Now()
958@@ -186,7 +186,7 @@ func (s *VideosTestSuite) TestNewVideosPage() {
959 PublishedTime: time.Now().Add(-1 * time.Hour),
960 DurationSeconds: 300,
961 IsShort: false,
962- }, channelID)
963+ }, channelID, false)
964 s.Require().NoError(err)
965
966 w := httptest.NewRecorder()
967@@ -215,7 +215,7 @@ func (s *VideosTestSuite) TestWatchedVideosPage() {
968 PublishedTime: time.Now().Add(-1 * time.Hour),
969 DurationSeconds: 300,
970 IsShort: false,
971- }, channelID)
972+ }, channelID, false)
973 s.Require().NoError(err)
974
975 watchTime := time.Now()
976@@ -261,7 +261,7 @@ func (s *VideosTestSuite) TestMarkVideoAsWatchedPage() {
977 PublishedTime: time.Now().Add(-1 * time.Hour),
978 DurationSeconds: 300,
979 IsShort: false,
980- }, channelID)
981+ }, channelID, false)
982 s.Require().NoError(err)
983
984 w := httptest.NewRecorder()
985@@ -289,7 +289,7 @@ func (s *VideosTestSuite) TestMarkVideoAsUnwatchedPage() {
986 PublishedTime: time.Now().Add(-1 * time.Hour),
987 DurationSeconds: 300,
988 IsShort: false,
989- }, channelID)
990+ }, channelID, false)
991 s.Require().NoError(err)
992
993 watchTime := time.Now()
994@@ -321,7 +321,7 @@ func (s *VideosTestSuite) TestSetVideoProgressPage() {
995 PublishedTime: time.Now().Add(-1 * time.Hour),
996 DurationSeconds: 300,
997 IsShort: false,
998- }, channelID)
999+ }, channelID, false)
1000 s.Require().NoError(err)
1001
1002 form := url.Values{}
1003diff --git a/migrations/000008_add_shorts_support.down.sql b/migrations/000008_add_shorts_support.down.sql
1004new file mode 100644
1005index 0000000000000000000000000000000000000000..fe725e657bcc6a26c08550867f4daa2bfe7c0432
1006--- /dev/null
1007+++ b/migrations/000008_add_shorts_support.down.sql
1008@@ -0,0 +1,2 @@
1009+ALTER TABLE videos DROP COLUMN IF EXISTS is_discarded;
1010+ALTER TABLE channels DROP COLUMN IF EXISTS enable_shorts;
1011diff --git a/migrations/000008_add_shorts_support.up.sql b/migrations/000008_add_shorts_support.up.sql
1012new file mode 100644
1013index 0000000000000000000000000000000000000000..bfd35932e490296a160757abb0d621dc13f133b1
1014--- /dev/null
1015+++ b/migrations/000008_add_shorts_support.up.sql
1016@@ -0,0 +1,2 @@
1017+ALTER TABLE channels ADD COLUMN IF NOT EXISTS enable_shorts BOOLEAN DEFAULT true;
1018+ALTER TABLE videos ADD COLUMN IF NOT EXISTS is_discarded BOOLEAN DEFAULT false;
1019diff --git a/mocks/db/db.go b/mocks/db/db.go
1020index 2faf144416978753ff49ceea74394d9a6d9d3c2a..3a075ebfb5fd8aa9ee7da3f006376dc98b60d477 100644
1021--- a/mocks/db/db.go
1022+++ b/mocks/db/db.go
1023@@ -21,7 +21,7 @@ var _ db.DB = &DBMock{}
1024 //
1025 // // make and configure a mocked db.DB
1026 // mockedDB := &DBMock{
1027-// AddVideoFunc: func(ctx context.Context, video models.Video, channelID string) error {
1028+// AddVideoFunc: func(ctx context.Context, video models.Video, channelID string, isDiscarded bool) error {
1029 // panic("mock out the AddVideo method")
1030 // },
1031 // CloseFunc: func() {
1032@@ -30,6 +30,12 @@ var _ db.DB = &DBMock{}
1033 // DeleteVideoFileFunc: func(ctx context.Context, videoID string) error {
1034 // panic("mock out the DeleteVideoFile method")
1035 // },
1036+// DiscardVideoFunc: func(ctx context.Context, videoID string) error {
1037+// panic("mock out the DiscardVideo method")
1038+// },
1039+// GetChannelByIDFunc: func(ctx context.Context, channelID string) (*models.Channel, error) {
1040+// panic("mock out the GetChannelByID method")
1041+// },
1042 // GetNewVideosFunc: func(ctx context.Context, sortDesc bool) ([]models.Video, error) {
1043 // panic("mock out the GetNewVideos method")
1044 // },
1045@@ -66,6 +72,9 @@ var _ db.DB = &DBMock{}
1046 // SubscribeToChannelFunc: func(ctx context.Context, channel models.Channel) error {
1047 // panic("mock out the SubscribeToChannel method")
1048 // },
1049+// ToggleChannelShortsFunc: func(ctx context.Context, channelID string, enableShorts bool) error {
1050+// panic("mock out the ToggleChannelShorts method")
1051+// },
1052 // UnsubscribeFromChannelFunc: func(ctx context.Context, channelID string) error {
1053 // panic("mock out the UnsubscribeFromChannel method")
1054 // },
1055@@ -77,7 +86,7 @@ var _ db.DB = &DBMock{}
1056 // }
1057 type DBMock struct {
1058 // AddVideoFunc mocks the AddVideo method.
1059- AddVideoFunc func(ctx context.Context, video models.Video, channelID string) error
1060+ AddVideoFunc func(ctx context.Context, video models.Video, channelID string, isDiscarded bool) error
1061
1062 // CloseFunc mocks the Close method.
1063 CloseFunc func()
1064@@ -85,6 +94,12 @@ type DBMock struct {
1065 // DeleteVideoFileFunc mocks the DeleteVideoFile method.
1066 DeleteVideoFileFunc func(ctx context.Context, videoID string) error
1067
1068+ // DiscardVideoFunc mocks the DiscardVideo method.
1069+ DiscardVideoFunc func(ctx context.Context, videoID string) error
1070+
1071+ // GetChannelByIDFunc mocks the GetChannelByID method.
1072+ GetChannelByIDFunc func(ctx context.Context, channelID string) (*models.Channel, error)
1073+
1074 // GetNewVideosFunc mocks the GetNewVideos method.
1075 GetNewVideosFunc func(ctx context.Context, sortDesc bool) ([]models.Video, error)
1076
1077@@ -121,6 +136,9 @@ type DBMock struct {
1078 // SubscribeToChannelFunc mocks the SubscribeToChannel method.
1079 SubscribeToChannelFunc func(ctx context.Context, channel models.Channel) error
1080
1081+ // ToggleChannelShortsFunc mocks the ToggleChannelShorts method.
1082+ ToggleChannelShortsFunc func(ctx context.Context, channelID string, enableShorts bool) error
1083+
1084 // UnsubscribeFromChannelFunc mocks the UnsubscribeFromChannel method.
1085 UnsubscribeFromChannelFunc func(ctx context.Context, channelID string) error
1086
1087@@ -134,6 +152,8 @@ type DBMock struct {
1088 Video models.Video
1089 // ChannelID is the channelID argument value.
1090 ChannelID string
1091+ // IsDiscarded is the isDiscarded argument value.
1092+ IsDiscarded bool
1093 }
1094 // Close holds details about calls to the Close method.
1095 Close []struct {
1096@@ -145,6 +165,20 @@ type DBMock struct {
1097 // VideoID is the videoID argument value.
1098 VideoID string
1099 }
1100+ // DiscardVideo holds details about calls to the DiscardVideo method.
1101+ DiscardVideo []struct {
1102+ // Ctx is the ctx argument value.
1103+ Ctx context.Context
1104+ // VideoID is the videoID argument value.
1105+ VideoID string
1106+ }
1107+ // GetChannelByID holds details about calls to the GetChannelByID method.
1108+ GetChannelByID []struct {
1109+ // Ctx is the ctx argument value.
1110+ Ctx context.Context
1111+ // ChannelID is the channelID argument value.
1112+ ChannelID string
1113+ }
1114 // GetNewVideos holds details about calls to the GetNewVideos method.
1115 GetNewVideos []struct {
1116 // Ctx is the ctx argument value.
1117@@ -241,6 +275,15 @@ type DBMock struct {
1118 // Channel is the channel argument value.
1119 Channel models.Channel
1120 }
1121+ // ToggleChannelShorts holds details about calls to the ToggleChannelShorts method.
1122+ ToggleChannelShorts []struct {
1123diff --git a/models/channel.go b/models/channel.go
1124index a3be9a930cc017c20fdb5ca79e9973ed333e3d51..61bbc991827b406a0cd17791adf47c51e0ee8924 100644
1125--- a/models/channel.go
1126+++ b/models/channel.go
1127@@ -11,4 +11,6 @@ type Channel struct {
1128 UnwatchedCount int `json:"unwatched_count"`
1129 // ImageURL is the URL of the channel's profile image
1130 ImageURL string `json:"image_url"`
1131+ // EnableShorts indicates if shorts should be shown for this channel
1132+ EnableShorts bool `json:"enable_shorts"`
1133 }
1134diff --git a/models/video.go b/models/video.go
1135index 82b0d6312344d4a775d97c45e9af9304aedbde06..45e92040a39dc163eea9fa2a4eb19004e886b89d 100644
1136--- a/models/video.go
1137+++ b/models/video.go
1138@@ -26,6 +26,9 @@ type Video struct {
1139 ProgressSeconds int `json:"progress"`
1140 // IsShort indicates if a video is a YouTube short
1141 IsShort bool `json:"short"`
1142+ // IsDiscarded indicates if the video was discarded from the feed without watching
1143+ // This happens to shorts videos for channels that have shorts disabled
1144+ IsDiscarded bool `json:"is_discarded"`
1145 // DownloadedAt is the timestamp when the video was downloaded to the server
1146 DownloadedAt *time.Time `json:"downloaded_at"`
1147 // FilePath is the path to the downloaded video file on the server
1148diff --git a/pages/add_video_modal_templ.go b/pages/add_video_modal_templ.go
1149index a3bca8401868b9c62b48299723a0f18ab7715932..0195ddf46b74cbf91f03f36010f80766b63000d0 100644
1150--- a/pages/add_video_modal_templ.go
1151+++ b/pages/add_video_modal_templ.go
1152@@ -1,6 +1,6 @@
1153 // Code generated by templ - DO NOT EDIT.
1154
1155-// templ: version: v0.3.977
1156+// templ: version: v0.3.1001
1157
1158 package pages
1159
1160diff --git a/pages/auth_templ.go b/pages/auth_templ.go
1161index c762aa9cd26f02507a90be8f3915cb1aac17815b..d6a0b95605bd02f32287882f33ab75b6cfb805bf 100644
1162--- a/pages/auth_templ.go
1163+++ b/pages/auth_templ.go
1164@@ -1,6 +1,6 @@
1165 // Code generated by templ - DO NOT EDIT.
1166
1167-// templ: version: v0.3.977
1168+// templ: version: v0.3.1001
1169 package pages
1170
1171 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1172diff --git a/pages/channels.templ b/pages/channels.templ
1173index 640c28beb473c058ec449ede7248ab44d5194a9c..421796fa7e55d7a4f0693ca62c968efb5cefb0cf 100644
1174--- a/pages/channels.templ
1175+++ b/pages/channels.templ
1176@@ -35,6 +35,33 @@ templ unsubscribeModal(channelID string) {
1177 </div>
1178 }
1179
1180+templ shortsToggle(channel models.Channel) {
1181+ <a
1182+ href={ fmt.Sprintf("/channels/%s/toggle-shorts?enable=%t", channel.ID, !channel.EnableShorts) }
1183+ class={ fmt.Sprintf("btn btn-sm %s", ifShortsEnabled(channel.EnableShorts)) }
1184+ hx-post={ fmt.Sprintf("/channels/%s/toggle-shorts", channel.ID) }
1185+ hx-vals={ fmt.Sprintf(`{"enable": %t}`, !channel.EnableShorts) }
1186+ hx-target={ fmt.Sprintf("#channel-card-%s", channel.ID) }
1187+ hx-swap="outerHTML"
1188+ >
1189+ { toggleLabel(channel.EnableShorts) }
1190+ </a>
1191+}
1192+
1193+func ifShortsEnabled(enabled bool) string {
1194+ if enabled {
1195+ return "btn-success"
1196+ }
1197+ return "btn-outline-secondary"
1198+}
1199+
1200+func toggleLabel(enabled bool) string {
1201+ if enabled {
1202+ return "Shorts: ON"
1203+ }
1204+ return "Shorts: OFF"
1205+}
1206+
1207 templ ChannelCard(channel models.Channel) {
1208 <div id={ fmt.Sprintf("channel-card-%s", channel.ID) } class="channel-card col-md-3 p-2">
1209 @unsubscribeModal(channel.ID)
1210@@ -62,13 +89,16 @@ templ ChannelCard(channel models.Channel) {
1211 { fmt.Sprintf("%d", channel.UnwatchedCount) } unwatched
1212 </p>
1213 </div>
1214- <button
1215- class="btn btn-danger ms-2"
1216- data-bs-toggle="modal"
1217- data-bs-target={ fmt.Sprintf("#unsubscribe-modal-%s", channel.ID) }
1218- >
1219- <i class="bi bi-bookmark-dash"></i>
1220- </button>
1221+ <div class="d-flex gap-2 align-items-center">
1222+ @shortsToggle(channel)
1223+ <button
1224+ class="btn btn-danger ms-2"
1225+ data-bs-toggle="modal"
1226+ data-bs-target={ fmt.Sprintf("#unsubscribe-modal-%s", channel.ID) }
1227+ >
1228+ <i class="bi bi-bookmark-dash"></i>
1229+ </button>
1230+ </div>
1231 </div>
1232 </div>
1233 </div>
1234diff --git a/pages/channels_templ.go b/pages/channels_templ.go
1235index 4abab25a8881bb515f49625caea6bb193b985966..ae614d9390b80a3e9fad516065a3bd0abf39c01f 100644
1236--- a/pages/channels_templ.go
1237+++ b/pages/channels_templ.go
1238@@ -1,6 +1,6 @@
1239 // Code generated by templ - DO NOT EDIT.
1240
1241-// templ: version: v0.3.977
1242+// templ: version: v0.3.1001
1243 package pages
1244
1245 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1246@@ -82,7 +82,7 @@ func unsubscribeModal(channelID string) templ.Component {
1247 })
1248 }
1249
1250-func ChannelCard(channel models.Channel) templ.Component {
1251+func shortsToggle(channel models.Channel) templ.Component {
1252 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1253 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1254 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1255@@ -103,20 +103,146 @@ func ChannelCard(channel models.Channel) templ.Component {
1256 templ_7745c5c3_Var5 = templ.NopComponent
1257 }
1258 ctx = templ.ClearChildren(ctx)
1259- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div id=\"")
1260+ var templ_7745c5c3_Var6 = []any{fmt.Sprintf("btn btn-sm %s", ifShortsEnabled(channel.EnableShorts))}
1261+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
1262+ if templ_7745c5c3_Err != nil {
1263+ return templ_7745c5c3_Err
1264+ }
1265+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<a href=\"")
1266+ if templ_7745c5c3_Err != nil {
1267+ return templ_7745c5c3_Err
1268+ }
1269+ var templ_7745c5c3_Var7 templ.SafeURL
1270+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/channels/%s/toggle-shorts?enable=%t", channel.ID, !channel.EnableShorts))
1271+ if templ_7745c5c3_Err != nil {
1272+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 40, Col: 95}
1273+ }
1274+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
1275+ if templ_7745c5c3_Err != nil {
1276+ return templ_7745c5c3_Err
1277+ }
1278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"")
1279+ if templ_7745c5c3_Err != nil {
1280+ return templ_7745c5c3_Err
1281+ }
1282+ var templ_7745c5c3_Var8 string
1283+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
1284+ if templ_7745c5c3_Err != nil {
1285+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 1, Col: 0}
1286+ }
1287+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
1288+ if templ_7745c5c3_Err != nil {
1289+ return templ_7745c5c3_Err
1290+ }
1291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-post=\"")
1292+ if templ_7745c5c3_Err != nil {
1293+ return templ_7745c5c3_Err
1294+ }
1295+ var templ_7745c5c3_Var9 string
1296+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/channels/%s/toggle-shorts", channel.ID))
1297+ if templ_7745c5c3_Err != nil {
1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 42, Col: 65}
1299+ }
1300+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
1301+ if templ_7745c5c3_Err != nil {
1302+ return templ_7745c5c3_Err
1303+ }
1304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" hx-vals=\"")
1305+ if templ_7745c5c3_Err != nil {
1306+ return templ_7745c5c3_Err
1307+ }
1308+ var templ_7745c5c3_Var10 string
1309+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"enable": %t}`, !channel.EnableShorts))
1310+ if templ_7745c5c3_Err != nil {
1311+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 43, Col: 64}
1312+ }
1313+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
1314 if templ_7745c5c3_Err != nil {
1315 return templ_7745c5c3_Err
1316 }
1317- var templ_7745c5c3_Var6 string
1318- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("channel-card-%s", channel.ID))
1319+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-target=\"")
1320 if templ_7745c5c3_Err != nil {
1321- return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 39, Col: 53}
1322+ return templ_7745c5c3_Err
1323 }
1324- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
1325+ var templ_7745c5c3_Var11 string
1326+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#channel-card-%s", channel.ID))
1327+ if templ_7745c5c3_Err != nil {
1328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/channels.templ`, Line: 44, Col: 57}
1329+ }
1330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
1331 if templ_7745c5c3_Err != nil {
1332 return templ_7745c5c3_Err
1333 }
1334- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"channel-card col-md-3 p-2\">")
1335+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" hx-swap=\"outerHTML\">")
1336+ if templ_7745c5c3_Err != nil {
1337+ return templ_7745c5c3_Err
1338diff --git a/pages/error_templ.go b/pages/error_templ.go
1339index f1751101a72ef6e968b229018cdddcea29d4eb6d..5987f7d6b7862c6002c891fc8802260832db5c3a 100644
1340--- a/pages/error_templ.go
1341+++ b/pages/error_templ.go
1342@@ -1,6 +1,6 @@
1343 // Code generated by templ - DO NOT EDIT.
1344
1345-// templ: version: v0.3.977
1346+// templ: version: v0.3.1001
1347 package pages
1348
1349 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1350diff --git a/pages/main_templ.go b/pages/main_templ.go
1351index 99b6c16c4807a66bc14f5d4c56210cd3a5c1c2c8..7261b696e0843d0c37b8e59929ef89de592013d3 100644
1352--- a/pages/main_templ.go
1353+++ b/pages/main_templ.go
1354@@ -1,6 +1,6 @@
1355 // Code generated by templ - DO NOT EDIT.
1356
1357-// templ: version: v0.3.977
1358+// templ: version: v0.3.1001
1359 package pages
1360
1361 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1362diff --git a/pages/navbar_templ.go b/pages/navbar_templ.go
1363index e5aa8057dfa04c67602f878d009504b5766c9bc6..f2cc7c24b33e02e695702c9799eadc2af64ac3cd 100644
1364--- a/pages/navbar_templ.go
1365+++ b/pages/navbar_templ.go
1366@@ -1,6 +1,6 @@
1367 // Code generated by templ - DO NOT EDIT.
1368
1369-// templ: version: v0.3.977
1370+// templ: version: v0.3.1001
1371 package pages
1372
1373 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1374diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
1375index 97553643a2473167ad6bf05e38c156f5a23bb975..a9bb2c5a258fe5ea3301032915cd667a178c98de 100644
1376--- a/pages/new_videos_templ.go
1377+++ b/pages/new_videos_templ.go
1378@@ -1,6 +1,6 @@
1379 // Code generated by templ - DO NOT EDIT.
1380
1381-// templ: version: v0.3.977
1382+// templ: version: v0.3.1001
1383 package pages
1384
1385 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1386diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
1387index 8875e7f18135ce8196abc6861a86532f157edafd..159c663781a01c7504febb91a4207e0416fc9dea 100644
1388--- a/pages/subscription_templ.go
1389+++ b/pages/subscription_templ.go
1390@@ -1,6 +1,6 @@
1391 // Code generated by templ - DO NOT EDIT.
1392
1393-// templ: version: v0.3.977
1394+// templ: version: v0.3.1001
1395 package pages
1396
1397 //lint:file-ignore SA4006 This context is only used if a nested component is present.
1398diff --git a/pages/watched_videos_templ.go b/pages/watched_videos_templ.go
1399index 2ee5d23756749cf5ace107ed1f5168f6f81872d8..ad99a7ca9636848649fc0ab284111bc80e955bb2 100644
1400--- a/pages/watched_videos_templ.go
1401+++ b/pages/watched_videos_templ.go
1402@@ -1,6 +1,6 @@
1403 // Code generated by templ - DO NOT EDIT.
1404
1405-// templ: version: v0.3.977
1406+// templ: version: v0.3.1001
1407 package pages
1408
1409 //lint:file-ignore SA4006 This context is only used if a nested component is present.