feat: wire scraped schedule into handler with caching and week nav

This commit is contained in:
2026-05-27 10:56:37 +02:00
parent c044ebdda0
commit 5dd6eedc3f
4 changed files with 279 additions and 53 deletions

View File

@@ -3,6 +3,7 @@ package handler
import (
"context"
"fmt"
"mal/integrations/animeschedule"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
@@ -12,6 +13,7 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -20,6 +22,14 @@ import (
type AnimeHandler struct {
svc domain.AnimeService
watchlistSvc domain.WatchlistService
scheduleCacheMu sync.Mutex
scheduleCache map[string]cachedWeekSchedule
}
type cachedWeekSchedule struct {
fetchedAt time.Time
value animeschedule.WeekSchedule
}
func wrapAnimes(in []jikan.Anime) []domain.Anime {
@@ -32,8 +42,9 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime {
func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
svc: svc,
watchlistSvc: watchlistSvc,
scheduleCache: map[string]cachedWeekSchedule{},
}
}
@@ -310,43 +321,140 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
user, _ := c.Get("User")
year, week := parseYearWeek(c)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"CurrentPath": "/schedule",
"User": user,
"CurrentPath": "/schedule",
"User": user,
"ScheduleYear": year,
"ScheduleWeek": week,
})
}
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
year, week := parseYearWeek(c)
animes, err := h.svc.GetAiringSchedule(c.Request.Context(), userID)
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week)
if err != nil {
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
observability.Warn(
"schedule_fetch_failed",
"animeschedule_fetch_failed",
"anime",
"",
map[string]any{
"user_id": userID,
"year": year,
"week": week,
},
err,
)
c.AbortWithStatus(http.StatusInternalServerError)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"_fragment": "schedule_section_scraped",
"ScheduleDays": []any{},
"ScheduleYear": year,
"ScheduleWeek": week,
"PrevYear": prevYear,
"PrevWeek": prevWeek,
"NextYear": nextYear,
"NextWeek": nextWeek,
"ScheduleError": true,
})
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
days := buildScheduleDays(schedule, schedule.Year, schedule.Week)
prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1)
nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1)
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
"_fragment": "schedule_section",
"Animes": animes,
"WatchlistMap": watchlistMap,
"_fragment": "schedule_section_scraped",
"ScheduleDays": days,
"ScheduleYear": schedule.Year,
"ScheduleWeek": schedule.Week,
"PrevYear": prevYear,
"PrevWeek": prevWeek,
"NextYear": nextYear,
"NextWeek": nextWeek,
})
}
func parseYearWeek(c *gin.Context) (int, int) {
year, _ := strconv.Atoi(c.Query("year"))
week, _ := strconv.Atoi(c.Query("week"))
if year <= 0 || week <= 0 {
now := time.Now()
y, w := now.ISOWeek()
if year <= 0 {
year = y
}
if week <= 0 {
week = w
}
}
return year, week
}
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int) (animeschedule.WeekSchedule, error) {
cacheKey := fmt.Sprintf("%d-%02d", year, week)
const ttl = 10 * time.Minute
h.scheduleCacheMu.Lock()
cached, ok := h.scheduleCache[cacheKey]
h.scheduleCacheMu.Unlock()
if ok && time.Since(cached.fetchedAt) < ttl {
return cached.value, nil
}
value, err := animeschedule.FetchWeek(ctx, nil, year, week)
if err != nil {
return animeschedule.WeekSchedule{}, err
}
h.scheduleCacheMu.Lock()
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
h.scheduleCacheMu.Unlock()
return value, nil
}
type scheduleDayView struct {
DateLabel string
WeekdayLabel string
Entries []animeschedule.Entry
}
func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView {
start := isoWeekStartMonday(year, week)
order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}
out := make([]scheduleDayView, 0, 7)
for i, wd := range order {
date := start.AddDate(0, 0, i)
out = append(out, scheduleDayView{
DateLabel: strings.ToUpper(date.Format("02 Jan")),
WeekdayLabel: wd.String(),
Entries: schedule.Days[wd],
})
}
return out
}
func isoWeekStartMonday(year int, week int) time.Time {
// ISO week 1 is the week with the year's first Thursday in it.
jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local)
// Move back to Monday
offset := int(time.Monday - jan4.Weekday())
if offset > 0 {
offset -= 7
}
week1Monday := jan4.AddDate(0, 0, offset)
return week1Monday.AddDate(0, 0, (week-1)*7)
}
func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) {
target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7)
return target.ISOWeek()
}
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
q := c.Query("q")
animeType := c.Query("type")