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