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