5f8b981ae2f42e0c2f08c354ac752fb6abdf2a5a

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

Message

Implement remaining missing JSON API endpoints

Diff

This diff is truncated to protect this page.

  1diff --git a/API.md b/API.md
  2index f720777c65f956abe40c1e66b2d7ac7d6c059a1e..f4f3606e0df806441b71e3f04266762c1fd5ab23 100644
  3--- a/API.md
  4+++ b/API.md
  5@@ -44,6 +44,19 @@ Video listings contain these fields. Times are JSON timestamps in RFC 3339 forma
  6 
  7 `duration` and `progress` are seconds. `channel_id` and the server file path are not exposed.
  8 
  9+## Channel object
 10+
 11+```json
 12+{
 13+  "channel_id": "YouTube channel ID",
 14+  "name": "Channel name",
 15+  "subscribed": true,
 16+  "unwatched_count": 3,
 17+  "image_url": "https://...",
 18+  "enable_shorts": true
 19+}
 20+```
 21+
 22 ## MVP routes
 23 
 24 ### `POST /api/fetch`
 25@@ -56,6 +69,22 @@ Success response:
 26 {"msg":"videos fetched successfully"}
 27 ```
 28 
 29+### `POST /api/videos`
 30+
 31+Adds one custom video without subscribing to its channel. The request body accepts a YouTube video ID or URL:
 32+
 33+```json
 34+{"video_id":"https://www.youtube.com/watch?v=..."}
 35+```
 36+
 37+A YouTube URL with `?t=N` saves `N` seconds of watch progress. The created channel remains unsubscribed, so later fetches do not add its other videos.
 38+
 39+Success response (`201`):
 40+
 41+```json
 42+{"msg":"video added"}
 43+```
 44+
 45 ### `GET /api/videos/new`
 46 
 47 Returns all unwatched, non-discarded videos:
 48@@ -113,3 +142,73 @@ Success response:
 49 ```
 50 
 51 Missing or invalid progress returns `400`.
 52+
 53+### `GET /api/videos/:video_id`
 54+
 55+Returns one video, including its current download status:
 56+
 57+```json
 58+{"video":{/* Video object */}}
 59+```
 60+
 61+A missing video returns `404`.
 62+
 63+### `POST /api/videos/:video_id/download`
 64+
 65+Starts an asynchronous server download. The request body must select one of the supported maximum video heights: `480`, `720`, `1080`, `1440`, or `2160`.
 66+
 67+```json
 68+{"format":720}
 69+```
 70+
 71+Success response (`202`):
 72+
 73+```json
 74+{"msg":"download started"}
 75+```
 76+
 77+Poll `GET /api/videos/:video_id` to read `download_status` and `download_error`. Invalid or missing formats return `400`.
 78+
 79+### `GET /api/videos/:video_id/file`
 80+
 81+Returns the completed server download as an attachment. The endpoint requires the same `Authorization` header as every other API route. It returns `404` if the video has no completed file.
 82+
 83+## Channel routes
 84+
 85+### `GET /api/channels`
 86+
 87+Returns subscribed channels ordered by name:
 88+
 89+```json
 90+{"channels":[/* Channel objects */]}
 91+```
 92+
 93+### `POST /api/channels/:channel_id/subscribe`
 94+
 95+Subscribes to a YouTube channel. `channel_id` may be a raw channel ID or a channel handle, with or without `@`.
 96+
 97+Success response:
 98+
 99+```json
100+{/* Channel object */}
101+```
102+
103+### `POST /api/channels/:channel_id/unsubscribe`
104+
105diff --git a/db/channels.go b/db/channels.go
106index 73d96a474c3d98fafb789e9c26ecb6c7e3bb2ce7..79073cfca542df8d37f841979cf16cf535f3b6ef 100644
107--- a/db/channels.go
108+++ b/db/channels.go
109@@ -10,7 +10,10 @@ import (
110 func (db *postgresDB) SubscribeToChannel(ctx context.Context, channel models.Channel) error {
111 	const query = `
112 		INSERT INTO channels (id, name, subscribed, image_url, enable_shorts) VALUES ($1, $2, $3, $4, $5)
113-		ON CONFLICT (id) DO UPDATE SET subscribed = $3, image_url = $4, enable_shorts = $5
114+		ON CONFLICT (id) DO UPDATE SET
115+			subscribed = CASE WHEN EXCLUDED.subscribed THEN true ELSE channels.subscribed END,
116+			image_url = CASE WHEN EXCLUDED.subscribed THEN EXCLUDED.image_url ELSE channels.image_url END,
117+			enable_shorts = CASE WHEN EXCLUDED.subscribed THEN EXCLUDED.enable_shorts ELSE channels.enable_shorts END
118 	`
119 	resp, err := db.db.Exec(ctx, query, channel.ID, channel.Name, channel.Subscribed,
120 		channel.ImageURL, channel.EnableShorts)
121diff --git a/handler/downloads.go b/handler/downloads.go
122index 7cf2cedc77c427b74f0ba21a752fdd0f97c2fb5b..f85965cfad5d357bb26eb44d02607f5a25274c07 100644
123--- a/handler/downloads.go
124+++ b/handler/downloads.go
125@@ -86,7 +86,7 @@ func (h *handler) ServeVideoFile(ctx context.Context, videoID string) (filePath
126 		return "", "", fmt.Errorf("video not found: %w", err)
127 	}
128 
129-	if video.FilePath == nil {
130+	if !video.IsDownloaded() || video.FilePath == nil {
131 		return "", "", fmt.Errorf("video not downloaded")
132 	}
133 
134diff --git a/handler/handler.go b/handler/handler.go
135index 3989f8ab85847a1f55c168810ef89c86076dd72f..4f08312e9e6d9d9d9012bb5450bd96944606f0c7 100644
136--- a/handler/handler.go
137+++ b/handler/handler.go
138@@ -20,6 +20,7 @@ type Handler interface {
139 	ToggleChannelShorts(ctx context.Context, channelID string, enableShorts bool) error
140 	GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error)
141 	GetWatchedVideos(ctx context.Context, sortDesc bool, page int) ([]models.Video, error)
142+	GetVideo(ctx context.Context, videoID string) (*models.Video, error)
143 	FetchVideos(ctx context.Context) error
144 	MarkVideoAsWatched(ctx context.Context, videoID string) error
145 	MarkVideoAsUnwatched(ctx context.Context, videoID string) error
146diff --git a/handler/videos.go b/handler/videos.go
147index 31031b393ad18b9ab5fd5b6471699338d0f1f35e..409338214bce1a3b47f1ce1074762dc8c2b9c64c 100644
148--- a/handler/videos.go
149+++ b/handler/videos.go
150@@ -15,7 +15,11 @@ import (
151 	"github.com/TheEdgeOfRage/ytrssil-api/models"
152 )
153 
154-var ErrInvalidProgress = errors.New("invalid progress time")
155+var (
156+	ErrInvalidProgress   = errors.New("invalid progress time")
157+	ErrInvalidVideoInput = errors.New("invalid video input")
158+	ErrVideoNotFound     = errors.New("video not found")
159+)
160 
161 func (h *handler) GetNewVideos(ctx context.Context, sortDesc bool) ([]models.Video, error) {
162 	return h.db.GetNewVideos(ctx, sortDesc)
163@@ -31,6 +35,17 @@ func (h *handler) GetWatchedVideos(ctx context.Context, sortDesc bool, page int)
164 	return h.db.GetWatchedVideos(ctx, sortDesc, WatchedVideosPageSize, offset)
165 }
166 
167+func (h *handler) GetVideo(ctx context.Context, videoID string) (*models.Video, error) {
168+	exists, err := h.db.HasVideo(ctx, videoID)
169+	if err != nil {
170+		return nil, err
171+	}
172+	if !exists {
173+		return nil, ErrVideoNotFound
174+	}
175+	return h.db.GetVideo(ctx, videoID)
176+}
177+
178 func (h *handler) addVideosForChannel(ctx context.Context, parsedChannel *feedparser.Channel, enableShorts bool) {
179 	videos := make(map[string]*models.Video, len(parsedChannel.Videos))
180 
181@@ -228,6 +243,10 @@ type videoInput struct {
182 }
183 
184 func parseVideoInput(input string) (videoInput, error) {
185+	if input == "" {
186+		return videoInput{}, fmt.Errorf("missing video ID")
187+	}
188+
189 	if !strings.Contains(input, "/") {
190 		return videoInput{id: input}, nil
191 	}
192@@ -263,9 +282,9 @@ func parseVideoInput(input string) (videoInput, error) {
193 }
194 
195 func (h *handler) AddCustomVideo(ctx context.Context, rawVideoID string) error {
196-	input, err := parseVideoInput(rawVideoID)
197+	input, err := parseVideoInput(strings.TrimSpace(rawVideoID))
198 	if err != nil {
199-		return err
200+		return fmt.Errorf("%w: %v", ErrInvalidVideoInput, err)
201 	}
202 
203 	exists, err := h.db.HasVideo(ctx, input.id)
204diff --git a/httpserver/ytrssil/channels.go b/httpserver/ytrssil/channels.go
205index 2f73cbb13cb8a4a0c4ef98970788d780c395c29d..ca2c0eeff1e3911af3ff1e987c22847def913e0b 100644
206--- a/httpserver/ytrssil/channels.go
207+++ b/httpserver/ytrssil/channels.go
208@@ -3,6 +3,7 @@ package ytrssil
209 import (
210 	"errors"
211 	"net/http"
212+	"strconv"
213 
214 	"github.com/gin-gonic/gin"
215 
216@@ -10,6 +11,16 @@ import (
217 	"github.com/TheEdgeOfRage/ytrssil-api/feedparser"
218 )
219 
220+func (srv *server) ListChannelsJSON(c *gin.Context) {
221+	channels, err := srv.handler.ListChannels(c.Request.Context())
222+	if err != nil {
223+		c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
224+		return
225+	}
226+
227+	c.JSON(http.StatusOK, gin.H{"channels": channels})
228+}
229+
230 func (srv *server) SubscribeToChannelJSON(c *gin.Context) {
231 	channel, err := srv.handler.SubscribeToChannel(c.Request.Context(), c.Param("channel_id"))
232 	if err != nil {
233@@ -46,9 +57,19 @@ func (srv *server) UnsubscribeFromChannelJSON(c *gin.Context) {
234 
235 func (srv *server) ToggleChannelShortsJSON(c *gin.Context) {
236 	channelID := c.Param("channel_id")
237-	enable := c.Query("enable") == "true"
238+	enableValue, ok := c.GetQuery("enable")
239+	if !ok {
240+		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing enable query parameter"})
241+		return
242+	}
243+
244+	enable, err := strconv.ParseBool(enableValue)
245+	if err != nil {
246+		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "invalid enable query parameter"})
247+		return
248+	}
249 
250-	err := srv.handler.ToggleChannelShorts(c.Request.Context(), channelID, enable)
251+	err = srv.handler.ToggleChannelShorts(c.Request.Context(), channelID, enable)
252 	if err != nil {
253 		if errors.Is(err, db.ErrChannelNotFound) {
254 			c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
255diff --git a/httpserver/ytrssil/server.go b/httpserver/ytrssil/server.go
256index 219511b57e27a740933e5e8a5f1207338834584d..9460f0991463ce0d7aa9a455d166480317dedebf 100644
257--- a/httpserver/ytrssil/server.go
258+++ b/httpserver/ytrssil/server.go
259@@ -92,14 +92,19 @@ func SetupGinRouter(
260 	api.Use(auth.APIAuthMiddleware(cfg.AuthToken))
261 	{
262 		api.POST("/fetch", srv.FetchVideosJSON)
263+		api.GET("channels", srv.ListChannelsJSON)
264 		api.POST("channels/:channel_id/subscribe", srv.SubscribeToChannelJSON)
265 		api.POST("channels/:channel_id/unsubscribe", srv.UnsubscribeFromChannelJSON)
266+		api.POST("channels/:channel_id/shorts", srv.ToggleChannelShortsJSON)
267+		api.POST("videos", srv.AddCustomVideoJSON)
268 		api.GET("videos/new", srv.GetNewVideosJSON)
269 		api.GET("videos/watched", srv.GetWatchedVideosJSON)
270+		api.GET("videos/:video_id", srv.GetVideoJSON)
271 		api.POST("videos/:video_id/watch", srv.MarkVideoAsWatchedJSON)
272 		api.POST("videos/:video_id/unwatch", srv.MarkVideoAsUnwatchedJSON)
273 		api.POST("videos/:video_id/progress", srv.SetVideoProgressJSON)
274 		api.POST("videos/:video_id/download", srv.DownloadVideoJSON)
275+		api.GET("videos/:video_id/file", srv.ServeVideoFileJSON)
276 	}
277 
278 	return engine, nil
279diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
280index b436ada2187d7ec4244ce4d43099eddf196d9463..19c616e26a6e731cecc3a3c8649f9f1cf836bb07 100644
281--- a/httpserver/ytrssil/videos.go
282+++ b/httpserver/ytrssil/videos.go
283@@ -47,6 +47,42 @@ func (srv *server) FetchVideosJSON(c *gin.Context) {
284 	c.JSON(http.StatusOK, gin.H{"msg": "videos fetched successfully"})
285 }
286 
287+func (srv *server) GetVideoJSON(c *gin.Context) {
288+	video, err := srv.handler.GetVideo(c.Request.Context(), c.Param("video_id"))
289+	if err != nil {
290+		if errors.Is(err, handler.ErrVideoNotFound) {
291+			c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
292+			return
293+		}
294+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
295+		return
296+	}
297+
298+	c.JSON(http.StatusOK, gin.H{"video": video})
299+}
300+
301+func (srv *server) AddCustomVideoJSON(c *gin.Context) {
302+	var request struct {
303+		VideoID string `json:"video_id"`
304+	}
305+	if err := c.ShouldBindJSON(&request); err != nil {
306+		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid video request"})
307+		return
308+	}
309+
310+	err := srv.handler.AddCustomVideo(c.Request.Context(), request.VideoID)
311+	if err != nil {
312+		if errors.Is(err, handler.ErrInvalidVideoInput) {
313+			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
314+			return
315+		}
316+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
317+		return
318+	}
319+
320+	c.JSON(http.StatusCreated, gin.H{"msg": "video added"})
321+}
322+
323 func (srv *server) MarkVideoAsWatchedJSON(c *gin.Context) {
324 	err := srv.handler.MarkVideoAsWatched(c.Request.Context(), c.Param("video_id"))
325 	if err != nil {
326@@ -94,14 +130,44 @@ func (srv *server) SetVideoProgressJSON(c *gin.Context) {
327 }
328 
329 func (srv *server) DownloadVideoJSON(c *gin.Context) {
330-	videoID := c.Param("video_id")
331-	resolution := c.PostForm("format")
332+	var request struct {
333+		Format int `json:"format"`
334+	}
335+	if err := c.ShouldBindJSON(&request); err != nil {
336+		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid download request"})
337+		return
338+	}
339 
340-	err := srv.handler.DownloadVideo(c.Request.Context(), videoID, resolution)
341+	if !isSupportedDownloadFormat(request.Format) {
342+		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid download format"})
343+		return
344+	}
345+
346+	err := srv.handler.DownloadVideo(c.Request.Context(), c.Param("video_id"), strconv.Itoa(request.Format))
347 	if err != nil {
348 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
349 		return
350 	}
351 
352-	c.JSON(http.StatusOK, gin.H{"msg": "download started"})
353+	c.JSON(http.StatusAccepted, gin.H{"msg": "download started"})
354+}
355+
356+func (srv *server) ServeVideoFileJSON(c *gin.Context) {
357+	filePath, filename, err := srv.handler.ServeVideoFile(c.Request.Context(), c.Param("video_id"))
358+	if err != nil {
359+		c.JSON(http.StatusNotFound, gin.H{"error": "video file not found"})
360+		return
361+	}
362+
363+	c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
364+	c.File(filePath)
365+}
366+
367+func isSupportedDownloadFormat(format int) bool {
368+	switch format {
369+	case 480, 720, 1080, 1440, 2160:
370+		return true
371+	default:
372+		return false
373+	}
374 }