feat: add public watchlist export

This commit is contained in:
2026-06-29 13:39:35 +02:00
parent 1e6ac5e854
commit be71ec91c8
7 changed files with 422 additions and 47 deletions

View File

@@ -0,0 +1,152 @@
package watchlist
import (
"strings"
"time"
"mal/internal/domain"
)
type publicWatchlist struct {
SchemaVersion string `json:"schema_version"`
GeneratedAt time.Time `json:"generated_at"`
UserID string `json:"user_id"`
Summary watchlistSummary `json:"summary"`
Entries []publicWatchlistEntry `json:"entries"`
}
type watchlistSummary struct {
Total int `json:"total"`
Watching int `json:"watching"`
Completed int `json:"completed"`
PlanToWatch int `json:"plan_to_watch"`
OnHold int `json:"on_hold"`
Dropped int `json:"dropped"`
}
type publicWatchlistEntry struct {
MALID int64 `json:"mal_id"`
Status string `json:"status"`
AddedAt time.Time `json:"added_at"`
CompletedAt *time.Time `json:"completed_at"`
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
LastWatchedAt *time.Time `json:"last_watched_at"`
Progress watchlistProgress `json:"progress"`
Titles watchlistTitles `json:"titles"`
}
type watchlistProgress struct {
CurrentEpisode *int64 `json:"current_episode"`
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
}
type watchlistTitles struct {
Preferred string `json:"preferred"`
Original string `json:"original"`
English *string `json:"english"`
Japanese *string `json:"japanese"`
}
func newPublicWatchlist(userID string, rows []domain.UserWatchListRow, generatedAt time.Time) publicWatchlist {
export := publicWatchlist{
SchemaVersion: "1.0",
GeneratedAt: generatedAt,
UserID: userID,
Entries: make([]publicWatchlistEntry, 0, len(rows)),
}
for _, row := range rows {
export.Summary.add(row.Status)
export.Entries = append(export.Entries, newPublicWatchlistEntry(row))
}
return export
}
func (s *watchlistSummary) add(status string) {
s.Total++
switch status {
case "watching":
s.Watching++
case "completed":
s.Completed++
case "plan_to_watch":
s.PlanToWatch++
case "on_hold":
s.OnHold++
case "dropped":
s.Dropped++
}
}
func newPublicWatchlistEntry(row domain.UserWatchListRow) publicWatchlistEntry {
currentEpisode := nullableInt64(row.CurrentEpisode.Int64, row.CurrentEpisode.Valid)
currentTimeSeconds := nullableFloat64(row.CurrentTimeSeconds, row.CurrentEpisode.Valid)
lastWatchedAt := nullableTime(row.LastEpisodeAt.Time, row.LastEpisodeAt.Valid)
if row.PlaybackCurrentEpisode.Valid {
currentEpisode = &row.PlaybackCurrentEpisode.Int64
currentTimeSeconds = nullableFloat64(row.PlaybackCurrentTimeSeconds.Float64, row.PlaybackCurrentTimeSeconds.Valid)
lastWatchedAt = nullableTime(row.PlaybackUpdatedAt.Time, row.PlaybackUpdatedAt.Valid)
}
entry := publicWatchlistEntry{
MALID: row.AnimeID,
Status: row.Status,
AddedAt: row.CreatedAt,
LastWatchedAt: lastWatchedAt,
Progress: watchlistProgress{
CurrentEpisode: currentEpisode,
CurrentTimeSeconds: currentTimeSeconds,
},
Titles: watchlistTitles{
Preferred: row.DisplayTitle(),
Original: row.TitleOriginal,
English: nullableString(row.TitleEnglish.String, row.TitleEnglish.Valid),
Japanese: nullableString(row.TitleJapanese.String, row.TitleJapanese.Valid),
},
}
if row.Status == "completed" {
accuracy := "exact"
if row.CompletedAt.Valid {
entry.CompletedAt = &row.CompletedAt.Time
if row.CompletedAtEstimated {
accuracy = "estimated_from_historical_update"
}
} else {
entry.CompletedAt = &row.UpdatedAt
accuracy = "estimated_from_historical_update"
}
entry.CompletionDateAccuracy = &accuracy
}
return entry
}
func nullableString(value string, valid bool) *string {
if !valid || strings.TrimSpace(value) == "" {
return nil
}
return &value
}
func nullableInt64(value int64, valid bool) *int64 {
if !valid {
return nil
}
return &value
}
func nullableFloat64(value float64, valid bool) *float64 {
if !valid {
return nil
}
return &value
}
func nullableTime(value time.Time, valid bool) *time.Time {
if !valid {
return nil
}
return &value
}

View File

@@ -6,6 +6,8 @@ import (
"mal/internal/server"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
@@ -22,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("/api/public/users/:userID/watchlist", h.HandleGetPublicWatchlist)
r.GET("/watchlist", h.HandleGetWatchlist)
}
@@ -138,3 +141,45 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
"User": user,
})
}
func (h *WatchlistHandler) HandleGetPublicWatchlist(c *gin.Context) {
userID := strings.TrimSpace(c.Param("userID"))
if userID == "" {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid user id")
return
}
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"public_watchlist_load_failed",
"watchlist",
"failed to load public watchlist",
map[string]any{"user_id": userID},
err,
)
return
}
c.Header("Cache-Control", "no-store")
if c.Query("download") == "1" {
c.Header("Content-Disposition", `attachment; filename="watchlist-`+safeFilenamePart(userID)+`.json"`)
}
c.JSON(http.StatusOK, newPublicWatchlist(userID, entries, time.Now().UTC()))
}
func safeFilenamePart(value string) string {
const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
var filename strings.Builder
for _, char := range value {
if strings.ContainsRune(allowed, char) {
filename.WriteRune(char)
}
}
if filename.Len() == 0 {
return "user"
}
return filename.String()
}

View File

@@ -2,11 +2,14 @@ package watchlist
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"mal/internal/db"
"mal/internal/domain"
@@ -110,6 +113,196 @@ func TestHandleDeleteContinueWatching(t *testing.T) {
}
}
func TestHandleGetPublicWatchlistReturnsMinimalJSON(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, "/api/public/users/user-42/watchlist", 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-Type"); !strings.HasPrefix(got, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", got)
}
if svc.watchlistUserID != "user-42" {
t.Fatalf("GetWatchlist user id = %q, want user-42", svc.watchlistUserID)
}
var body publicWatchlistResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
}
assertPublicWatchlistEnvelope(t, body)
if len(body.Entries) != 2 {
t.Fatalf("entries = %d, want 2", len(body.Entries))
}
assertCompletedPublicEntry(t, body.Entries[0], addedAt, completedAt)
assertWatchingPublicEntry(t, body.Entries[1], lastWatchedAt)
assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes())
}
func publicWatchlistRows(addedAt time.Time, completedAt time.Time, lastWatchedAt time.Time) []domain.UserWatchListRow {
return []domain.UserWatchListRow{
{
AnimeID: 1,
Status: "completed",
CreatedAt: addedAt,
UpdatedAt: completedAt.Add(48 * time.Hour),
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
TitleOriginal: "Sousou no Frieren",
TitleEnglish: sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
TitleJapanese: sql.NullString{String: "葬送のフリーレン", Valid: true},
ImageUrl: "https://cdn.example/frieren.webp",
Airing: sql.NullBool{Bool: false, Valid: true},
},
{
AnimeID: 2,
Status: "watching",
CreatedAt: addedAt.Add(24 * time.Hour),
CurrentEpisode: sql.NullInt64{Int64: 3, Valid: true},
CurrentTimeSeconds: 10,
PlaybackCurrentEpisode: sql.NullInt64{Int64: 7, Valid: true},
PlaybackCurrentTimeSeconds: sql.NullFloat64{Float64: 321.5, Valid: true},
PlaybackUpdatedAt: sql.NullTime{Time: lastWatchedAt, Valid: true},
TitleOriginal: "Dungeon Meshi",
ImageUrl: "https://cdn.example/dungeon.webp",
},
}
}
type publicWatchlistResponse struct {
SchemaVersion string `json:"schema_version"`
UserID string `json:"user_id"`
Summary struct {
Total int `json:"total"`
Completed int `json:"completed"`
Watching int `json:"watching"`
} `json:"summary"`
Entries []publicWatchlistResponseEntry `json:"entries"`
}
type publicWatchlistResponseEntry struct {
MALID int64 `json:"mal_id"`
Status string `json:"status"`
AddedAt string `json:"added_at"`
CompletedAt *string `json:"completed_at"`
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
LastWatchedAt *string `json:"last_watched_at"`
Progress struct {
CurrentEpisode *int64 `json:"current_episode"`
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
} `json:"progress"`
Titles struct {
Preferred string `json:"preferred"`
Original string `json:"original"`
English *string `json:"english"`
Japanese *string `json:"japanese"`
} `json:"titles"`
}
func assertPublicWatchlistEnvelope(t *testing.T, body publicWatchlistResponse) {
t.Helper()
assertEqual(t, "schema version", body.SchemaVersion, "1.0")
assertEqual(t, "user id", body.UserID, "user-42")
assertEqual(t, "summary total", body.Summary.Total, 2)
assertEqual(t, "summary completed", body.Summary.Completed, 1)
assertEqual(t, "summary watching", body.Summary.Watching, 1)
}
func assertCompletedPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, addedAt time.Time, completedAt time.Time) {
t.Helper()
assertEqual(t, "completed MAL id", entry.MALID, int64(1))
assertEqual(t, "completed status", entry.Status, "completed")
assertEqual(t, "added_at", entry.AddedAt, addedAt.Format(time.RFC3339))
assertPointerEqual(t, "completed_at", entry.CompletedAt, completedAt.Format(time.RFC3339))
assertPointerEqual(t, "completion date accuracy", entry.CompletionDateAccuracy, "exact")
assertEqual(t, "preferred title", entry.Titles.Preferred, "Frieren: Beyond Journey's End")
assertEqual(t, "original title", entry.Titles.Original, "Sousou no Frieren")
}
func assertWatchingPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, lastWatchedAt time.Time) {
t.Helper()
assertPointerEqual(t, "watching progress episode", entry.Progress.CurrentEpisode, int64(7))
assertPointerEqual(t, "watching progress seconds", entry.Progress.CurrentTimeSeconds, 321.5)
assertPointerEqual(t, "last_watched_at", entry.LastWatchedAt, lastWatchedAt.Format(time.RFC3339))
assertNilPointer(t, "watching completed_at", entry.CompletedAt)
assertNilPointer(t, "watching completion date accuracy", entry.CompletionDateAccuracy)
}
func assertEqual[T comparable](t *testing.T, label string, got T, want T) {
t.Helper()
if got != want {
t.Fatalf("%s = %v, want %v", label, got, want)
}
}
func assertPointerEqual[T comparable](t *testing.T, label string, got *T, want T) {
t.Helper()
if got == nil || *got != want {
t.Fatalf("%s = %v, want %v", label, got, want)
}
}
func assertNilPointer[T any](t *testing.T, label string, got *T) {
t.Helper()
if got != nil {
t.Fatalf("%s = %v, want null", label, got)
}
}
func assertMinimalPublicWatchlistJSON(t *testing.T, data []byte) {
t.Helper()
var raw struct {
RecommendationGuidance json.RawMessage `json:"recommendation_guidance"`
Entries []map[string]json.RawMessage `json:"entries"`
}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("decode raw response: %v", err)
}
if raw.RecommendationGuidance != nil {
t.Fatalf("recommendation_guidance should not be part of the public contract")
}
for _, key := range []string{
"updated_at",
"status_meaning",
"myanimelist_status",
"anilist_status",
"exclude_from_recommendations",
"recommendation_exclusion_reason",
"anime",
"external_ids",
"links",
} {
if _, exists := raw.Entries[0][key]; exists {
t.Fatalf("%s should not be part of the public contract", key)
}
}
}
func TestHandleGetPublicWatchlistCanDownloadJSON(t *testing.T) {
router := newWatchlistTestRouter(&fakeWatchlistService{})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist?download=1", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist-user-42.json"` {
t.Fatalf("Content-Disposition = %q", got)
}
}
func newWatchlistTestRouter(svc domain.WatchlistService) *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
@@ -134,6 +327,10 @@ type fakeWatchlistService struct {
deletedContinueUserID string
deletedContinueAnimeID int64
watchlist []domain.UserWatchListRow
watchlistUserID string
watchlistErr error
}
func (s *fakeWatchlistService) UpdateEntry(_ context.Context, userID string, animeID int64, status string) error {
@@ -149,8 +346,9 @@ func (s *fakeWatchlistService) RemoveEntry(_ context.Context, userID string, ani
return s.removeErr
}
func (s *fakeWatchlistService) GetWatchlist(context.Context, string) ([]domain.UserWatchListRow, error) {
return nil, nil
func (s *fakeWatchlistService) GetWatchlist(_ context.Context, userID string) ([]domain.UserWatchListRow, error) {
s.watchlistUserID = userID
return s.watchlist, s.watchlistErr
}
func (s *fakeWatchlistService) GetWatchlistMap(context.Context, string, []int64) (map[int64]bool, error) {