From 2bd0edb264a6fc8b6c49888613709fae238d291b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Fri, 29 May 2026 13:55:39 -0400 Subject: [PATCH] feat(calendar): resolve presets to id-sets and overlay watched status Also drops the now-unused Filter/UserID/ProfileID fields from the blendUpcomingIntoDiscoverRows CalendarFilter literal in recommendations.go, which only wants an unrestricted windowed query. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/api/handlers/calendar.go | 148 ++++++++++++++++++--- internal/api/handlers/calendar_test.go | 161 +++++++++++++++++++++++ internal/api/handlers/recommendations.go | 3 - 3 files changed, 294 insertions(+), 18 deletions(-) diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go index cecbb382..9925adb8 100644 --- a/internal/api/handlers/calendar.go +++ b/internal/api/handlers/calendar.go @@ -2,27 +2,62 @@ package handlers import ( "context" + "log/slog" "net/http" "sort" "strconv" "time" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" ) type calendarRepository interface { ListEvents(ctx context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error) } +// calendarPersonalRepo resolves per-profile id-sets and watched status. +type calendarPersonalRepo interface { + ListFollowedItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) + ListFavoriteItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) + ListWatchlistItemIDs(ctx context.Context, userID int, profileID string) ([]string, error) + ListWatchedItemIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]bool, error) +} + +// calendarPopularSource reads the cached server-wide popular id-set. +type calendarPopularSource interface { + GetRecommendationCache(ctx context.Context, userID int, profileID, recType, sourceItemID string) ([]recommendations.ScoredItem, error) +} + +// calendarTrendingSource reads the external-trending snapshot. +type calendarTrendingSource interface { + Get(ctx context.Context, source, window string) (sections.TrendingSnapshot, bool, error) +} + +const ( + calendarFilterAll = "all" + calendarFilterEverything = "everything" + calendarFilterFollowing = "following" + calendarFilterFavorites = "favorites" + calendarFilterWatchlist = "watchlist" + calendarFilterPopular = "popular" + calendarFilterTrending = "trending" +) + // CalendarHandler handles the calendar endpoint. type CalendarHandler struct { repo calendarRepository detailSvc *catalog.DetailService + personal calendarPersonalRepo // nil-tolerant (per-profile presets degrade to empty) + popular calendarPopularSource // nil when recommendations disabled + trending calendarTrendingSource // nil when trending disabled } -// NewCalendarHandler creates a new CalendarHandler. -func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService) *CalendarHandler { - return &CalendarHandler{repo: repo, detailSvc: detailSvc} +// NewCalendarHandler creates a new CalendarHandler. The repo doubles as the +// per-profile resolver since *catalog.CalendarRepository implements both. +func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService, popular calendarPopularSource, trending calendarTrendingSource) *CalendarHandler { + return &CalendarHandler{repo: repo, detailSvc: detailSvc, personal: repo, popular: popular, trending: trending} } // --- Response types --- @@ -42,6 +77,7 @@ type calendarEventResponse struct { LocalAirDate string `json:"local_air_date"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` + Watched bool `json:"watched"` Badges []string `json:"badges"` } @@ -79,33 +115,39 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque filter := q.Get("filter") if filter == "" { - filter = "all" + filter = calendarFilterAll } switch filter { - case "all", "favorites", "watchlist": + case calendarFilterAll, calendarFilterEverything, calendarFilterFollowing, + calendarFilterFavorites, calendarFilterWatchlist, calendarFilterPopular, calendarFilterTrending: default: - writeError(w, http.StatusBadRequest, "bad_request", "filter must be all, favorites, or watchlist") + writeError(w, http.StatusBadRequest, "bad_request", "invalid filter") return } af := requestAccessFilter(r) - if (filter == "favorites" || filter == "watchlist") && af.ProfileID == "" { - writeError(w, http.StatusBadRequest, "bad_request", "profile required for favorites/watchlist filter") + viewerLocation := catalog.CalendarLocation(q.Get("timezone")) + + restrict, ids, err := h.resolveCalendarRestriction(r.Context(), filter, af) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to resolve calendar filter") + return + } + // A restricting preset with no ids can never match — skip the windowed query. + if restrict && len(ids) == 0 { + writeJSON(w, http.StatusOK, calendarResponse{Events: []calendarDayResponse{}}) return } - - viewerLocation := catalog.CalendarLocation(q.Get("timezone")) cf := catalog.CalendarFilter{ Start: start.AddDate(0, 0, -2), End: end.AddDate(0, 0, 2), - Filter: filter, AllowedLibraryIDs: af.AllowedLibraryIDs, DisabledLibraryIDs: af.DisabledLibraryIDs, MaxContentRating: af.MaxContentRating, - UserID: af.UserID, - ProfileID: af.ProfileID, + RestrictByIDs: restrict, + RestrictToIDs: ids, } if v := q.Get("library_id"); v != "" { @@ -123,12 +165,87 @@ func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Reque return } + watched := h.resolveWatched(r.Context(), af, events) + // Group events by date and build response. - days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation) + days := groupEventsByDate(events, r, h.detailSvc, start, end, viewerLocation, watched) writeJSON(w, http.StatusOK, calendarResponse{Events: days}) } -func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location) []calendarDayResponse { +// resolveCalendarRestriction maps a preset to an id-set restriction. restrict=false +// means no restriction (everything/all). A nil/missing source degrades that preset +// to an empty id-set (which the caller renders as an empty calendar). +func (h *CalendarHandler) resolveCalendarRestriction(ctx context.Context, filter string, af catalog.AccessFilter) (restrict bool, ids []string, err error) { + switch filter { + case calendarFilterAll, calendarFilterEverything: + return false, nil, nil + case calendarFilterFollowing: + if h.personal == nil { + return true, nil, nil + } + ids, err = h.personal.ListFollowedItemIDs(ctx, af.UserID, af.ProfileID) + return true, ids, err + case calendarFilterFavorites: + if h.personal == nil { + return true, nil, nil + } + ids, err = h.personal.ListFavoriteItemIDs(ctx, af.UserID, af.ProfileID) + return true, ids, err + case calendarFilterWatchlist: + if h.personal == nil { + return true, nil, nil + } + ids, err = h.personal.ListWatchlistItemIDs(ctx, af.UserID, af.ProfileID) + return true, ids, err + case calendarFilterPopular: + if h.popular == nil { + return true, nil, nil + } + items, err := h.popular.GetRecommendationCache(ctx, recommendations.GlobalCacheUserID, recommendations.GlobalCacheProfileID, recommendations.RecTypePopular, "") + if err != nil { + return true, nil, err + } + ids = make([]string, 0, len(items)) + for _, it := range items { + ids = append(ids, it.MediaItemID) + } + return true, ids, nil + case calendarFilterTrending: + if h.trending == nil { + return true, nil, nil + } + snap, ok, err := h.trending.Get(ctx, "tmdb", "week") + if err != nil { + return true, nil, err + } + if !ok { + return true, nil, nil + } + return true, snap.ContentIDs, nil + default: + return false, nil, nil + } +} + +// resolveWatched decorates events with the profile's completed status. Best-effort: +// a lookup failure logs and returns no watched marks rather than failing the page. +func (h *CalendarHandler) resolveWatched(ctx context.Context, af catalog.AccessFilter, events []catalog.CalendarEvent) map[string]bool { + if h.personal == nil || len(events) == 0 { + return map[string]bool{} + } + ids := make([]string, 0, len(events)) + for _, ev := range events { + ids = append(ids, ev.ContentID) + } + watched, err := h.personal.ListWatchedItemIDs(ctx, af.UserID, af.ProfileID, ids) + if err != nil { + slog.WarnContext(ctx, "calendar watched overlay failed", "error", err) + return map[string]bool{} + } + return watched +} + +func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService, start, end time.Time, viewerLocation *time.Location, watched map[string]bool) []calendarDayResponse { if len(events) == 0 { return []calendarDayResponse{} } @@ -241,6 +358,7 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv LocalAirDate: item.localDate, PosterURL: posterURLs[ev.PosterPath], PosterThumbhash: ev.PosterThumbhash, + Watched: watched[ev.ContentID], Badges: badges, }) } diff --git a/internal/api/handlers/calendar_test.go b/internal/api/handlers/calendar_test.go index 7ffe5d62..0387e35c 100644 --- a/internal/api/handlers/calendar_test.go +++ b/internal/api/handlers/calendar_test.go @@ -9,6 +9,8 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/recommendations" + "github.com/Silo-Server/silo-server/internal/sections" ) type stubCalendarRepo struct { @@ -422,3 +424,162 @@ func ptrString(value string) *string { func ptrInt(value int) *int { return &value } + +type stubCalendarPersonal struct { + followed []string + favorites []string + watchlist []string + watched map[string]bool + lastWatchedIDs []string +} + +func (s *stubCalendarPersonal) ListFollowedItemIDs(_ context.Context, _ int, _ string) ([]string, error) { + return s.followed, nil +} +func (s *stubCalendarPersonal) ListFavoriteItemIDs(_ context.Context, _ int, _ string) ([]string, error) { + return s.favorites, nil +} +func (s *stubCalendarPersonal) ListWatchlistItemIDs(_ context.Context, _ int, _ string) ([]string, error) { + return s.watchlist, nil +} +func (s *stubCalendarPersonal) ListWatchedItemIDs(_ context.Context, _ int, _ string, ids []string) (map[string]bool, error) { + s.lastWatchedIDs = ids + return s.watched, nil +} + +type stubPopularSource struct{ items []recommendations.ScoredItem } + +func (s *stubPopularSource) GetRecommendationCache(_ context.Context, _ int, _, _, _ string) ([]recommendations.ScoredItem, error) { + return s.items, nil +} + +type stubTrendingSource struct { + snap sections.TrendingSnapshot + ok bool +} + +func (s *stubTrendingSource) Get(_ context.Context, _, _ string) (sections.TrendingSnapshot, bool, error) { + return s.snap, s.ok, nil +} + +func TestHandleGetCalendar_FollowingPassesFollowedIDs(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: []string{"s1", "s2"}}} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !repo.last.RestrictByIDs { + t.Fatalf("expected RestrictByIDs true") + } + if got := repo.last.RestrictToIDs; len(got) != 2 || got[0] != "s1" || got[1] != "s2" { + t.Fatalf("RestrictToIDs = %v, want [s1 s2]", got) + } +} + +func TestHandleGetCalendar_PopularReadsCache(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{ + repo: repo, + popular: &stubPopularSource{items: []recommendations.ScoredItem{{MediaItemID: "p1"}, {MediaItemID: "p2"}}}, + } + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=popular", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 2 || repo.last.RestrictToIDs[0] != "p1" { + t.Fatalf("RestrictToIDs = %v, want [p1 p2]", repo.last.RestrictToIDs) + } +} + +func TestHandleGetCalendar_TrendingReadsSnapshot(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{ + repo: repo, + trending: &stubTrendingSource{ok: true, snap: sections.TrendingSnapshot{ContentIDs: []string{"t1"}}}, + } + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=trending", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if !repo.last.RestrictByIDs || len(repo.last.RestrictToIDs) != 1 || repo.last.RestrictToIDs[0] != "t1" { + t.Fatalf("RestrictToIDs = %v, want [t1]", repo.last.RestrictToIDs) + } +} + +func TestHandleGetCalendar_EverythingHasNoRestriction(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if repo.last.RestrictByIDs { + t.Fatalf("expected no restriction for everything") + } +} + +func TestHandleGetCalendar_RejectsUnknownFilter(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=bogus", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if repo.calls != 0 { + t.Fatalf("expected repo not called, got %d", repo.calls) + } +} + +func TestHandleGetCalendar_EmptyFollowedShortCircuits(t *testing.T) { + repo := &stubCalendarRepo{} + handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{followed: nil}} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=following", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if repo.calls != 0 { + t.Fatalf("expected ListEvents skipped for empty followed set, got %d calls", repo.calls) + } +} + +func TestHandleGetCalendar_MarksWatchedItems(t *testing.T) { + repo := &stubCalendarRepo{events: []catalog.CalendarEvent{ + {ContentID: "ep-1", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)}, + {ContentID: "ep-2", Type: "episode", Title: "Show", AirDate: time.Date(2026, time.April, 8, 0, 0, 0, 0, time.UTC)}, + }} + handler := &CalendarHandler{repo: repo, personal: &stubCalendarPersonal{watched: map[string]bool{"ep-1": true}}} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-04-06&end=2026-04-12&filter=everything", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + var resp calendarResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + watched := map[string]bool{} + for _, day := range resp.Events { + for _, item := range day.Items { + watched[item.ContentID] = item.Watched + } + } + if !watched["ep-1"] || watched["ep-2"] { + t.Fatalf("watched flags = %v, want ep-1 true / ep-2 false", watched) + } +} diff --git a/internal/api/handlers/recommendations.go b/internal/api/handlers/recommendations.go index c6a57bb2..efa2b322 100644 --- a/internal/api/handlers/recommendations.go +++ b/internal/api/handlers/recommendations.go @@ -789,12 +789,9 @@ func (h *RecommendationsHandler) blendUpcomingIntoDiscoverRows( events, err := h.CalendarRepo.ListEvents(ctx, catalog.CalendarFilter{ Start: start, End: end, - Filter: "all", AllowedLibraryIDs: filter.AllowedLibraryIDs, DisabledLibraryIDs: filter.DisabledLibraryIDs, MaxContentRating: filter.MaxContentRating, - UserID: userID, - ProfileID: profileID, }) if err != nil { return discoverRows, err