42b3f3d05e91d0c0f8955ca426fc4fa3074d06c0

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

Message

Add shorts filter per channel with toggle UI

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.