38354d9a80d27b6c09bad9580638619c6d19ff65

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

Message

Support channel URLs when subscribing

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)