3960b6893356ccce711a39ec5a1621fcf0c5c777

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

Message

Made json API more stable and add API docs

Diff

  1diff --git a/API.md b/API.md
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..1dcb5c0639855a787894e53511e8a178fadb93b2
  4--- /dev/null
  5+++ b/API.md
  6@@ -0,0 +1,99 @@
  7+# JSON API
  8+
  9+The JSON API is available under `/api`. The service does not enable CORS.
 10+
 11+## Authentication
 12+
 13+Every `/api` request must include exactly one `Authorization` header. Its value must exactly equal the configured static token. Do not use a `Bearer` prefix.
 14+
 15+Missing or repeated headers return `401`:
 16+
 17+```json
 18+{"error":"missing Authorization header"}
 19+```
 20+
 21+An incorrect token returns `401`:
 22+
 23+```json
 24+{"error":"invalid auth token"}
 25+```
 26+
 27+Handler failures return `500` with an `error` string.
 28+
 29+## Video object
 30+
 31+Video listings contain these fields. Times are JSON timestamps in RFC 3339 format. Nullable fields are `null` when unset.
 32+
 33+```json
 34+{
 35+  "video_id": "YouTube video ID",
 36+  "channel_name": "Channel name",
 37+  "title": "Video title",
 38+  "published_timestamp": "2026-01-02T15:04:05Z",
 39+  "watch_timestamp": null,
 40+  "duration": 1200,
 41+  "progress": 300,
 42+  "short": false,
 43+  "is_live": false,
 44+  "is_discarded": false,
 45+  "downloaded_at": null,
 46+  "download_status": null,
 47+  "download_error": null
 48+}
 49+```
 50+
 51+`duration` and `progress` are seconds. `channel_id` and the server file path are not exposed.
 52+
 53+## MVP routes
 54+
 55+### `POST /api/fetch`
 56+
 57+Fetches new videos from every subscribed channel. It has no request body.
 58+
 59+Success response:
 60+
 61+```json
 62+{"msg":"videos fetched successfully"}
 63+```
 64+
 65+### `GET /api/videos/new`
 66+
 67+Returns all unwatched, non-discarded videos:
 68+
 69+```json
 70+{"videos":[/* Video objects */]}
 71+```
 72+
 73+Videos are ordered by `published_timestamp` newest first. Videos with the same timestamp are ordered by `video_id` descending.
 74+
 75+### `GET /api/videos/watched?page=N`
 76+
 77+Returns watched, non-discarded videos in pages of 100:
 78+
 79+```json
 80+{"videos":[/* Video objects */]}
 81+```
 82+
 83+`page` is a one-based positive integer and defaults to `1`. Invalid, zero, and negative values use page `1`. A response containing fewer than 100 videos is the final page.
 84+
 85+Videos are ordered by `watch_timestamp` newest first. Videos with the same timestamp are ordered by `video_id` descending.
 86+
 87+### `POST /api/videos/:video_id/watch`
 88+
 89+Marks the video as watched at the current server time. It has no request body.
 90+
 91+Success response:
 92+
 93+```json
 94+{"msg":"marked video as watched"}
 95+```
 96+
 97+### `POST /api/videos/:video_id/unwatch`
 98+
 99+Clears the video watch timestamp. It has no request body.
100+
101+Success response:
102+
103+```json
104+{"msg":"cleared video from watch history"}
105+```
106diff --git a/README.md b/README.md
107index 435e402776623270c9b484616c16bc759bb3b7cc..5c8f5fcaa5530c3149860193438607828fc8cb97 100644
108--- a/README.md
109+++ b/README.md
110@@ -50,6 +50,10 @@ FETCH_INTERVAL=5m
111 CLEANUP_INTERVAL=1h
112 ```
113 
114+## API
115+
116+See [API.md](API.md) for JSON API authentication, request, and response details.
117+
118 ## Usage
119 
120 ### Adding Channels
121diff --git a/db/videos.go b/db/videos.go
122index dbc83ebc285c82fdc32a259da7a7b32d44aa82a6..c03c97ee056024b390cf1af59cc16ca3017dea7b 100644
123--- a/db/videos.go
124+++ b/db/videos.go
125@@ -33,6 +33,7 @@ func (db *postgresDB) GetNewVideos(ctx context.Context, sortDesc bool) ([]models
126 	if sortDesc {
127 		query += " DESC"
128 	}
129+	query += ", videos.id DESC"
130 
131 	rows, err := db.db.Query(ctx, query)
132 	if err != nil {
133@@ -99,7 +100,7 @@ func (db *postgresDB) GetWatchedVideos(
134 	if sortDesc {
135 		query += " DESC"
136 	}
137-	query += " LIMIT $1 OFFSET $2"
138+	query += ", videos.id DESC LIMIT $1 OFFSET $2"
139 
140 	rows, err := db.db.Query(ctx, query, limit, offset)
141 	if err != nil {
142diff --git a/httpserver/ytrssil/videos.go b/httpserver/ytrssil/videos.go
143index 17eae03180d365697b73e75a1dbeb461fd3ebb9d..327795e039107f044d24d7e435075c4a5fcd4991 100644
144--- a/httpserver/ytrssil/videos.go
145+++ b/httpserver/ytrssil/videos.go
146@@ -2,12 +2,13 @@ package ytrssil
147 
148 import (
149 	"net/http"
150+	"strconv"
151 
152 	"github.com/gin-gonic/gin"
153 )
154 
155 func (srv *server) GetNewVideosJSON(c *gin.Context) {
156-	videos, err := srv.handler.GetNewVideos(c.Request.Context(), false)
157+	videos, err := srv.handler.GetNewVideos(c.Request.Context(), true)
158 	if err != nil {
159 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
160 		return
161@@ -17,7 +18,14 @@ func (srv *server) GetNewVideosJSON(c *gin.Context) {
162 }
163 
164 func (srv *server) GetWatchedVideosJSON(c *gin.Context) {
165-	videos, err := srv.handler.GetWatchedVideos(c.Request.Context(), false, 1)
166+	page := 1
167+	if pageParam := c.Query("page"); pageParam != "" {
168+		if parsedPage, err := strconv.Atoi(pageParam); err == nil && parsedPage > 0 {
169+			page = parsedPage
170+		}
171+	}
172+
173+	videos, err := srv.handler.GetWatchedVideos(c.Request.Context(), true, page)
174 	if err != nil {
175 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
176 		return