38354d9a80d27b6c09bad9580638619c6d19ff65
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/API.md b/API.md
2index f4f3606e0df806441b71e3f04266762c1fd5ab23..73a2844effabb630270b6a249cf768787840c5df 100644
3--- a/API.md
4+++ b/API.md
5@@ -183,9 +183,13 @@ Returns subscribed channels ordered by name:
6 {"channels":[/* Channel objects */]}
7 ```
8
9-### `POST /api/channels/:channel_id/subscribe`
10+### `POST /api/channels/subscribe`
11
12-Subscribes to a YouTube channel. `channel_id` may be a raw channel ID or a channel handle, with or without `@`.
13+Subscribes to a YouTube channel. The request body accepts a raw channel ID, a channel handle (with or without `@`), or a YouTube channel URL:
14+
15+```json
16+{"channel_id":"https://m.youtube.com/@username"}
17+```
18
19 Success response:
20
21diff --git a/handler/channels.go b/handler/channels.go
22index 18d3b2b9e8992d5c0e8d3b12c2e3f9f384809e65..0cbe450fd23ee87265447862bec117fe01a2e7c7 100644
23--- a/handler/channels.go
24+++ b/handler/channels.go
25@@ -3,18 +3,60 @@ package handler
26 import (
27 "context"
28 "errors"
29+ "net/url"
30 "strings"
31
32 "github.com/TheEdgeOfRage/ytrssil-api/db"
33 "github.com/TheEdgeOfRage/ytrssil-api/models"
34 )
35
36+var ErrInvalidChannelURL = errors.New("invalid YouTube channel URL")
37+
38 // isChannelID reports whether s looks like a raw YouTube channel ID (UCxxxxxxxx…).
39 func isChannelID(s string) bool {
40 return strings.HasPrefix(s, "UC") && len(s) == 24
41 }
42
43+func channelIDFromInput(input string) (string, error) {
44+ parsedURL, err := url.ParseRequestURI(input)
45+ if err != nil {
46+ if strings.Contains(input, "://") {
47+ return "", ErrInvalidChannelURL
48+ }
49+ return input, nil
50+ }
51+ if parsedURL.Scheme == "" {
52+ return input, nil
53+ }
54+
55+ host := strings.ToLower(parsedURL.Hostname())
56+ if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") ||
57+ (host != "youtube.com" && host != "www.youtube.com" && host != "m.youtube.com") {
58+ return "", ErrInvalidChannelURL
59+ }
60+
61+ path := strings.TrimSuffix(parsedURL.Path, "/")
62+ if strings.HasPrefix(path, "/@") && !strings.Contains(path[2:], "/") && len(path) > 2 {
63+ return path[1:], nil
64+ }
65+
66+ const channelPathPrefix = "/channel/"
67+ if strings.HasPrefix(path, channelPathPrefix) {
68+ channelID := path[len(channelPathPrefix):]
69+ if channelID != "" && !strings.Contains(channelID, "/") {
70+ return channelID, nil
71+ }
72+ }
73+
74+ return "", ErrInvalidChannelURL
75+}
76+
77 func (h *handler) SubscribeToChannel(ctx context.Context, channelID string) (*models.Channel, error) {
78+ channelID, err := channelIDFromInput(channelID)
79+ if err != nil {
80+ return nil, err
81+ }
82+
83 if !isChannelID(channelID) {
84 // Treat input as a handle; normalise to @handle form for the API.
85 handle := channelID
86diff --git a/handler/handler_test.go b/handler/handler_test.go
87index dd082f8adc82c0e4762be7dedcdfd6af4bd1362e..a72e0f520877747cdce9646b56ee72d3d10608d8 100644
88--- a/handler/handler_test.go
89+++ b/handler/handler_test.go
90@@ -22,6 +22,33 @@ func init() {
91 testConfig = config.TestConfig()
92 }
93
94+func TestChannelIDFromInput(t *testing.T) {
95+ tests := []struct {
96+ name string
97+ input string
98+ want string
99+ wantErr error
100+ }{
101+ {name: "handle", input: "@username", want: "@username"},
102+ {name: "YouTube handle URL", input: "https://youtube.com/@username", want: "@username"},
103+ {name: "mobile YouTube handle URL", input: "https://m.youtube.com/@username", want: "@username"},
104+ {
105+ name: "channel ID URL",
106+ input: "https://www.youtube.com/channel/UC1234567890123456789012",
107+ want: "UC1234567890123456789012",
108+ },
109+ {name: "non-YouTube URL", input: "https://example.com/@username", wantErr: ErrInvalidChannelURL},
110+ }
111+
112+ for _, tt := range tests {
113+ t.Run(tt.name, func(t *testing.T) {
114+ got, err := channelIDFromInput(tt.input)
115+ assert.ErrorIs(t, err, tt.wantErr)
116+ assert.Equal(t, tt.want, got)
117+ })
118+ }
119+}
120+
121 func TestGetNewVideos(t *testing.T) {
122 l := slog.New(slog.NewTextHandler(io.Discard, nil))
123 handler := New(
124diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
125index ca2c0eeff1e3911af3ff1e987c22847def913e0b..e080fdabe526d49f654acd8cbf835f98e4d05b0e 100644
126--- a/httpserver/ytrssil/channels.go
127+++ b/httpserver/ytrssil/channels.go
128@@ -9,6 +9,7 @@ import (
129
130 "github.com/TheEdgeOfRage/ytrssil-api/db"
131 "github.com/TheEdgeOfRage/ytrssil-api/feedparser"
132+ "github.com/TheEdgeOfRage/ytrssil-api/handler"
133 )
134
135 func (srv *server) ListChannelsJSON(c *gin.Context) {
136@@ -22,13 +23,21 @@ func (srv *server) ListChannelsJSON(c *gin.Context) {
137 }
138
139 func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
140- channel, err := srv.handler.SubscribeToChannel(c.Request.Context(), c.Param("channel_id"))
141+ var request struct {
142+ ChannelID string `json:"channel_id" binding:"required"`
143+ }
144+ if err := c.ShouldBindJSON(&request); err != nil {
145+ c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid channel request"})
146+ return
147+ }
148+
149+ channel, err := srv.handler.SubscribeToChannel(c.Request.Context(), request.ChannelID)
150 if err != nil {
151 if errors.Is(err, db.ErrAlreadySubscribed) {
152 c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": err.Error()})
153 return
154 }
155- if errors.Is(err, feedparser.ErrInvalidChannelID) {
156+ if errors.Is(err, feedparser.ErrInvalidChannelID) || errors.Is(err, handler.ErrInvalidChannelURL) {
157 c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
158 return
159 }
160diff --git a/httpserver/ytrssil/channels_test.go b/httpserver/ytrssil/channels_test.go
161index fd2e37f61c624d4471cc6bd4af3050eafc0ff157..4375f5e82c9cd8eb94847af461553abf1f407f7f 100644
162--- a/httpserver/ytrssil/channels_test.go
163+++ b/httpserver/ytrssil/channels_test.go
164@@ -1,6 +1,7 @@
165 package ytrssil_test
166
167 import (
168+ "bytes"
169 "context"
170 "encoding/json"
171 "fmt"
172@@ -26,8 +27,10 @@ func (s *ChannelsTestSuite) TestSubscribeToChannelJSON() {
173 channelID := "test-channel-123"
174
175 w := httptest.NewRecorder()
176- req, _ := http.NewRequest("POST", fmt.Sprintf("/api/channels/%s/subscribe", channelID), nil)
177+ requestBody := fmt.Sprintf(`{"channel_id":"%s"}`, channelID)
178+ req, _ := http.NewRequest("POST", "/api/channels/subscribe", bytes.NewBufferString(requestBody))
179 req.Header.Set("Authorization", s.cfg.AuthToken)
180+ req.Header.Set("Content-Type", "application/json")
181 s.server.Handler.ServeHTTP(w, req)
182
183 s.Equal(http.StatusOK, w.Code)
184@@ -40,6 +43,24 @@ func (s *ChannelsTestSuite) TestSubscribeToChannelJSON() {
185 s.True(response.Subscribed)
186 }
187
188+func (s *ChannelsTestSuite) TestSubscribeToChannelJSONWithURL() {
189+ channelURL := "https://m.youtube.com/@channel-234"
190+
191+ w := httptest.NewRecorder()
192+ requestBody := fmt.Sprintf(`{"channel_id":"%s"}`, channelURL)
193+ req, _ := http.NewRequest("POST", "/api/channels/subscribe", bytes.NewBufferString(requestBody))
194+ req.Header.Set("Authorization", s.cfg.AuthToken)
195+ req.Header.Set("Content-Type", "application/json")
196+ s.server.Handler.ServeHTTP(w, req)
197+
198+ s.Equal(http.StatusOK, w.Code)
199+
200+ var response models.Channel
201+ err := json.Unmarshal(w.Body.Bytes(), &response)
202+ s.Require().NoError(err)
203+ s.Equal("channel-234", response.ID)
204+}
205+
206 func (s *ChannelsTestSuite) TestUnsubscribeFromChannelJSON() {
207 channelID := "test-channel-456"
208
209@@ -86,7 +107,7 @@ func (s *ChannelsTestSuite) TestChannelsPage() {
210
211 func (s *ChannelsTestSuite) TestSubscribeToChannelPage() {
212 w := httptest.NewRecorder()
213- req, _ := http.NewRequest("POST", "/subscribe", strings.NewReader(`{"channelID":"channel-707"}`))
214+ req, _ := http.NewRequest("POST", "/subscribe", strings.NewReader(`{"channelID":"https://youtube.com/@channel-707"}`))
215 req.Header.Set("Content-Type", "application/json")
216 req.AddCookie(&http.Cookie{Name: "token", Value: s.cfg.AuthToken})
217 s.server.Handler.ServeHTTP(w, req)
218@@ -116,7 +137,7 @@ func (s *ChannelsTestSuite) TestUnsubscribeFromChannelPage() {
219
220 func (s *ChannelsTestSuite) TestSubscribeRequiresAuth() {
221 w := httptest.NewRecorder()
222- req, _ := http.NewRequest("POST", "/api/channels/test/subscribe", nil)
223+ req, _ := http.NewRequest("POST", "/api/channels/subscribe", nil)
224 s.server.Handler.ServeHTTP(w, req)
225
226 s.Equal(http.StatusUnauthorized, w.Code)
227diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
228index 9460f0991463ce0d7aa9a455d166480317dedebf..c4e898fe1c966c7cdf8b36024aa8cebb4f2657c2 100644
229--- a/httpserver/ytrssil/server.go
230+++ b/httpserver/ytrssil/server.go
231@@ -93,7 +93,7 @@ func SetupGinRouter(
232 {
233 api.POST("/fetch", srv.FetchVideosJSON)
234 api.GET("channels", srv.ListChannelsJSON)
235- api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannelJSON)
236+ api.POST("channels/subscribe", srv.SubscribeToChannelJSON)
237 api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelJSON)
238 api.POST("channels/:channel_id/shorts", srv.ToggleChannelShortsJSON)
239 api.POST("videos", srv.AddCustomVideoJSON)
240diff --git a/pages/subscription.templ b/pages/subscription.templ
241index 0ae4e08b6be1e1fbde449e11715057e683142b7e..10111e8b0ddcbdb2ba695cdd361b0376beaac240 100644
242--- a/pages/subscription.templ
243+++ b/pages/subscription.templ
244@@ -16,7 +16,7 @@ templ subscriptionModal() {
245 >
246 <input
247 type="text"
248- placeholder="Channel ID or username"
249+ placeholder="Channel ID, username, or URL"
250 class="form-control"
251 autocomplete="off"
252 />
253diff --git a/pages/subscription_templ.go b/pages/subscription_templ.go
254index b5bc76b94d91476e0661c938a9dcc83c7ab4cd5c..b5877b5fed9ddf5146920c8f9c911f015bf56d4d 100644
255--- a/pages/subscription_templ.go
256+++ b/pages/subscription_templ.go
257@@ -29,7 +29,7 @@ func subscriptionModal() templ.Component {
258 templ_7745c5c3_Var1 = templ.NopComponent
259 }
260 ctx = templ.ClearChildren(ctx)