package handlers import ( "context" "net/http" "strconv" "time" "github.com/Silo-Server/silo-server/internal/catalog" ) type calendarRepository interface { ListEvents(ctx context.Context, f catalog.CalendarFilter) ([]catalog.CalendarEvent, error) } // CalendarHandler handles the calendar endpoint. type CalendarHandler struct { repo calendarRepository detailSvc *catalog.DetailService } // NewCalendarHandler creates a new CalendarHandler. func NewCalendarHandler(repo *catalog.CalendarRepository, detailSvc *catalog.DetailService) *CalendarHandler { return &CalendarHandler{repo: repo, detailSvc: detailSvc} } // --- Response types --- type calendarEventResponse struct { ContentID string `json:"content_id"` Type string `json:"type"` Title string `json:"title"` EpisodeTitle *string `json:"episode_title,omitempty"` SeriesID *string `json:"series_id,omitempty"` SeasonNumber *int `json:"season_number,omitempty"` EpisodeNumber *int `json:"episode_number,omitempty"` AirDate string `json:"air_date"` AirTime *string `json:"air_time,omitempty"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` Badges []string `json:"badges"` } type calendarDayResponse struct { Date string `json:"date"` Items []calendarEventResponse `json:"items"` } type calendarResponse struct { Events []calendarDayResponse `json:"events"` } // HandleGetCalendar returns calendar events for a date range. func (h *CalendarHandler) HandleGetCalendar(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() start, err := time.Parse("2006-01-02", q.Get("start")) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "start must be a valid date (YYYY-MM-DD)") return } end, err := time.Parse("2006-01-02", q.Get("end")) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "end must be a valid date (YYYY-MM-DD)") return } if end.Before(start) { writeError(w, http.StatusBadRequest, "bad_request", "end must not be before start") return } if end.Sub(start) > 30*24*time.Hour { writeError(w, http.StatusBadRequest, "bad_request", "date range cannot exceed 31 days") return } filter := q.Get("filter") if filter == "" { filter = "all" } switch filter { case "all", "favorites", "watchlist": default: writeError(w, http.StatusBadRequest, "bad_request", "filter must be all, favorites, or watchlist") return } af := requestAccessFilter(r) if (filter == "favorites" || filter == "watchlist") && af.ProfileID == "" { writeError(w, http.StatusBadRequest, "bad_request", "profile required for favorites/watchlist filter") return } cf := catalog.CalendarFilter{ Start: start, End: end, Filter: filter, AllowedLibraryIDs: af.AllowedLibraryIDs, DisabledLibraryIDs: af.DisabledLibraryIDs, MaxContentRating: af.MaxContentRating, UserID: af.UserID, ProfileID: af.ProfileID, } if v := q.Get("library_id"); v != "" { id, err := strconv.Atoi(v) if err != nil || id <= 0 { writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer") return } cf.LibraryID = &id } events, err := h.repo.ListEvents(r.Context(), cf) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to fetch calendar events") return } // Group events by date and build response. days := groupEventsByDate(events, r, h.detailSvc) writeJSON(w, http.StatusOK, calendarResponse{Events: days}) } func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSvc *catalog.DetailService) []calendarDayResponse { if len(events) == 0 { return []calendarDayResponse{} } posterURLs := map[string]string{} if detailSvc != nil { posterPaths := make([]string, 0, len(events)) seenPosterPaths := make(map[string]struct{}, len(events)) for _, ev := range events { if ev.PosterPath == "" { continue } if _, ok := seenPosterPaths[ev.PosterPath]; ok { continue } seenPosterPaths[ev.PosterPath] = struct{}{} posterPaths = append(posterPaths, ev.PosterPath) } posterURLs = detailSvc.PresignImageURLs(r.Context(), posterPaths, "poster", "small") } var days []calendarDayResponse var currentDay *calendarDayResponse for _, ev := range events { dateStr := ev.AirDate.Format("2006-01-02") if currentDay == nil || currentDay.Date != dateStr { if currentDay != nil { days = append(days, *currentDay) } currentDay = &calendarDayResponse{Date: dateStr} } badges := buildBadges(ev) currentDay.Items = append(currentDay.Items, calendarEventResponse{ ContentID: ev.ContentID, Type: ev.Type, Title: ev.Title, EpisodeTitle: ev.EpisodeTitle, SeriesID: ev.SeriesID, SeasonNumber: ev.SeasonNumber, EpisodeNumber: ev.EpisodeNumber, AirDate: dateStr, AirTime: ev.AirTime, PosterURL: posterURLs[ev.PosterPath], PosterThumbhash: ev.PosterThumbhash, Badges: badges, }) } if currentDay != nil { days = append(days, *currentDay) } return days } func buildBadges(ev catalog.CalendarEvent) []string { var badges []string if ev.IsPremiere { if ev.SeasonNumber != nil && *ev.SeasonNumber == 1 { badges = append(badges, "series_premiere") } else { badges = append(badges, "season_premiere") } } if ev.IsFinale { badges = append(badges, "finale") } if badges == nil { badges = []string{} } return badges }