diff --git a/README.md b/README.md index 175cbfe6..6f433ea4 100644 --- a/README.md +++ b/README.md @@ -172,9 +172,9 @@ Configuration is loaded from environment variables, and a local `.env` file is r `GET /api/public/users/{user_id}/watchlist` returns a versioned, agent-friendly JSON view of a user's complete watchlist. It includes status summaries, episode progress, -added/completed/last-watched dates, and localized titles. Add `?download=1` to receive the same -response as a `watchlist-{user_id}.json` attachment; the watchlist page's export action uses this -URL. +added/completed/last-watched dates, and localized titles. + +`GET /watchlist/export` downloads the current user's watchlist as `watchlist.json`. This endpoint is intentionally unauthenticated. A deployment that should keep watchlist and playback data private must restrict access to `/api/public/` at the network or reverse-proxy layer. diff --git a/internal/watchlist/handler.go b/internal/watchlist/handler.go index f39940b6..b3ad5690 100644 --- a/internal/watchlist/handler.go +++ b/internal/watchlist/handler.go @@ -24,6 +24,7 @@ func (h *WatchlistHandler) Register(r *gin.Engine) { r.POST("/api/watchlist", h.HandleUpdateWatchlist) r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist) r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching) + r.GET("/watchlist/export", h.HandleDownloadWatchlist) r.GET("/api/public/users/:userID/watchlist", h.HandleGetPublicWatchlist) r.GET("/watchlist", h.HandleGetWatchlist) } @@ -142,6 +143,28 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) { }) } +func (h *WatchlistHandler) HandleDownloadWatchlist(c *gin.Context) { + userID := server.CurrentUserID(c) + + entries, err := h.svc.GetWatchlist(c.Request.Context(), userID) + if err != nil { + server.RespondError( + c, + http.StatusInternalServerError, + "watchlist_load_failed", + "watchlist", + "failed to load watchlist", + map[string]any{"user_id": userID}, + err, + ) + return + } + + c.Header("Cache-Control", "no-store") + c.Header("Content-Disposition", `attachment; filename="watchlist.json"`) + c.JSON(http.StatusOK, newPublicWatchlist(userID, entries, time.Now().UTC())) +} + func (h *WatchlistHandler) HandleGetPublicWatchlist(c *gin.Context) { userID := strings.TrimSpace(c.Param("userID")) if userID == "" { diff --git a/internal/watchlist/handler_test.go b/internal/watchlist/handler_test.go index 42dd94bc..bd557f73 100644 --- a/internal/watchlist/handler_test.go +++ b/internal/watchlist/handler_test.go @@ -151,6 +151,31 @@ func TestHandleGetPublicWatchlistReturnsMinimalJSON(t *testing.T) { assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes()) } +func TestHandleDownloadWatchlistReturnsAttachment(t *testing.T) { + gin.SetMode(gin.TestMode) + + addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC) + completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC) + lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC) + svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)} + router := newWatchlistTestRouter(svc) + + rec := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watchlist/export", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist.json"` { + t.Fatalf("Content-Disposition = %q, want watchlist.json attachment", got) + } + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", got) + } + assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes()) +} + func publicWatchlistRows(addedAt time.Time, completedAt time.Time, lastWatchedAt time.Time) []domain.UserWatchListRow { return []domain.UserWatchListRow{ { diff --git a/templates/watchlist.gohtml b/templates/watchlist.gohtml index 4dafdddd..89ea7535 100644 --- a/templates/watchlist.gohtml +++ b/templates/watchlist.gohtml @@ -15,7 +15,7 @@
- +