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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user