49e1f0ad99c8b254467a39fc1e9ff234d464efc4
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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)