49e1f0ad99c8b254467a39fc1e9ff234d464efc4

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

Message

Add support for adding custom videos

Diff

This diff is truncated to protect this page.

   1diff --git a/.dockerignore b/.dockerignore
   2index 34b70187d6d275b0430cbdfbbdb82d2c18fdcf89..68ad3c2ba35778f5ec24b2e8725f32ee82f8d404 100644
   3--- a/.dockerignore
   4+++ b/.dockerignore
   5@@ -1,5 +1,6 @@
   6 bin
   7 dist
   8+assets/vendor/
   9 
  10 compose.yaml
  11 Dockerfile
  12diff --git a/.gitignore b/.gitignore
  13index 80f7f4b1607b156770526a7cb39ef1c83a6a10ac..82009254414ac175f140ffd4d0b10fbd5ef42930 100644
  14--- a/.gitignore
  15+++ b/.gitignore
  16@@ -23,6 +23,7 @@ debug
  17 # Build artifacts and cache
  18 **/bin
  19 /dist/
  20+assets/vendor/
  21 
  22 # Visual studio code configuration
  23 .vscode/
  24diff --git a/.serena/.gitignore b/.serena/.gitignore
  25new file mode 100644
  26index 0000000000000000000000000000000000000000..14d86ad623010057a726185db3690c2d04126984
  27--- /dev/null
  28+++ b/.serena/.gitignore
  29@@ -0,0 +1 @@
  30+/cache
  31diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md
  32new file mode 100644
  33index 0000000000000000000000000000000000000000..e09511b98427c1618c4c7efc0417585eaeb17426
  34--- /dev/null
  35+++ b/.serena/memories/code_style_conventions.md
  36@@ -0,0 +1,36 @@
  37+# Code Style and Conventions
  38+
  39+## General Style
  40+- **Indentation**: Tabs with size 4 (as per .editorconfig)
  41+- **Line Endings**: LF (Unix-style)
  42+- **Charset**: UTF-8
  43+- **Trailing Whitespace**: Must be trimmed
  44+- **Final Newline**: Required in all files
  45+
  46+## YAML/TOML Files
  47+- **Indentation**: 2 spaces (not tabs)
  48+
  49+## Go-Specific Conventions
  50+- **Go Version**: 1.25
  51+- **Formatting**: Uses `gofmt`, `gofumpt`, and `goimports`
  52+- **Import Grouping**: Via `gci` with sections:
  53+  - standard library
  54+  - default (third-party)
  55+  - localmodule (project imports)
  56+
  57+## Linting Rules
  58+Enabled linters:
  59+- `forbidigo`: Prevents use of forbidden identifiers
  60+- `lll`: Line length limit
  61+- `prealloc`: Detects slice declarations that could preallocate
  62+- `predeclared`: Finds shadowing of predeclared identifiers
  63+- `staticcheck`: Static analysis checks
  64+
  65+Disabled linters:
  66+- `errcheck`: Error checking is disabled
  67+
  68+Mock files in `mocks/` directory are excluded from most linting rules.
  69+
  70+## File Generation
  71+- Templates use `templ` and must be regenerated via `templ generate` before building
  72+- Mocks are generated using `moq` tool
  73diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md
  74new file mode 100644
  75index 0000000000000000000000000000000000000000..e9697533f94af1d8e6bcd4774d97b8c9e404b38a
  76--- /dev/null
  77+++ b/.serena/memories/project_overview.md
  78@@ -0,0 +1,44 @@
  79+# Ytrssil API - Project Overview
  80+
  81+## Purpose
  82+Ytrssil API is a YouTube RSS-to-interface layer application. It provides an HTTP API for managing YouTube channel subscriptions and video tracking with watch progress functionality.
  83+
  84+## Tech Stack
  85+- **Language**: Go 1.25
  86+- **Web Framework**: Gin (github.com/gin-gonic/gin v1.11.0)
  87+- **Templating**: templ (github.com/a-h/templ v0.3.960) for HTML generation
  88+- **Database**: PostgreSQL (via lib/pq driver)
  89+- **Testing**: testify (github.com/stretchr/testify v1.11.1)
  90+- **Hot Reload**: air (for development)
  91+- **Containerization**: Docker with buildx
  92+
  93+## Project Structure
  94+```
  95+.
  96+├── cmd/               # Entry point (main.go)
  97+├── httpserver/        # HTTP server implementations
  98+│   ├── ytrssil/      # Main API routes
  99+│   └── auth/         # Authentication routes
 100+├── handler/          # Business logic handlers
 101+│   ├── channels.go
 102+│   ├── videos.go
 103+│   └── handler_test.go
 104+├── models/           # Data models
 105+│   ├── channel.go
 106+│   └── video.go
 107+├── db/               # Database layer
 108+│   ├── db.go
 109+│   ├── psql.go
 110+│   ├── channels.go
 111+│   └── videos.go
 112+├── feedparser/       # RSS feed parsing logic
 113+├── pages/            # templ templates for HTML pages
 114+├── lib/              # Shared libraries
 115+├── mocks/            # Generated mock interfaces for testing
 116+├── migrations/       # Database migrations
 117+├── config/           # Configuration files
 118+└── assets/           # Static assets
 119+```
 120+
 121+## Dependencies
 122+Database runs on PostgreSQL 18 via Docker Compose.
 123diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md
 124new file mode 100644
 125index 0000000000000000000000000000000000000000..42026c4932e77b38ac4c1fcf044dbe6050d01fbe
 126--- /dev/null
 127+++ b/.serena/memories/suggested_commands.md
 128@@ -0,0 +1,60 @@
 129+# Suggested Commands
 130+
 131+## Development Commands
 132+
 133+### Building
 134+```bash
 135+make build          # Generate templates and build binary to dist/ytrssil
 136+templ generate      # Generate Go code from templ templates
 137+```
 138+
 139+### Testing
 140+```bash
 141+make test           # Run tests with race detection and benchmarks (10s timeout)
 142+go test -timeout=10s -race -benchmem ./...
 143+```
 144+
 145+### Linting and Formatting
 146+```bash
 147+make lint           # Run full linting pipeline:
 148+                    # - go mod tidy
 149+                    # - go vet
 150+                    # - golangci-lint fmt (format)
 151+                    # - golangci-lint run (lint)
 152+```
 153+
 154+### Development Server
 155+```bash
 156+make air            # Start hot-reload development server using air
 157+```
 158+
 159+### Mock Generation
 160+```bash
 161+make gen-mocks      # Generate mock implementations for testing:
 162+                    # - db/DB interface
 163+                    # - feedparser/Parser interface
 164+                    # - lib/clients/youtube/Client interface
 165+```
 166+
 167+### Database
 168+```bash
 169+make migrate        # Run database migrations
 170+                    # Default: postgres://ytrssil:ytrssil@localhost:5432/ytrssil?sslmode=disable
 171+                    # Override with: make migrate DB_URI=<your-uri>
 172+```
 173+
 174+### Docker
 175+```bash
 176+make image-build    # Build and push Docker images:
 177+                    # - theedgeofrage/ytrssil:api
 178+                    # - theedgeofrage/ytrssil:migrations
 179+
 180+docker compose up   # Start full stack (postgres + api + migrations)
 181+```
 182+
 183+## Binary Installation (in bin/)
 184+The Makefile installs tools locally to avoid global pollution:
 185+- `bin/moq`: Mock generator
 186+- `bin/golangci-lint`: Linter
 187+- `bin/migrate`: Database migration tool
 188+- `bin/air`: Hot reload development server
 189diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md
 190new file mode 100644
 191index 0000000000000000000000000000000000000000..6828d5bd782aa195fb4ecbedbeb51832974f748c
 192--- /dev/null
 193+++ b/.serena/memories/task_completion_checklist.md
 194@@ -0,0 +1,54 @@
 195+# Task Completion Checklist
 196+
 197+When completing a coding task in this project, follow these steps:
 198+
 199+## 1. Code Generation
 200+If templates were modified:
 201+```bash
 202+templ generate
 203+```
 204+
 205+## 2. Mock Generation
 206+If interfaces were modified:
 207+```bash
 208+make gen-mocks
 209+```
 210+
 211+## 3. Linting and Formatting
 212+Always run before committing:
 213+```bash
 214+make lint
 215+```
 216+This will:
 217+- Tidy go.mod dependencies
 218+- Run go vet
 219+- Format code with golangci-lint
 220+- Run static analysis linters
 221+
 222+## 4. Testing
 223+Run the test suite:
 224+```bash
 225+make test
 226+```
 227+Tests run with:
 228+- 10 second timeout
 229+- Race detection enabled
 230+- Benchmark memory tracking
 231+
 232+## 5. Build Verification
 233+Ensure the project builds:
 234+```bash
 235+make build
 236+```
 237+
 238+## 6. Database Migrations
 239+If database schema changed:
 240+```bash
 241+make migrate
 242+```
 243+
 244+## Notes
 245+- All commands should pass without errors
 246+- The project uses tabs for indentation (size 4)
 247+- Mock files are auto-excluded from linting
 248+- Tests should complete within 10 seconds
 249diff --git a/.serena/project.yml b/.serena/project.yml
 250new file mode 100644
 251index 0000000000000000000000000000000000000000..2d70284c0b28e1e88d903a9cd018567266653ef6
 252--- /dev/null
 253+++ b/.serena/project.yml
 254@@ -0,0 +1,84 @@
 255+# list of languages for which language servers are started; choose from:
 256+#   al               bash             clojure          cpp              csharp           csharp_omnisharp
 257+#   dart             elixir           elm              erlang           fortran          go
 258+#   haskell          java             julia            kotlin           lua              markdown
 259+#   nix              perl             php              python           python_jedi      r
 260+#   rego             ruby             ruby_solargraph  rust             scala            swift
 261+#   terraform        typescript       typescript_vts   yaml             zig
 262+# Note:
 263+#   - For C, use cpp
 264+#   - For JavaScript, use typescript
 265+# Special requirements:
 266+#   - csharp: Requires the presence of a .sln file in the project folder.
 267+# When using multiple languages, the first language server that supports a given file will be used for that file.
 268+# The first language is the default language and the respective language server will be used as a fallback.
 269+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
 270+languages:
 271+- go
 272+
 273+# the encoding used by text files in the project
 274+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
 275+encoding: "utf-8"
 276+
 277+# whether to use the project's gitignore file to ignore files
 278+# Added on 2025-04-07
 279+ignore_all_files_in_gitignore: true
 280+
 281+# list of additional paths to ignore
 282+# same syntax as gitignore, so you can use * and **
 283+# Was previously called `ignored_dirs`, please update your config if you are using that.
 284+# Added (renamed) on 2025-04-07
 285+ignored_paths: []
 286+
 287+# whether the project is in read-only mode
 288+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
 289+# Added on 2025-04-18
 290+read_only: false
 291+
 292+# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
 293+# Below is the complete list of tools for convenience.
 294+# To make sure you have the latest list of tools, and to view their descriptions, 
 295+# execute `uv run scripts/print_tool_overview.py`.
 296+#
 297+#  * `activate_project`: Activates a project by name.
 298+#  * `check_onboarding_performed`: Checks whether project onboarding was already performed.
 299+#  * `create_text_file`: Creates/overwrites a file in the project directory.
 300+#  * `delete_lines`: Deletes a range of lines within a file.
 301+#  * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
 302+#  * `execute_shell_command`: Executes a shell command.
 303+#  * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
 304+#  * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
 305+#  * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
 306+#  * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
 307+#  * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
 308+#  * `initial_instructions`: Gets the initial instructions for the current project.
 309+#     Should only be used in settings where the system prompt cannot be set,
 310+#     e.g. in clients you have no control over, like Claude Desktop.
 311+#  * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
 312+#  * `insert_at_line`: Inserts content at a given line in a file.
 313+#  * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
 314+#  * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
 315+#  * `list_memories`: Lists memories in Serena's project-specific memory store.
 316+#  * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
 317+#  * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
 318+#  * `read_file`: Reads a file within the project directory.
 319+#  * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
 320+#  * `remove_project`: Removes a project from the Serena configuration.
 321+#  * `replace_lines`: Replaces a range of lines within a file with new content.
 322+#  * `replace_symbol_body`: Replaces the full definition of a symbol.
 323+#  * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
 324+#  * `search_for_pattern`: Performs a search for a pattern in the project.
 325+#  * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
 326+#  * `switch_modes`: Activates modes by providing a list of their names
 327+#  * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
 328+#  * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
 329+#  * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
 330+#  * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
 331+excluded_tools: []
 332+
 333+# initial prompt for the project. It will always be given to the LLM upon activating the project
 334+# (contrary to the memories, which are loaded on demand).
 335+initial_prompt: ""
 336+
 337+project_name: "api"
 338+included_optional_tools: []
 339diff --git a/CLAUDE.md b/CLAUDE.md
 340new file mode 100644
 341index 0000000000000000000000000000000000000000..db73210d744ef0634070948af15b21986d28c42d
 342--- /dev/null
 343+++ b/CLAUDE.md
 344@@ -0,0 +1 @@
 345+- Don't use serena for templ files, it only supports normal go code. Also don't attempt to edit the generated templ code
 346diff --git a/Dockerfile b/Dockerfile
 347index f2a485c8a83be315a44cccc0d7187e39606ba714..7c1fb95ad97376a0b6f7dc587292e33c939f6d7b 100644
 348--- a/Dockerfile
 349+++ b/Dockerfile
 350@@ -1,23 +1,30 @@
 351 FROM golang:1.25-trixie AS builder
 352 
 353-COPY go.mod go.sum /app/
 354 WORKDIR /app/
 355-RUN go mod download
 356+ENV GOCACHE="/cache"
 357+ENV PORT="80"
 358+
 359+COPY go.mod go.sum /app/
 360+RUN --mount=type=cache,target="/cache" go mod download
 361 
 362 COPY . /app/
 363-RUN go build -o dist/ytrssil-api cmd/main.go
 364+RUN --mount=type=cache,target="/cache" go build -o dist/ytrssil-api cmd/main.go
 365 
 366 FROM debian:trixie-slim AS api
 367 
 368 HEALTHCHECK --start-period=10s CMD exec curl -sf localhost:$PORT/healthz
 369-ENTRYPOINT ["/app/ytrssil-api"]
 370+WORKDIR /app/
 371+ENTRYPOINT ["./ytrssil-api"]
 372 
 373 RUN apt update \
 374 	&& apt install -y ca-certificates curl \
 375 	&& apt clean \
 376 	&& rm -rf /var/lib/apt/lists/*
 377 
 378-COPY --from=builder /app/dist/ /app/
 379+COPY ./assets/ ./assets/
 380+RUN assets/load.sh
 381+
 382+COPY --from=builder /app/dist/ ./
 383 
 384 FROM migrate/migrate:4 AS migrations
 385 COPY ./migrations/ /migrations/
 386diff --git a/Makefile b/Makefile
 387index 0639f09406195992dd31a434d77eb2ab43770106..695021b3ce8c3b978addd7df1ccd574a700f00d0 100644
 388--- a/Makefile
 389+++ b/Makefile
 390@@ -31,6 +31,7 @@ build:
 391 gen-mocks: bin/moq
 392 	./bin/moq -pkg db_mock -out ./mocks/db/db.go ./db DB
 393 	./bin/moq -pkg parser_mock -out ./mocks/feedparser/feedparser.go ./feedparser Parser
 394+	./bin/moq -pkg youtube_mock -out ./mocks/youtube/youtube.go ./lib/clients/youtube Client
 395 	go fmt ./...
 396 
 397 migrate: bin/migrate
 398diff --git a/api_docs.md b/api_docs.md
 399new file mode 100644
 400index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
 401--- /dev/null
 402+++ b/api_docs.md
 403diff --git a/assets/index.js b/assets/index.js
 404new file mode 100644
 405index 0000000000000000000000000000000000000000..a81869b6ee8d3aa66e8cf48e8432e578eebf5ad9
 406--- /dev/null
 407+++ b/assets/index.js
 408@@ -0,0 +1,67 @@
 409+document.addEventListener("DOMContentLoaded", function() {
 410+	const searchInput = document.getElementById("video-search");
 411+	if (!searchInput) return;
 412+
 413+	function performSearch() {
 414+		const videoCards = Array.from(document.querySelectorAll(".video-card"));
 415+		const query = searchInput.value.trim();
 416+
 417+		if (!query) {
 418+			videoCards.forEach((card) => (card.style.display = ""));
 419+			return;
 420+		}
 421+
 422+		const videos = videoCards.map((card) => ({
 423+			element: card,
 424+			title: card.dataset.title || "",
 425+			channelName: card.dataset.channelName || "",
 426+		}));
 427+
 428+		const fuse = new Fuse(videos, {
 429+			keys: ["title", "channelName"],
 430+			threshold: 0.4,
 431+			includeScore: true,
 432+		});
 433+
 434+		const results = fuse.search(query);
 435+		const resultElements = new Set(results.map((r) => r.item.element));
 436+
 437+		videoCards.forEach((card) => {
 438+			card.style.display = resultElements.has(card) ? "" : "none";
 439+		});
 440+	}
 441+
 442+	searchInput.addEventListener("input", performSearch);
 443+
 444+	if (searchInput.value.trim()) {
 445+		performSearch();
 446+	}
 447+});
 448+
 449+function addVideoHandler(event) {
 450+	if (event.detail.successful) {
 451+		bootstrap.Modal.getInstance(
 452+			document.getElementById("add-video-modal"),
 453+		).hide();
 454+	} else {
 455+		const field = event.detail.elt.querySelector(`[name="video_id"]`);
 456+		field.setCustomValidity(event.detail.xhr.responseText);
 457+		field.onfocus = () => field.reportValidity();
 458+		field.onchange = () => field.setCustomValidity("");
 459+		field.reportValidity();
 460+	}
 461+}
 462+
 463+function subscribeHandler(event) {
 464+	if (event.detail.successful) {
 465+		bootstrap.Modal.getInstance(
 466+			document.getElementById("subscription-modal"),
 467+		).hide();
 468+	} else {
 469+		const field = event.detail.elt.querySelector(`[name="channel_id"]`);
 470+		field.setCustomValidity(event.detail.xhr.responseText);
 471+		field.onfocus = field.reportValidity;
 472+		field.onchange = () => field.setCustomValidity("");
 473+		field.reportValidity();
 474+	}
 475+}
 476diff --git a/assets/load.sh b/assets/load.sh
 477new file mode 100755
 478index 0000000000000000000000000000000000000000..c0481677197fb5558c422f2c4c78d4a5b89799bf
 479--- /dev/null
 480+++ b/assets/load.sh
 481@@ -0,0 +1,12 @@
 482+set -e
 483+
 484+
 485+ASSETS_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
 486+
 487+mkdir -p "$ASSETS_DIR/vendor/fonts"
 488+curl -o "$ASSETS_DIR/vendor/bootstrap.min.css" https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css
 489+curl -o "$ASSETS_DIR/vendor/bootstrap.bundle.min.js" https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
 490+curl -o "$ASSETS_DIR/vendor/bootstrap-icons.min.css" https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css
 491+curl -o "$ASSETS_DIR/vendor/fonts/bootstrap-icons.woff2" "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a="
 492+curl -o "$ASSETS_DIR/vendor/fuse.js" https://cdn.jsdelivr.net/npm/fuse.js@7.0.0
 493+curl -o "$ASSETS_DIR/vendor/htmx.min.js" https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js
 494diff --git a/cmd/main.go b/cmd/main.go
 495index 72c0896cd168778afbcc4c4d250d5bfb9ae9673a..1c6d13c7cf76f354dfb295a6ef03f04364836b92 100644
 496--- a/cmd/main.go
 497+++ b/cmd/main.go
 498@@ -18,6 +18,7 @@ import (
 499 	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 500 	"github.com/TheEdgeOfRage/ytrssil-api/handler"
 501 	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
 502+	"github.com/TheEdgeOfRage/ytrssil-api/lib/clients/youtube"
 503 )
 504 
 505 func init() {
 506@@ -59,7 +60,8 @@ func main() {
 507 		return
 508 	}
 509 	parser := feedparser.NewParser(logger)
 510-	handler := handler.New(logger, cfg, db, parser)
 511+	youTubeClient := youtube.NewYouTubeClient(logger, cfg.YouTubeAPIKey)
 512+	handler := handler.New(logger, db, parser, youTubeClient)
 513 	if cfg.Dev {
 514 		gin.SetMode(gin.DebugMode)
 515 	} else {
 516diff --git a/db/channels.go b/db/channels.go
 517index d172deb2249a7756a4cda137174c2d5338c35750..8a2f20d4e052537992e01b2600938583afc0b6e3 100644
 518--- a/db/channels.go
 519+++ b/db/channels.go
 520@@ -2,26 +2,37 @@ package db
 521 
 522 import (
 523 	"context"
 524+	"fmt"
 525 
 526 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 527 )
 528 
 529 func (d *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
 530-	const query = `INSERT INTO channels (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`
 531-	resp, err := d.db.ExecContext(ctx, query, channel.ID, channel.Name)
 532+	const query = `
 533+		INSERT INTO channels (id, name, subscribed) VALUES ($1, $2, $3)
 534+		ON CONFLICT (id) DO UPDATE SET subscribed = $3
 535+	`
 536+	resp, err := d.db.ExecContext(ctx, query, channel.ID, channel.Name, channel.Subscribed)
 537 	if err != nil {
 538-		d.l.Error("Failed to create channel", "call", "sql.ExecContext", "error", err)
 539+		d.l.Error("Failed to subscribe to channel", "call", "sql.ExecContext", "error", err)
 540 		return err
 541 	}
 542-	if affected, _ := resp.RowsAffected(); affected == 0 {
 543-		return ErrChannelExists
 544+
 545+	rows, err := resp.RowsAffected()
 546+	if err != nil {
 547+		d.l.Error("Failed to subscribe to channel", "call", "sql.RowsAffected", "error", err)
 548+		return err
 549+	}
 550+	if rows == 0 {
 551+		d.l.Error("Failed to subscribe to channel, no rows affected", "call", "sql.RowsAffected")
 552+		return fmt.Errorf("failed to subscribe to channel")
 553 	}
 554 
 555 	return nil
 556 }
 557 
 558 func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error) {
 559-	const query = `SELECT id, name FROM channels`
 560+	const query = `SELECT id, name, subscribed FROM channels WHERE subscribed = true`
 561 	rows, err := d.db.QueryContext(ctx, query)
 562 	if err != nil {
 563 		d.l.Error("Failed to list channels", "call", "sql.QueryContext", "error", err)
 564@@ -32,7 +43,7 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
 565 	channels := make([]models.Channel, 0)
 566 	for rows.Next() {
 567 		var channel models.Channel
 568-		err = rows.Scan(&channel.ID, &channel.Name)
 569+		err = rows.Scan(&channel.ID, &channel.Name, &channel.Subscribed)
 570 		if err != nil {
 571 			d.l.Error("Failed to scan rows to list channels", "call", "sql.Scan", "error", err)
 572 			return nil, err
 573@@ -44,7 +55,7 @@ func (d *postgresDB) ListChannels(ctx context.Context) ([]models.Channel, error)
 574 }
 575 
 576 func (d *postgresDB) UnsubscribeFromChannel(ctx context.Context, channelID string) error {
 577-	const query = `DELETE FROM channels WHERE id = $1`
 578+	const query = `UPDATE channels SET subscribed = false WHERE id = $1`
 579 	resp, err := d.db.ExecContext(ctx, query, channelID)
 580 	if err != nil {
 581 		d.l.Error("Failed to unsubscribe from channel", "call", "sql.ExecContext", "error", err)
 582diff --git a/go.mod b/go.mod
 583index d99507e0ee034722b5dabf22374d8634956d1c74..c9e3534bd683893e4a41fb5c52dbb9753025bab3 100644
 584--- a/go.mod
 585+++ b/go.mod
 586@@ -4,6 +4,7 @@ go 1.25
 587 
 588 require (
 589 	github.com/a-h/templ v0.3.960
 590+	github.com/dustin/go-humanize v1.0.1
 591 	github.com/gin-gonic/gin v1.11.0
 592 	github.com/jessevdk/go-flags v1.6.1
 593 	github.com/lib/pq v1.10.9
 594diff --git a/go.sum b/go.sum
 595index ae873fe176a15ec20a1a0c42beb9955f35927458..94f5af57129632b0c0c5fe48d94eeef149562c4c 100644
 596--- a/go.sum
 597+++ b/go.sum
 598@@ -9,6 +9,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
 599 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 600 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 601 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 602+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 603+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 604 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 605 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
 606 github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
 607diff --git a/handler/channels.go b/handler/channels.go
 608index 1635f71c60166cb12ac82ca2d5f6dc93cf3a6e84..e513a9362c8e84900cdc4373609509c4945f41d5 100644
 609--- a/handler/channels.go
 610+++ b/handler/channels.go
 611@@ -15,8 +15,9 @@ func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) erro
 612 	}
 613 
 614 	channel := models.Channel{
 615-		ID:   channelID,
 616-		Name: parsedChannel.Name,
 617+		ID:         channelID,
 618+		Name:       parsedChannel.Name,
 619+		Subscribed: true,
 620 	}
 621 
 622 	err = h.db.SubscribeToChannel(ctx, channel)
 623diff --git a/handler/handler.go b/handler/handler.go
 624index e4858e633808da96d18d9f66a858c19ba3812b90..325043341e86d35165864d20094d781b9f6cd73e 100644
 625--- a/handler/handler.go
 626+++ b/handler/handler.go
 627@@ -4,9 +4,9 @@ import (
 628 	"context"
 629 	"log/slog"
 630 
 631-	"github.com/TheEdgeOfRage/ytrssil-api/config"
 632 	"github.com/TheEdgeOfRage/ytrssil-api/db"
 633 	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
 634+	"github.com/TheEdgeOfRage/ytrssil-api/lib/clients/youtube"
 635 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 636 )
 637 
 638@@ -19,25 +19,26 @@ type Handler interface {
 639 	MarkVideoAsWatched(ctx context.Context, videoID string) error
 640 	MarkVideoAsUnwatched(ctx context.Context, videoID string) error
 641 	SetVideoProgress(ctx context.Context, videoID string, progressTime string) (*models.Video, error)
 642+	AddCustomVideo(ctx context.Context, videoID string) error
 643 }
 644 
 645 type handler struct {
 646 	log           *slog.Logger
 647 	db            db.DB
 648 	parser        feedparser.Parser
 649-	youTubeAPIKey string
 650+	youTubeClient youtube.Client
 651 }
 652 
 653 func New(
 654 	log *slog.Logger,
 655-	cfg config.Config,
 656 	db db.DB,
 657 	parser feedparser.Parser,
 658+	youTubeClient youtube.Client,
 659 ) *handler {
 660 	return &handler{
 661 		log:           log,
 662 		db:            db,
 663 		parser:        parser,
 664-		youTubeAPIKey: cfg.YouTubeAPIKey,
 665+		youTubeClient: youTubeClient,
 666 	}
 667 }
 668diff --git a/handler/handler_test.go b/handler/handler_test.go
 669index d1b66eeaa454667aba060884e2f78ad4284e73bb..f45df88affb0d3dc5c3a6c211465bf998e91ae18 100644
 670--- a/handler/handler_test.go
 671+++ b/handler/handler_test.go
 672@@ -12,6 +12,7 @@ import (
 673 	"github.com/TheEdgeOfRage/ytrssil-api/config"
 674 	db_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/db"
 675 	parser_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/feedparser"
 676+	youtube_mock "github.com/TheEdgeOfRage/ytrssil-api/mocks/youtube"
 677 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 678 )
 679 
 680@@ -23,18 +24,23 @@ func init() {
 681 
 682 func TestGetNewVideos(t *testing.T) {
 683 	l := slog.New(slog.NewTextHandler(io.Discard, nil))
 684-	handler := New(l, testConfig, &db_mock.DBMock{
 685-		GetNewVideosFunc: func(ctx context.Context, _ bool) ([]models.Video, error) {
 686-			return []models.Video{
 687-				{
 688-					ID:            "test",
 689-					ChannelName:   "test",
 690-					Title:         "test",
 691-					PublishedTime: time.Now(),
 692-				},
 693-			}, nil
 694+	handler := New(
 695+		l,
 696+		&db_mock.DBMock{
 697+			GetNewVideosFunc: func(ctx context.Context, _ bool) ([]models.Video, error) {
 698+				return []models.Video{
 699+					{
 700+						ID:            "test",
 701+						ChannelName:   "test",
 702+						Title:         "test",
 703+						PublishedTime: time.Now(),
 704+					},
 705+				}, nil
 706+			},
 707 		},
 708-	}, &parser_mock.ParserMock{})
 709+		&parser_mock.ParserMock{},
 710+		&youtube_mock.ClientMock{},
 711+	)
 712 	resp, err := handler.GetNewVideos(context.TODO(), false)
 713 
 714 	if assert.NoError(t, err) {
 715diff --git a/handler/videos.go b/handler/videos.go
 716index 6cef6d64c69fc6a7c4debaf7e66e6af77702109d..645d523cd02643960993b327a8c98887f54db557 100644
 717--- a/handler/videos.go
 718+++ b/handler/videos.go
 719@@ -2,13 +2,9 @@ package handler
 720 
 721 import (
 722 	"context"
 723-	"encoding/json"
 724 	"errors"
 725 	"fmt"
 726-	"io"
 727 	"math"
 728-	"net/http"
 729-	"net/url"
 730 	"strings"
 731 	"sync"
 732 	"time"
 733@@ -28,79 +24,6 @@ func (h *handler) GetWatchedVideos(ctx context.Context, sortDesc bool) ([]models
 734 	return h.db.GetWatchedVideos(ctx, sortDesc)
 735 }
 736 
 737-type APIVideoListResponse struct {
 738-	Items []APIVideo `json:"items"`
 739-}
 740-
 741-type APIVideo struct {
 742-	ID             string            `json:"id"`
 743-	ContentDetails APIContentDetails `json:"contentDetails"`
 744-}
 745-
 746-type APIContentDetails struct {
 747-	Duration string `json:"duration"`
 748-}
 749-
 750-func (h *handler) getVideoDurations(ctx context.Context, videos map[string]*models.Video) error {
 751-	ids := strings.Builder{}
 752-	for id := range videos {
 753-		ids.WriteString(id)
 754-		ids.WriteString(",")
 755-	}
 756-	query := url.Values{}
 757-	query.Add("id", ids.String())
 758-	query.Add("part", "contentDetails")
 759-	query.Add("key", h.youTubeAPIKey)
 760-
 761-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
 762-	if err != nil {
 763-		return fmt.Errorf("failed to set up request: %w", err)
 764-	}
 765-	req.URL = &url.URL{
 766-		Scheme:   "https",
 767-		Host:     "www.googleapis.com",
 768-		Path:     "/youtube/v3/videos",
 769-		RawQuery: query.Encode(),
 770-	}
 771-
 772-	resp, err := http.DefaultClient.Do(req)
 773-	if err != nil {
 774-		return fmt.Errorf("failed to fetch video details: %w", err)
 775-	}
 776-	defer resp.Body.Close()
 777-
 778-	if resp.StatusCode != http.StatusOK {
 779-		body, err := io.ReadAll(resp.Body)
 780-		var bodyStr string
 781-		if err != nil {
 782-			h.log.Error("failed to decode error body", "error", err)
 783-			bodyStr = "failed to decode body"
 784-		} else {
 785-			bodyStr = string(body)
 786-		}
 787-		return fmt.Errorf("got non-200 status from YouTube API [%d]: %v", resp.StatusCode, bodyStr)
 788-	}
 789-
 790-	decoder := json.NewDecoder(resp.Body)
 791-	var respData APIVideoListResponse
 792-	err = decoder.Decode(&respData)
 793-	if err != nil {
 794-		return fmt.Errorf("failed to decode video details: %w", err)
 795-	}
 796-
 797-	for _, v := range respData.Items {
 798-		durationStr := strings.TrimPrefix(v.ContentDetails.Duration, "PT")
 799-		durationStr = strings.ToLower(durationStr)
 800-		duration, err := time.ParseDuration(durationStr)
 801-		if err != nil {
 802-			return fmt.Errorf("failed to parse video duration [%v]: %w", durationStr, err)
 803-		}
 804-		videos[v.ID].DurationSeconds = int(math.Round(duration.Seconds()))
 805-	}
 806-
 807-	return nil
 808-}
 809-
 810 func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedparser.Channel) {
 811 	var err error
 812 	videos := make(map[string]*models.Video, len(parsedChannel.Videos))
 813@@ -114,7 +37,7 @@ func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedpa
 814 		videoID := strings.Split(parsedVideo.ID, ":")[2]
 815 		exists, err := h.db.HasVideo(ctx, videoID)
 816 		if err != nil {
 817-			h.log.Error("Failed to save video to db", "call", "db.AddVideo", "err", err)
 818+			h.log.Error("Failed to check if video already exists", "call", "db.HasVideo", "err", err)
 819diff --git a/httpserver/ytrssil/api_setup_test.go b/httpserver/ytrssil/api_setup_test.go
 820index b35b191cc670eb6c32837c3d5c423e92256ffdfd..ca2feec515848e82681cff627709eb489733190d 100644
 821--- a/httpserver/ytrssil/api_setup_test.go
 822+++ b/httpserver/ytrssil/api_setup_test.go
 823@@ -14,7 +14,6 @@ import (
 824 
 825 	"github.com/TheEdgeOfRage/ytrssil-api/config"
 826 	"github.com/TheEdgeOfRage/ytrssil-api/handler"
 827-	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/auth"
 828 	"github.com/TheEdgeOfRage/ytrssil-api/httpserver/ytrssil"
 829 )
 830 
 831@@ -38,15 +37,10 @@ func (s *APITestSuite) SetupSuite() {
 832 	l := slog.New(slog.NewTextHandler(io.Discard, nil))
 833 	s.cfg = config.TestConfig()
 834 
 835-	handler := handler.New(l, s.cfg, nil, nil)
 836+	handler := handler.New(l, nil, nil, nil)
 837 
 838 	gin.SetMode(gin.TestMode)
 839-	router, err := ytrssil.SetupGinRouter(
 840-		l,
 841-		handler,
 842-		auth.APIAuthMiddleware(""),
 843-		auth.PageAuthMiddleware(""),
 844-	)
 845+	router, err := ytrssil.SetupGinRouter(l, s.cfg, handler)
 846 	s.Require().NoError(err)
 847 
 848 	s.server = &http.Server{
 849diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
 850index 9fedac0051644660d8b51f4388eee4c9034c9ebd..3a9ba0e9464603b60e05454c2a248586254958c2 100644
 851--- a/httpserver/ytrssil/channels.go
 852+++ b/httpserver/ytrssil/channels.go
 853@@ -18,7 +18,7 @@ func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
 854 			return
 855 		}
 856 		if errors.Is(err, feedparser.ErrInvalidChannelID) {
 857-			c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
 858+			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
 859 			return
 860 		}
 861 
 862diff --git a/httpserver/ytrssil/channels_pages.go b/httpserver/ytrssil/channels_pages.go
 863index 846e9f44918619ec648ad05275fc1eb4b0fcf69f..414f68d788ce133be6ffa150eaf2bdeb53743372 100644
 864--- a/httpserver/ytrssil/channels_pages.go
 865+++ b/httpserver/ytrssil/channels_pages.go
 866@@ -14,17 +14,17 @@ func (srv *server) SubscribeToChannelPage(c *gin.Context) {
 867 	err := srv.handler.SubscribeToChannel(c.Request.Context(), c.PostForm("channel_id"))
 868 	if err != nil {
 869 		if errors.Is(err, db.ErrAlreadySubscribed) {
 870-			c.String(http.StatusConflict, err.Error())
 871+			returnErr(c, http.StatusConflict, err)
 872 			return
 873 		}
 874 		if errors.Is(err, feedparser.ErrInvalidChannelID) {
 875-			c.Data(http.StatusNotFound, "text/html", []byte(err.Error()))
 876+			returnErr(c, http.StatusBadRequest, err)
 877 			return
 878 		}
 879 
 880-		c.String(http.StatusInternalServerError, err.Error())
 881+		returnErr(c, http.StatusInternalServerError, err)
 882 		return
 883 	}
 884 
 885-	c.String(http.StatusOK, "")
 886+	returnMsg(c, http.StatusAccepted, "")
 887 }
 888diff --git a/httpserver/ytrssil/error_helper.go b/httpserver/ytrssil/error_helper.go
 889new file mode 100644
 890index 0000000000000000000000000000000000000000..d19015669c5c2411ca4f30048e8bb5a271597476
 891--- /dev/null
 892+++ b/httpserver/ytrssil/error_helper.go
 893@@ -0,0 +1,13 @@
 894+package ytrssil
 895+
 896+import (
 897+	"github.com/gin-gonic/gin"
 898+)
 899+
 900+func returnErr(c *gin.Context, status int, err error) {
 901+	c.Data(status, "text/html", []byte(err.Error()))
 902+}
 903+
 904+func returnMsg(c *gin.Context, status int, msg string) {
 905+	c.Data(status, "text/html", []byte(msg))
 906+}
 907diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
 908index 741f40512373002009a10d95d9978420c8f34587..8c59f62b26792426b70e638d43bb0daff930bbc3 100644
 909--- a/httpserver/ytrssil/server.go
 910+++ b/httpserver/ytrssil/server.go
 911@@ -75,6 +75,7 @@ func SetupGinRouter(
 912 	{
 913 		pages.GET("/", srv.NewVideosPage)
 914 		pages.POST("/subscribe", srv.SubscribeToChannelPage)
 915+		pages.POST("/videos", srv.AddVideoPage)
 916 		pages.PATCH("/videos/:video_id/watch", srv.MarkVideoAsWatchedPage)
 917 		pages.PATCH("/videos/:video_id/progress", srv.SetVideoProgressPage)
 918 	}
 919diff --git a/httpserver/ytrssil/videos_pages.go b/httpserver/ytrssil/videos_pages.go
 920index 7e21e7a1645cdf0795300514f403c7d525af92ec..8200b219b93d771aa4c96dfdfab6683b74fb6c41 100644
 921--- a/httpserver/ytrssil/videos_pages.go
 922+++ b/httpserver/ytrssil/videos_pages.go
 923@@ -11,50 +11,57 @@ import (
 924 func (srv server) NewVideosPage(c *gin.Context) {
 925 	videos, err := srv.handler.GetNewVideos(c.Request.Context(), true)
 926 	if err != nil {
 927-		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
 928+		returnErr(c, http.StatusInternalServerError, err)
 929 		return
 930 	}
 931 
 932-	r := pages.TemplRenderer{
 933+	c.Render(http.StatusOK, pages.TemplRenderer{
 934 		Ctx:       c.Request.Context(),
 935 		Component: pages.NewVideosPage(videos),
 936-	}
 937-	c.Render(http.StatusOK, r)
 938+	})
 939 }
 940 
 941 func (srv server) MarkVideoAsWatchedPage(c *gin.Context) {
 942-	r := pages.TemplRenderer{
 943-		Ctx: c.Request.Context(),
 944-	}
 945-
 946 	err := srv.handler.MarkVideoAsWatched(c.Request.Context(), c.Param("video_id"))
 947 	if err != nil {
 948-		r.Component = pages.ErrorPage(err.Error())
 949-		c.Render(http.StatusInternalServerError, r)
 950+		returnErr(c, http.StatusInternalServerError, err)
 951 		return
 952 	}
 953 
 954-	c.String(http.StatusOK, "")
 955+	returnMsg(c, http.StatusOK, "")
 956 }
 957 
 958 func (srv server) SetVideoProgressPage(c *gin.Context) {
 959-	r := pages.TemplRenderer{
 960-		Ctx: c.Request.Context(),
 961-	}
 962 	progress := c.PostForm("progress")
 963 	if progress == "" {
 964-		r.Component = pages.ErrorPage("missing progress")
 965-		c.Render(http.StatusBadRequest, r)
 966+		returnMsg(c, http.StatusBadRequest, "missing progress")
 967 		return
 968 	}
 969 
 970 	video, err := srv.handler.SetVideoProgress(c.Request.Context(), c.Param("video_id"), progress)
 971 	if err != nil {
 972-		r.Component = pages.ErrorPage(err.Error())
 973-		c.Render(http.StatusInternalServerError, r)
 974+		returnErr(c, http.StatusInternalServerError, err)
 975+		return
 976+	}
 977+
 978+	c.Render(http.StatusOK, pages.TemplRenderer{
 979+		Ctx:       c.Request.Context(),
 980+		Component: pages.ProgressBar(*video),
 981+	})
 982+}
 983+
 984+func (srv server) AddVideoPage(c *gin.Context) {
 985+	videoID := c.PostForm("video_id")
 986+	if videoID == "" {
 987+		returnMsg(c, http.StatusBadRequest, "missing video ID")
 988+		return
 989+	}
 990+
 991+	err := srv.handler.AddCustomVideo(c.Request.Context(), videoID)
 992+	if err != nil {
 993+		returnErr(c, http.StatusInternalServerError, err)
 994 		return
 995 	}
 996 
 997-	r.Component = pages.ProgressBar(*video)
 998-	c.Render(http.StatusOK, r)
 999+	returnMsg(c, http.StatusAccepted, "")
1000 }
1001diff --git a/lib/clients/youtube/get_durations.go b/lib/clients/youtube/get_durations.go
1002new file mode 100644
1003index 0000000000000000000000000000000000000000..716198e5a6b2e9b4acac11c77614fd85acd1637a
1004--- /dev/null
1005+++ b/lib/clients/youtube/get_durations.go
1006@@ -0,0 +1,104 @@
1007+package youtube
1008+
1009+import (
1010+	"context"
1011+	"encoding/json"
1012+	"fmt"
1013+	"io"
1014+	"math"
1015+	"net/http"
1016+	"net/url"
1017+	"strings"
1018+	"time"
1019+
1020+	"github.com/TheEdgeOfRage/ytrssil-api/models"
1021+)
1022+
1023+type APIVideoListResponse struct {
1024+	Items []APIVideo `json:"items"`
1025+}
1026+
1027+type APIVideo struct {
1028+	ID             string            `json:"id"`
1029+	Snippet        APISnippet        `json:"snippet"`
1030+	ContentDetails APIContentDetails `json:"contentDetails"`
1031+	Player         APIPlayer         `json:"player"`
1032+}
1033+
1034+type APISnippet struct {
1035+	PublishedAt string `json:"publishedAt"`
1036+	ChannelID   string `json:"channelId"`
1037+	Title       string `json:"title"`
1038+	ChannelName string `json:"channelTitle"`
1039+}
1040+
1041+type APIPlayer struct {
1042+	EmbedHTML string `json:"embedHtml"`
1043+}
1044+
1045+type APIContentDetails struct {
1046+	Duration   string `json:"duration"`
1047+	Definition string `json:"definition"`
1048+}
1049+
1050+func (c *youTubeClient) GetVideoDurations(ctx context.Context, videos map[string]*models.Video) error {
1051+	ids := strings.Builder{}
1052+	for id := range videos {
1053+		ids.WriteString(id)
1054+		ids.WriteString(",")
1055+	}
1056+	videoIDs := ids.String()
1057+	query := url.Values{}
1058+	query.Add("id", videoIDs)
1059+	query.Add("part", "contentDetails")
1060+	query.Add("key", c.apiKey)
1061+
1062+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
1063+	if err != nil {
1064+		return fmt.Errorf("failed to set up request: %w", err)
1065+	}
1066+	req.URL = &url.URL{
1067+		Scheme:   "https",
1068+		Host:     "www.googleapis.com",
1069+		Path:     "/youtube/v3/videos",
1070+		RawQuery: query.Encode(),
1071+	}
1072+
1073+	c.log.Info("Making request to YouTube API for video durations", "videoIDs", videoIDs)
1074+	resp, err := http.DefaultClient.Do(req)
1075+	if err != nil {
1076+		return fmt.Errorf("failed to fetch video details: %w", err)
1077+	}
1078+	defer resp.Body.Close()
1079+
1080+	if resp.StatusCode != http.StatusOK {
1081+		body, err := io.ReadAll(resp.Body)
1082+		var bodyStr string
1083+		if err != nil {
1084+			c.log.Error("failed to decode error body", "error", err)
1085+			bodyStr = "failed to decode body"
1086+		} else {
1087+			bodyStr = string(body)
1088+		}
1089+		return fmt.Errorf("got non-200 status from YouTube API [%d]: %v", resp.StatusCode, bodyStr)
1090+	}
1091+
1092+	decoder := json.NewDecoder(resp.Body)
1093+	var respData APIVideoListResponse
1094+	err = decoder.Decode(&respData)
1095+	if err != nil {
1096+		return fmt.Errorf("failed to decode video details: %w", err)
1097+	}
1098+
1099+	for _, v := range respData.Items {
1100+		durationStr := strings.TrimPrefix(v.ContentDetails.Duration, "PT")
1101+		durationStr = strings.ToLower(durationStr)
1102+		duration, err := time.ParseDuration(durationStr)
1103+		if err != nil {
1104+			return fmt.Errorf("failed to parse video duration [%v]: %w", durationStr, err)
1105+		}
1106diff --git a/lib/clients/youtube/get_video_metadata.go b/lib/clients/youtube/get_video_metadata.go
1107new file mode 100644
1108index 0000000000000000000000000000000000000000..7f10aecbc1d1ead762bbb02af592229948b6da10
1109--- /dev/null
1110+++ b/lib/clients/youtube/get_video_metadata.go
1111@@ -0,0 +1,92 @@
1112+package youtube
1113+
1114+import (
1115+	"context"
1116+	"encoding/json"
1117+	"fmt"
1118+	"io"
1119+	"math"
1120+	"net/http"
1121+	"net/url"
1122+	"strings"
1123+	"time"
1124+
1125+	"github.com/TheEdgeOfRage/ytrssil-api/models"
1126+)
1127+
1128+func (c *youTubeClient) GetVideoMetadata(ctx context.Context, videoID string) (*models.Video, error) {
1129+	query := url.Values{}
1130+	query.Add("id", videoID)
1131+	query.Add("part", "snippet,contentDetails,player")
1132+	query.Add("key", c.apiKey)
1133+
1134+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
1135+	if err != nil {
1136+		return nil, fmt.Errorf("failed to set up request: %w", err)
1137+	}
1138+	req.URL = &url.URL{
1139+		Scheme:   "https",
1140+		Host:     "www.googleapis.com",
1141+		Path:     "/youtube/v3/videos",
1142+		RawQuery: query.Encode(),
1143+	}
1144+
1145+	c.log.Info("Making request to YouTube API for video metadata", "videoID", videoID)
1146+	resp, err := http.DefaultClient.Do(req)
1147+	if err != nil {
1148+		return nil, fmt.Errorf("failed to fetch video details: %w", err)
1149+	}
1150+	defer resp.Body.Close()
1151+
1152+	if resp.StatusCode != http.StatusOK {
1153+		body, err := io.ReadAll(resp.Body)
1154+		var bodyStr string
1155+		if err != nil {
1156+			c.log.Error("failed to decode error body", "error", err)
1157+			bodyStr = "failed to decode body"
1158+		} else {
1159+			bodyStr = string(body)
1160+		}
1161+		return nil, fmt.Errorf("got non-200 status from YouTube API [%d]: %v", resp.StatusCode, bodyStr)
1162+	}
1163+
1164+	decoder := json.NewDecoder(resp.Body)
1165+	var respData APIVideoListResponse
1166+	err = decoder.Decode(&respData)
1167+	if err != nil {
1168+		return nil, fmt.Errorf("failed to decode video details: %w", err)
1169+	}
1170+
1171+	if len(respData.Items) == 0 {
1172+		return nil, fmt.Errorf("video not found [%s]", videoID)
1173+	}
1174+
1175+	videoData := respData.Items[0]
1176+	video := &models.Video{
1177+		ID:          videoData.ID,
1178+		Title:       videoData.Snippet.Title,
1179+		ChannelName: videoData.Snippet.ChannelName,
1180+		ChannelID:   videoData.Snippet.ChannelID,
1181+	}
1182+
1183+	publishedTime, err := time.Parse(time.RFC3339, videoData.Snippet.PublishedAt)
1184+	if err != nil {
1185+		return nil, fmt.Errorf("failed to parse published time [%v]: %w", videoData.Snippet.PublishedAt, err)
1186+	}
1187+	video.PublishedTime = publishedTime
1188+
1189+	durationStr := strings.TrimPrefix(videoData.ContentDetails.Duration, "PT")
1190+	durationStr = strings.ToLower(durationStr)
1191+	duration, err := time.ParseDuration(durationStr)
1192+	if err != nil {
1193+		return nil, fmt.Errorf("failed to parse video duration [%v]: %w", durationStr, err)
1194+	}
1195+	video.DurationSeconds = int(math.Round(duration.Seconds()))
1196+
1197+	video.IsShort, err = c.isShort(ctx, videoID)
1198+	if err != nil {
1199+		return nil, err
1200+	}
1201+
1202+	return video, nil
1203+}
1204diff --git a/lib/clients/youtube/is_short.go b/lib/clients/youtube/is_short.go
1205new file mode 100644
1206index 0000000000000000000000000000000000000000..a331e6485c72720f555515f348e45c8047228107
1207--- /dev/null
1208+++ b/lib/clients/youtube/is_short.go
1209@@ -0,0 +1,33 @@
1210+package youtube
1211+
1212+import (
1213+	"context"
1214+	"fmt"
1215+	"net/http"
1216+)
1217+
1218+func (c *youTubeClient) isShort(ctx context.Context, videoID string) (bool, error) {
1219+	url := fmt.Sprintf("https://www.youtube.com/shorts/%s", videoID)
1220+	req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
1221+	if err != nil {
1222+		c.log.Error(
1223+			"Failed to create request to check if a video is a short",
1224+			"videoID", videoID,
1225+			"error", err,
1226+		)
1227+		return false, err
1228+	}
1229+
1230+	client := &http.Client{
1231+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
1232+			return http.ErrUseLastResponse
1233+		},
1234+	}
1235+	resp, err := client.Do(req)
1236+	if err != nil {
1237+		c.log.Error("Failed to check if a video is a short", "videoID", videoID, "error", err)
1238+		return false, err
1239+	}
1240+
1241+	return resp.StatusCode == http.StatusOK, nil
1242+}
1243diff --git a/lib/clients/youtube/youtube.go b/lib/clients/youtube/youtube.go
1244new file mode 100644
1245index 0000000000000000000000000000000000000000..703fae7a0e43b8d49b0b3d36569678211a0c5aab
1246--- /dev/null
1247+++ b/lib/clients/youtube/youtube.go
1248@@ -0,0 +1,27 @@
1249+package youtube
1250+
1251+import (
1252+	"context"
1253+	"log/slog"
1254+
1255+	"github.com/TheEdgeOfRage/ytrssil-api/models"
1256+)
1257+
1258+type Client interface {
1259+	GetVideoDurations(ctx context.Context, videos map[string]*models.Video) error
1260+	GetVideoMetadata(ctx context.Context, videoID string) (*models.Video, error)
1261+}
1262+
1263+type youTubeClient struct {
1264+	log    *slog.Logger
1265+	apiKey string
1266+}
1267+
1268+var _ Client = (*youTubeClient)(nil)
1269+
1270+func NewYouTubeClient(log *slog.Logger, apiKey string) *youTubeClient {
1271+	return &youTubeClient{
1272+		log:    log,
1273+		apiKey: apiKey,
1274+	}
1275+}
1276diff --git a/migrations/000005_add_subscribed_column.down.sql b/migrations/000005_add_subscribed_column.down.sql
1277new file mode 100644
1278index 0000000000000000000000000000000000000000..099bd131aefd3e1d30430193251fb49598809e88
1279--- /dev/null
1280+++ b/migrations/000005_add_subscribed_column.down.sql
1281@@ -0,0 +1 @@
1282+ALTER TABLE channels DROP COLUMN IF EXISTS subscribed;
1283diff --git a/migrations/000005_add_subscribed_column.up.sql b/migrations/000005_add_subscribed_column.up.sql
1284new file mode 100644
1285index 0000000000000000000000000000000000000000..edfdcb6338bcc019bff058d93ec40210abe6e556
1286--- /dev/null
1287+++ b/migrations/000005_add_subscribed_column.up.sql
1288@@ -0,0 +1 @@
1289+ALTER TABLE channels ADD COLUMN IF NOT EXISTS subscribed boolean NOT NULL DEFAULT true;
1290diff --git a/mocks/youtube/youtube.go b/mocks/youtube/youtube.go
1291new file mode 100644
1292index 0000000000000000000000000000000000000000..c9e8ee933be56d9f0f648d072f7f975675eb8b39
1293--- /dev/null
1294+++ b/mocks/youtube/youtube.go
1295@@ -0,0 +1,133 @@
1296+// Code generated by moq; DO NOT EDIT.
1297+// github.com/matryer/moq
1298+
1299+package youtube_mock
1300+
1301+import (
1302+	"context"
1303+	"github.com/TheEdgeOfRage/ytrssil-api/lib/clients/youtube"
1304+	"github.com/TheEdgeOfRage/ytrssil-api/models"
1305+	"sync"
1306+)
1307+
1308+// Ensure, that ClientMock does implement youtube.Client.
1309+// If this is not the case, regenerate this file with moq.
1310+var _ youtube.Client = &ClientMock{}
1311+
1312+// ClientMock is a mock implementation of youtube.Client.
1313+//
1314+//	func TestSomethingThatUsesClient(t *testing.T) {
1315+//
1316+//		// make and configure a mocked youtube.Client
1317+//		mockedClient := &ClientMock{
1318+//			GetVideoDurationsFunc: func(ctx context.Context, videos map[string]*models.Video) error {
1319+//				panic("mock out the GetVideoDurations method")
1320+//			},
1321+//			GetVideoMetadataFunc: func(ctx context.Context, videoID string) (*models.Video, error) {
1322+//				panic("mock out the GetVideoMetadata method")
1323+//			},
1324+//		}
1325+//
1326+//		// use mockedClient in code that requires youtube.Client
1327+//		// and then make assertions.
1328+//
1329+//	}
1330+type ClientMock struct {
1331+	// GetVideoDurationsFunc mocks the GetVideoDurations method.
1332+	GetVideoDurationsFunc func(ctx context.Context, videos map[string]*models.Video) error
1333+
1334+	// GetVideoMetadataFunc mocks the GetVideoMetadata method.
1335+	GetVideoMetadataFunc func(ctx context.Context, videoID string) (*models.Video, error)
1336+
1337+	// calls tracks calls to the methods.
1338+	calls struct {
1339+		// GetVideoDurations holds details about calls to the GetVideoDurations method.
1340+		GetVideoDurations []struct {
1341+			// Ctx is the ctx argument value.
1342+			Ctx context.Context
1343+			// Videos is the videos argument value.
1344+			Videos map[string]*models.Video
1345+		}
1346+		// GetVideoMetadata holds details about calls to the GetVideoMetadata method.
1347+		GetVideoMetadata []struct {
1348+			// Ctx is the ctx argument value.
1349+			Ctx context.Context
1350+			// VideoID is the videoID argument value.
1351+			VideoID string
1352+		}
1353+	}
1354+	lockGetVideoDurations sync.RWMutex
1355+	lockGetVideoMetadata  sync.RWMutex
1356+}
1357+
1358+// GetVideoDurations calls GetVideoDurationsFunc.
1359+func (mock *ClientMock) GetVideoDurations(ctx context.Context, videos map[string]*models.Video) error {
1360+	if mock.GetVideoDurationsFunc == nil {
1361+		panic("ClientMock.GetVideoDurationsFunc: method is nil but Client.GetVideoDurations was just called")
1362+	}
1363+	callInfo := struct {
1364+		Ctx    context.Context
1365+		Videos map[string]*models.Video
1366+	}{
1367+		Ctx:    ctx,
1368+		Videos: videos,
1369+	}
1370+	mock.lockGetVideoDurations.Lock()
1371+	mock.calls.GetVideoDurations = append(mock.calls.GetVideoDurations, callInfo)
1372+	mock.lockGetVideoDurations.Unlock()
1373+	return mock.GetVideoDurationsFunc(ctx, videos)
1374+}
1375+
1376+// GetVideoDurationsCalls gets all the calls that were made to GetVideoDurations.
1377+// Check the length with:
1378+//
1379+//	len(mockedClient.GetVideoDurationsCalls())
1380+func (mock *ClientMock) GetVideoDurationsCalls() []struct {
1381+	Ctx    context.Context
1382+	Videos map[string]*models.Video
1383+} {
1384+	var calls []struct {
1385+		Ctx    context.Context
1386+		Videos map[string]*models.Video
1387+	}
1388+	mock.lockGetVideoDurations.RLock()
1389+	calls = mock.calls.GetVideoDurations
1390+	mock.lockGetVideoDurations.RUnlock()
1391+	return calls
1392+}
1393+
1394+// GetVideoMetadata calls GetVideoMetadataFunc.
1395diff --git a/models/channel.go b/models/channel.go
1396index f5e91df34c2ce4e04366733a7c223cab3076606c..04a91be2ad8e4ba4f0a3324bdd491e96e7d9e281 100644
1397--- a/models/channel.go
1398+++ b/models/channel.go
1399@@ -5,4 +5,6 @@ type Channel struct {
1400 	ID string `json:"channel_id"`
1401 	// Name of the channel
1402 	Name string `json:"name"`
1403+	// Subscribed indicates if the user is subscribed to this channel
1404+	Subscribed bool `json:"subscribed"`
1405 }
1406diff --git a/models/video.go b/models/video.go
1407index 7ad73c99783e4df54fdd1aa2f5e8e77c31cf16ce..2c188524c43ada991876ebcac5b33157716affef 100644
1408--- a/models/video.go
1409+++ b/models/video.go
1410@@ -1,8 +1,11 @@
1411 package models
1412 
1413 import (
1414+	"fmt"
1415 	"math"
1416 	"time"
1417+
1418+	"github.com/dustin/go-humanize"
1419 )
1420 
1421 type Video struct {
1422@@ -26,10 +29,31 @@ type Video struct {
1423 	IsShort bool `json:"short"`
1424 }
1425 
1426+// ProgressPercentage returns the current progress of the video as an integer from 0-100
1427 func (v Video) ProgressPercentage() int {
1428 	return int(math.Floor(100 * float64(v.ProgressSeconds) / float64(v.DurationSeconds)))
1429 }
1430 
1431+// Duration returns the total duration of the video in the hh:mm:ss format
1432+func (v Video) Duration() string {
1433+	duration := time.Duration(v.DurationSeconds) * time.Second
1434+	duration = duration.Round(time.Second)
1435+	h := duration / time.Hour
1436+	duration -= h * time.Hour
1437+	m := duration / time.Minute
1438+	duration -= m * time.Minute
1439+	s := duration / time.Second
1440+
1441+	if h > 0 {
1442+		return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
1443+	}
1444+	return fmt.Sprintf("%02d:%02d", m, s)
1445+}
1446+
1447+func (v Video) HumanizedPublishTime() string {
1448+	return humanize.Time(v.PublishedTime)
1449+}
1450+
1451 type PaginatedVideos struct {
1452 	Videos     []Video
1453 	NextOffset int
1454diff --git a/pages/add_video_modal.templ b/pages/add_video_modal.templ
1455new file mode 100644
1456index 0000000000000000000000000000000000000000..089ef7f84913d4bc39884bd1272a7e2a2d5c3afc
1457--- /dev/null
1458+++ b/pages/add_video_modal.templ
1459@@ -0,0 +1,33 @@
1460+
1461+package pages
1462+
1463+templ addVideoModal() {
1464+	<div class="modal fade" id="add-video-modal" tabindex="-1">
1465+		<div class="modal-dialog">
1466+			<div class="modal-content">
1467+				<div class="modal-header">
1468+					<h1 class="modal-title fs-5">Add video to list</h1>
1469+					<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
1470+				</div>
1471+				<div class="modal-body">
1472+					<form
1473+						id="add-video-form"
1474+						class="d-flex"
1475+						hx-post="/videos"
1476+						hx-swap="none"
1477+						hx-on::after-request="addVideoHandler(event)"
1478+					>
1479+						<input
1480+							type="text"
1481+							name="video_id"
1482+							placeholder="Video ID"
1483+							class="form-control"
1484+							autocomplete="off"
1485+						/>
1486+						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-bookmark-plus"></i></button>
1487+					</form>
1488+				</div>
1489+			</div>
1490+		</div>
1491+	</div>
1492+}
1493diff --git a/pages/add_video_modal_templ.go b/pages/add_video_modal_templ.go
1494new file mode 100644
1495index 0000000000000000000000000000000000000000..ce7e8e36e38b7daa9dcdcf94bcb50a9c9e7b5b2f
1496--- /dev/null
1497+++ b/pages/add_video_modal_templ.go
1498@@ -0,0 +1,41 @@
1499+// Code generated by templ - DO NOT EDIT.
1500+
1501+// templ: version: v0.3.960
1502+
1503+package pages
1504+
1505+//lint:file-ignore SA4006 This context is only used if a nested component is present.
1506+
1507+import "github.com/a-h/templ"
1508+import templruntime "github.com/a-h/templ/runtime"
1509+
1510+func addVideoModal() templ.Component {
1511+	return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1512+		templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1513+		if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1514+			return templ_7745c5c3_CtxErr
1515+		}
1516+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1517+		if !templ_7745c5c3_IsBuffer {
1518+			defer func() {
1519+				templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1520+				if templ_7745c5c3_Err == nil {
1521+					templ_7745c5c3_Err = templ_7745c5c3_BufErr
1522+				}
1523+			}()
1524+		}
1525+		ctx = templ.InitializeContext(ctx)
1526+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
1527+		if templ_7745c5c3_Var1 == nil {
1528+			templ_7745c5c3_Var1 = templ.NopComponent
1529+		}
1530+		ctx = templ.ClearChildren(ctx)
1531diff --git a/pages/main.templ b/pages/main.templ
1532index 26e6416b6f737ade10b8fe2fc9d353057c2ae58b..0a8cbf681b52a466dbffaec2038ecb4255e9a041 100644
1533--- a/pages/main.templ
1534+++ b/pages/main.templ
1535@@ -7,27 +7,23 @@ templ BaseLayout(title string) {
1536 			<title>{ title }</title>
1537 			<meta charset="utf-8"/>
1538 			<meta name="viewport" content="width=device-width, initial-scale=1"/>
1539-			<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"/>
1540-			<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css"/>
1541+			<link rel="icon" type="image/png" href="/assets/ytrssil.png"/>
1542+			<link rel="stylesheet" href="/assets/vendor/bootstrap.min.css"/>
1543+			<link rel="stylesheet" href="/assets/vendor/bootstrap-icons.min.css"/>
1544 			<style>
1545 				.pillarbox { position: relative; }
1546 				.pillarbox::after { right: 0; }
1547 				.pillarbox::before,
1548 				.pillarbox::after { content: ''; position: absolute; top: 0; bottom: 0%; width: 36%; background: black; z-index: 1; }
1549 			</style>
1550-			<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
1551-			<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
1552-			<script>
1553-				document.addEventListener('htmx:responseError', event => {
1554-					const field = event.detail.elt.querySelector(`[name="channel_id"]`);
1555-					field.setCustomValidity(event.detail.xhr.responseText);
1556-					field.onchange = () => field.setCustomValidity('');
1557-					field.reportValidity();
1558-				});
1559-			</script>
1560+			<script src="/assets/vendor/bootstrap.bundle.min.js"></script>
1561+			<script src="/assets/vendor/htmx.min.js"></script>
1562+			<script src="/assets/vendor/fuse.js"></script>
1563+			<script src="/assets/index.js"></script>
1564 		</head>
1565 		<body>
1566 			@subscriptionModal()
1567+			@addVideoModal()
1568 			@navbar()
1569 			<div class="container-fluid">
1570 				{ children... }
1571diff --git a/pages/main_templ.go b/pages/main_templ.go
1572index 08d84d4529f145f2f4fffa86d39bfd8c95e19509..2afb136a3f297e06a262f6425d6cd796c3186e6f 100644
1573--- a/pages/main_templ.go
1574+++ b/pages/main_templ.go
1575@@ -42,7 +42,7 @@ func BaseLayout(title string) templ.Component {
1576 		if templ_7745c5c3_Err != nil {
1577 			return templ_7745c5c3_Err
1578 		}
1579diff --git a/pages/navbar.templ b/pages/navbar.templ
1580index ea51178e20a4b15cc803d155e0081efbe1f25e56..950e09bc795ed55887b76b507cf9d023d89f43bb 100644
1581--- a/pages/navbar.templ
1582+++ b/pages/navbar.templ
1583@@ -22,13 +22,20 @@ templ navbar() {
1584 							class="btn btn-primary"
1585 							hx-post="/fetch"
1586 							hx-swap="none"
1587-						>Fetch <i class="bi bi-cloud-download"></i></button>
1588+							hx-on::after-settle="console.log(`AAAAA`); window.location.reload()"
1589+						>
1590+							Fetch <i class="bi bi-cloud-download"></i>
1591+						</button>
1592+					</li>
1593+					<li class="nav-item me-md-2 mt-2 mt-md-0">
1594+						<button type="button" data-bs-toggle="modal" data-bs-target="#add-video-modal" class="btn">
1595+							Add Video <i class="bi bi-camera-video"></i>
1596+						</button>
1597 					</li>
1598 				</ul>
1599-				<form class="d-flex" role="search">
1600-					<input class="form-control me-2" type="search" placeholder="Search"/>
1601-					<button class="btn btn-outline-success" type="submit">Search</button>
1602-				</form>
1603+				<div class="d-flex" role="search">
1604+					<input id="video-search" class="form-control" type="search" placeholder="Search videos..."/>
1605+				</div>
1606 			</div>
1607 		</div>
1608 	</nav>
1609diff --git a/pages/navbar_templ.go b/pages/navbar_templ.go
1610index f5a6e31c1b686999c0cf5b577f01df4984222530..9f020fc85b1250d27ecd7e30f90982a5631ca897 100644
1611--- a/pages/navbar_templ.go
1612+++ b/pages/navbar_templ.go
1613@@ -29,7 +29,7 @@ func navbar() templ.Component {
1614 			templ_7745c5c3_Var1 = templ.NopComponent
1615 		}
1616 		ctx = templ.ClearChildren(ctx)
1617diff --git a/pages/new_videos.templ b/pages/new_videos.templ
1618index 2986d63f05193530a896d2d51a2fb2dda000f3a1..9e94871806293a9a6a650f7b106f1b964d1c3b1e 100644
1619--- a/pages/new_videos.templ
1620+++ b/pages/new_videos.templ
1621@@ -20,7 +20,7 @@ templ thumbnail(video models.Video) {
1622 	<a
1623 		target="blank"
1624 		href={ templ.SafeURL(fmt.Sprintf("https://youtube.com/watch?v=%s", video.ID)) }
1625-		style="display: block; overflow: hidden;"
1626+		style="display: block; overflow: hidden; position: relative"
1627 		if video.IsShort {
1628 			class="pillarbox"
1629 		}
1630@@ -38,7 +38,7 @@ templ thumbnail(video models.Video) {
1631 				viewBox="0 0 24 24"
1632 				width="24"
1633 				focusable="false"
1634-				style="pointer-events: none; width: 2rem; height: 2rem; z-index: 2; position: absolute; bottom: 0.5rem; right: 0.5rem;"
1635+				style="pointer-events: none; width: 2rem; height: 2rem; z-index: 2; position: absolute; bottom: 0.5rem; left: 0.5rem;"
1636 				fill="white"
1637 			><path d="m13.974 2.052-8 4.7a4 4 0 00.385 7.097l.942.423-1.327.78a4 4 0 004.052 6.897l8-4.7a4.001 4.001 0 00-.384-7.096L16.7 9.73l1.326-.78a4 4 0 10-4.052-6.897ZM10 15V9l5 3-5 3Z"></path></svg>
1638 		} else {
1639@@ -49,6 +49,14 @@ templ thumbnail(video models.Video) {
1640 				style="width: 100%; aspect-ratio: 16/9; object-fit: cover;"
1641 			/>
1642 		}
1643+		<span
1644+			focusable="false"
1645+			class="text-body bg-body bg-opacity-75 px-1 py-1 rounded"
1646+			style="pointer-events: none;  z-index: 2; position: absolute; bottom: 0.2rem; right: 0.2rem;"
1647+			fill="white"
1648+		>
1649+			{ video.Duration() }
1650+		</span>
1651 	</a>
1652 	@ProgressBar(video)
1653 }
1654@@ -58,12 +66,15 @@ templ videoCard(video models.Video) {
1655 		@thumbnail(video)
1656 		<div class="card-body">
1657 			<p class="card-title text-truncate mb-0">{ video.Title }</p>
1658-			<p>
1659+			<p class="d-flex justify-content-between">
1660 				<a
1661 					target="blank"
1662 					href={ fmt.Sprintf("https://www.youtube.com/channel/%s", video.ChannelID) }
1663 					class="card-text fw-light link-light link-underline-opacity-0"
1664 				>{ video.ChannelName }</a>
1665+				<span>
1666+					{ video.HumanizedPublishTime() }
1667+				</span>
1668 			</p>
1669 			<div class="d-flex justify-content-between">
1670 				<form
1671@@ -92,7 +103,7 @@ templ NewVideosPage(videos []models.Video) {
1672 	@BaseLayout("ytrssil - New Videos") {
1673 		<div class="row">
1674 			for _, video := range videos {
1675-				<div class="video-card col-md-3 p-2">
1676+				<div class="video-card col-md-3 p-2" data-title={ video.Title } data-channel-name={ video.ChannelName }>
1677 					@videoCard(video)
1678 				</div>
1679 			}
1680diff --git a/pages/new_videos_templ.go b/pages/new_videos_templ.go
1681index b7847bb26332426799c1cc044035cfad4a90b401..482bd65ac496e15fe3ea13c7a0a2508190b86edd 100644
1682--- a/pages/new_videos_templ.go
1683+++ b/pages/new_videos_templ.go
1684@@ -123,7 +123,7 @@ func thumbnail(video models.Video) templ.Component {
1685 		if templ_7745c5c3_Err != nil {
1686 			return templ_7745c5c3_Err
1687 		}
1688-		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"display: block; overflow: hidden;\"")
1689+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" style=\"display: block; overflow: hidden; position: relative\"")
1690 		if templ_7745c5c3_Err != nil {
1691 			return templ_7745c5c3_Err
1692 		}
1693@@ -164,7 +164,7 @@ func thumbnail(video models.Video) templ.Component {
1694 			if templ_7745c5c3_Err != nil {
1695 				return templ_7745c5c3_Err
1696 			}
1697diff --git a/pages/subscription.templ b/pages/subscription.templ
1698index 22cff9553caf6e46e025b72f65810ecde3a606de..95ec301724102109474e2e0a86922f15f854f250 100644
1699--- a/pages/subscription.templ
1700+++ b/pages/subscription.templ
1701@@ -10,16 +10,21 @@ templ subscriptionModal() {
1702 				</div>
1703 				<div class="modal-body">
1704 					<form
1705-						hx-post="/subscribe"
1706-						hx-target="#subscription-error"
1707-						hx-swap="innerHTML"
1708-						hx-on::after-request="if(event.detail.successful) bootstrap.Modal.getInstance(document.getElementById('subscription-modal')).hide()"
1709+						id="subscription-form"
1710 						class="d-flex"
1711+						hx-post="/subscribe"
1712+						hx-swap="none"
1713+						hx-on::after-request="subscribeHandler(event)"
1714 					>
1715-						<input type="text" name="channel_id" placeholder="Channel username or ID" class="form-control"/>
1716-						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-plus"></i></button>
1717+						<input
1718+							type="text"
1719+							name="channel_id"
1720+							placeholder="Channel ID"
1721+							class="form-control"
1722+							autocomplete="off"
1723+						/>
1724+						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-bookmark-plus"></i></button>
1725 					</form>
1726-					<div id="subscription-error" class="text-danger mt-2 ms-1"></div>
1727 				</div>
1728 			</div>
1729 		</div>
1730diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
1731index 8c805fd26fe4809a0d5ae5fddcc0951face4f8f7..dc38f8bc977871275caea2b22425def7186960cb 100644
1732--- a/pages/subscription_templ.go
1733+++ b/pages/subscription_templ.go
1734@@ -29,7 +29,7 @@ func subscriptionModal() templ.Component {
1735 			templ_7745c5c3_Var1 = templ.NopComponent
1736 		}
1737 		ctx = templ.ClearChildren(ctx)