7b840bf352765542a39410fddb03eed7bc77afef

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

Message

Accept channel name alongside channel ID

Diff

This diff is truncated to protect this page.

  1diff --git a/CLAUDE.md b/AGENTS.md
  2rename from CLAUDE.md
  3rename to AGENTS.md
  4index 0dcd946a8f1875ce7916e7c6119898dee31e1f82..760c1091185701b65732a071cd566b35f2c2ea69 100644
  5--- a/CLAUDE.md
  6+++ b/AGENTS.md
  7@@ -1,4 +1,3 @@
  8-- Don't use serena for templ files, it only supports normal go code.
  9 - Don't attempt to edit the generated templ code
 10 - Don't invoke ANY go commands other than `go vet` and `go mod tidy`
 11 
 12@@ -9,6 +8,7 @@
 13 **Tech Stack**: Go 1.25, Gin (HTTP), Templ (SSR), PostgreSQL (pgx), yt-dlp (downloads), HTMX (frontend interactivity), Bootstrap 5 (UI)
 14 
 15 **Layers**:
 16+
 17 - **HTTP**: Gin router with dual interfaces (HTML pages + JSON API), authentication middleware, HTMX-enabled templates
 18 - **Handler**: Business logic layer orchestrating channels, videos, downloads, and cleanup routines
 19 - **Database**: PostgreSQL with pgx connection pooling, 7 migrations tracking schema evolution
 20diff --git a/Makefile b/Makefile
 21index 0cf26b8c85197a30346806b009f6a13dd080ab68..8ab24758a78ad7286c70e1bc79eba359db92aae0 100644
 22--- a/Makefile
 23+++ b/Makefile
 24@@ -7,9 +7,9 @@ bin:
 25 bin/moq: bin
 26 	GOBIN=$(PWD)/bin go install github.com/matryer/moq@v0.6.0
 27 bin/golangci-lint: bin
 28-	GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9.0
 29+	GOBIN=$(PWD)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.1
 30 bin/migrate: bin
 31-	GOBIN=$(PWD)/bin go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.0
 32+	GOBIN=$(PWD)/bin go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
 33 bin/air: bin
 34 	GOBIN=$(PWD)/bin go install github.com/air-verse/air@v1.64.5
 35 
 36diff --git a/handler/channels.go b/handler/channels.go
 37index 8090d476cfa60615eccbe10a63c9ca4ec050d7ef..5008671a62edf7b5d35bc4f94a22cd42076d5fb5 100644
 38--- a/handler/channels.go
 39+++ b/handler/channels.go
 40@@ -3,12 +3,31 @@ package handler
 41 import (
 42 	"context"
 43 	"errors"
 44+	"strings"
 45 
 46 	"github.com/TheEdgeOfRage/ytrssil-api/db"
 47 	"github.com/TheEdgeOfRage/ytrssil-api/models"
 48 )
 49 
 50+// isChannelID reports whether s looks like a raw YouTube channel ID (UCxxxxxxxx…).
 51+func isChannelID(s string) bool {
 52+	return strings.HasPrefix(s, "UC") && len(s) == 24
 53+}
 54+
 55 func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) (*models.Channel, error) {
 56+	if !isChannelID(channelID) {
 57+		// Treat input as a handle; normalise to @handle form for the API.
 58+		handle := channelID
 59+		if !strings.HasPrefix(handle, "@") {
 60+			handle = "@" + handle
 61+		}
 62+		resolved, err := h.youTubeClient.ResolveChannelID(ctx, handle)
 63+		if err != nil {
 64+			return nil, err
 65+		}
 66+		channelID = resolved
 67+	}
 68+
 69 	parsedChannel, err := h.parser.Parse(channelID)
 70 	if err != nil {
 71 		return nil, err
 72diff --git a/httpserver/ytrssil/suite_test.go b/httpserver/ytrssil/suite_test.go
 73index 6c3976554ccee258978a39d55c4bbfea348df2e6..399853a7ae6a938ce913b369efd55cea3c3dfc9c 100644
 74--- a/httpserver/ytrssil/suite_test.go
 75+++ b/httpserver/ytrssil/suite_test.go
 76@@ -7,6 +7,7 @@ import (
 77 	"log/slog"
 78 	"net/http"
 79 	"os/exec"
 80+	"strings"
 81 	"time"
 82 
 83 	"github.com/gin-gonic/gin"
 84@@ -106,6 +107,9 @@ func (s *EndpointsTestSuite) SetupSuite() {
 85 		GetChannelImageURLFunc: func(ctx context.Context, channelID string) (string, error) {
 86 			return fmt.Sprintf("https://example.com/%s.jpg", channelID), nil
 87 		},
 88+		ResolveChannelIDFunc: func(ctx context.Context, handle string) (string, error) {
 89+			return strings.TrimPrefix(handle, "@"), nil
 90+		},
 91 	}
 92 
 93 	h := handler.New(l, s.db, s.parser, s.youtubeClient, nil, s.cfg.DownloadsDir)
 94diff --git a/lib/clients/youtube/resolve_channel_id.go b/lib/clients/youtube/resolve_channel_id.go
 95new file mode 100644
 96index 0000000000000000000000000000000000000000..e226b0912f528b42e0b224434a01f41b7504199d
 97--- /dev/null
 98+++ b/lib/clients/youtube/resolve_channel_id.go
 99@@ -0,0 +1,64 @@
100+package youtube
101+
102+import (
103+	"context"
104+	"encoding/json"
105+	"fmt"
106+	"io"
107+	"net/http"
108+	"net/url"
109+)
110+
111+type apiChannelIDResponse struct {
112+	Items []struct {
113+		ID string `json:"id"`
114+	} `json:"items"`
115+}
116+
117+func (c *youTubeClient) ResolveChannelID(ctx context.Context, handle string) (string, error) {
118+	query := url.Values{}
119+	query.Add("forHandle", handle)
120+	query.Add("part", "id")
121+	query.Add("fields", "items/id")
122+	query.Add("key", c.apiKey)
123+
124+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
125+	if err != nil {
126+		return "", fmt.Errorf("failed to set up request: %w", err)
127+	}
128+	req.URL = &url.URL{
129+		Scheme:   "https",
130+		Host:     "www.googleapis.com",
131+		Path:     "/youtube/v3/channels",
132+		RawQuery: query.Encode(),
133+	}
134+
135+	c.log.Info("Resolving channel handle to ID", "handle", handle)
136+	resp, err := http.DefaultClient.Do(req)
137+	if err != nil {
138+		return "", fmt.Errorf("failed to fetch channel: %w", err)
139+	}
140+	defer resp.Body.Close()
141+
142+	if resp.StatusCode != http.StatusOK {
143+		body, err := io.ReadAll(resp.Body)
144+		var bodyStr string
145+		if err != nil {
146+			bodyStr = "failed to decode body"
147+		} else {
148+			bodyStr = string(body)
149+		}
150+		return "", fmt.Errorf("got non-200 status from YouTube API [%d]: %v", resp.StatusCode, bodyStr)
151+	}
152+
153+	var respData apiChannelIDResponse
154+	if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
155+		return "", fmt.Errorf("failed to decode response: %w", err)
156+	}
157+
158+	if len(respData.Items) == 0 {
159+		return "", fmt.Errorf("channel not found for handle %q", handle)
160+	}
161+
162+	return respData.Items[0].ID, nil
163+}
164diff --git a/lib/clients/youtube/youtube.go b/lib/clients/youtube/youtube.go
165index 5570129361168b412e577942bcd838658427165b..731597d3ea2abcf006d62da962403b411efe9404 100644
166--- a/lib/clients/youtube/youtube.go
167+++ b/lib/clients/youtube/youtube.go
168@@ -11,6 +11,7 @@ type Client interface {
169 	GetVideoDurations(ctx context.Context, videos map[string]*models.Video) error
170 	GetVideoMetadata(ctx context.Context, videoID string) (*models.Video, error)
171 	GetChannelImageURL(ctx context.Context, channelID string) (string, error)
172+	ResolveChannelID(ctx context.Context, handle string) (string, error)
173 }
174 
175 type youTubeClient struct {
176diff --git a/mocks/youtube/youtube.go b/mocks/youtube/youtube.go
177index 273d914448bedd9989c8432e0bd0d16123c7ae95..b2bc0efec742bbf6a3bb68187f45e73c11891556 100644
178--- a/mocks/youtube/youtube.go
179+++ b/mocks/youtube/youtube.go
180@@ -29,6 +29,9 @@ var _ youtube.Client = &ClientMock{}
181 //			GetVideoMetadataFunc: func(ctx context.Context, videoID string) (*models.Video, error) {
182 //				panic("mock out the GetVideoMetadata method")
183 //			},
184+//			ResolveChannelIDFunc: func(ctx context.Context, handle string) (string, error) {
185+//				panic("mock out the ResolveChannelID method")
186+//			},
187 //		}
188 //
189 //		// use mockedClient in code that requires youtube.Client
190@@ -45,6 +48,9 @@ type ClientMock struct {
191 	// GetVideoMetadataFunc mocks the GetVideoMetadata method.
192 	GetVideoMetadataFunc func(ctx context.Context, videoID string) (*models.Video, error)
193 
194+	// ResolveChannelIDFunc mocks the ResolveChannelID method.
195+	ResolveChannelIDFunc func(ctx context.Context, handle string) (string, error)
196+
197 	// calls tracks calls to the methods.
198 	calls struct {
199 		// GetChannelImageURL holds details about calls to the GetChannelImageURL method.
200@@ -68,10 +74,18 @@ type ClientMock struct {
201 			// VideoID is the videoID argument value.
202 			VideoID string
203 		}
204+		// ResolveChannelID holds details about calls to the ResolveChannelID method.
205+		ResolveChannelID []struct {
206+			// Ctx is the ctx argument value.
207+			Ctx context.Context
208+			// Handle is the handle argument value.
209+			Handle string
210+		}
211 	}
212 	lockGetChannelImageURL sync.RWMutex
213 	lockGetVideoDurations  sync.RWMutex
214 	lockGetVideoMetadata   sync.RWMutex
215+	lockResolveChannelID   sync.RWMutex
216 }
217 
218 // GetChannelImageURL calls GetChannelImageURLFunc.
219@@ -181,3 +195,39 @@ func (mock *ClientMock) GetVideoMetadataCalls() []struct {
220 	mock.lockGetVideoMetadata.RUnlock()
221 	return calls
222 }
223+
224+// ResolveChannelID calls ResolveChannelIDFunc.
225+func (mock *ClientMock) ResolveChannelID(ctx context.Context, handle string) (string, error) {
226+	if mock.ResolveChannelIDFunc == nil {
227+		panic("ClientMock.ResolveChannelIDFunc: method is nil but Client.ResolveChannelID was just called")
228+	}
229+	callInfo := struct {
230+		Ctx    context.Context
231+		Handle string
232+	}{
233+		Ctx:    ctx,
234+		Handle: handle,
235+	}
236+	mock.lockResolveChannelID.Lock()
237+	mock.calls.ResolveChannelID = append(mock.calls.ResolveChannelID, callInfo)
238+	mock.lockResolveChannelID.Unlock()
239+	return mock.ResolveChannelIDFunc(ctx, handle)
240+}
241+
242+// ResolveChannelIDCalls gets all the calls that were made to ResolveChannelID.
243+// Check the length with:
244+//
245+//	len(mockedClient.ResolveChannelIDCalls())
246+func (mock *ClientMock) ResolveChannelIDCalls() []struct {
247+	Ctx    context.Context
248+	Handle string
249+} {
250+	var calls []struct {
251+		Ctx    context.Context
252+		Handle string
253+	}
254+	mock.lockResolveChannelID.RLock()
255+	calls = mock.calls.ResolveChannelID
256+	mock.lockResolveChannelID.RUnlock()
257+	return calls
258+}
259diff --git a/pages/add_video_modal.templ b/pages/add_video_modal.templ
260index 089ef7f84913d4bc39884bd1272a7e2a2d5c3afc..fda99e97a3b0db73987390554d03d3cdb643dfc6 100644
261--- a/pages/add_video_modal.templ
262+++ b/pages/add_video_modal.templ
263@@ -24,7 +24,7 @@ templ addVideoModal() {
264 							class="form-control"
265 							autocomplete="off"
266 						/>
267-						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-bookmark-plus"></i></button>
268+						<button type="submit" class="btn btn-primary ms-3"><i class="bi bi-camera-video"></i></button>
269 					</form>
270 				</div>
271 			</div>
272diff --git a/pages/add_video_modal_templ.go b/pages/add_video_modal_templ.go
273index 143a29d628b7edb2cda881ebb54978415186b75b..a3bca8401868b9c62b48299723a0f18ab7715932 100644
274--- a/pages/add_video_modal_templ.go
275+++ b/pages/add_video_modal_templ.go
276@@ -30,7 +30,7 @@ func addVideoModal() templ.Component {
277 			templ_7745c5c3_Var1 = templ.NopComponent
278 		}
279 		ctx = templ.ClearChildren(ctx)
280diff --git a/pages/subscription.templ b/pages/subscription.templ
281index 95ec301724102109474e2e0a86922f15f854f250..fc7c2b305902f71a23814ee3295dfcdcf61d778a 100644
282--- a/pages/subscription.templ
283+++ b/pages/subscription.templ
284@@ -19,7 +19,7 @@ templ subscriptionModal() {
285 						<input
286 							type="text"
287 							name="channel_id"
288-							placeholder="Channel ID"
289+							placeholder="Channel ID or username"
290 							class="form-control"
291 							autocomplete="off"
292 						/>
293diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
294index a29790e084a6cdc8712bd92e9cc3bfe0c62ec4cb..8875e7f18135ce8196abc6861a86532f157edafd 100644
295--- a/pages/subscription_templ.go
296+++ b/pages/subscription_templ.go
297@@ -29,7 +29,7 @@ func subscriptionModal() templ.Component {
298 			templ_7745c5c3_Var1 = templ.NopComponent
299 		}
300 		ctx = templ.ClearChildren(ctx)