5f8b981ae2f42e0c2f08c354ac752fb6abdf2a5a
- 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 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 }