From 532e03d354524a5b5a988395f4de50bf1af15a54 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Thu, 28 May 2026 17:45:56 +0200 Subject: [PATCH] refactor: decompose anime handler and parallelize for-you fetches --- internal/anime/command_palette.go | 193 +++++++++++++++++++++ internal/anime/handler.go | 271 ------------------------------ internal/anime/schedule.go | 95 +++++++++++ internal/anime/service.go | 45 +++-- internal/db/helpers.go | 4 + 5 files changed, 323 insertions(+), 285 deletions(-) create mode 100644 internal/anime/command_palette.go create mode 100644 internal/anime/schedule.go diff --git a/internal/anime/command_palette.go b/internal/anime/command_palette.go new file mode 100644 index 0000000..fe6ef77 --- /dev/null +++ b/internal/anime/command_palette.go @@ -0,0 +1,193 @@ +package anime + +import ( + "context" + "fmt" + "mal/internal/db" + "mal/internal/domain" + "mal/internal/server" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type commandPaletteItem struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label"` + Subtitle string `json:"subtitle"` + Href string `json:"href"` + Image string `json:"image,omitempty"` + Icon string `json:"icon,omitempty"` +} + +func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) { + user := server.CurrentUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + query := strings.TrimSpace(c.Query("q")) + items := make([]commandPaletteItem, 0, 12) + + if query != "" { + items = append(items, commandPaletteItem{ + ID: "search:" + strings.ToLower(query), + Type: "search", + Label: fmt.Sprintf("Search anime for %q", query), + Subtitle: "Browse", + Href: "/browse?q=" + url.QueryEscape(query), + Icon: "search", + }) + + if len(query) >= 2 { + items = append(items, h.commandPaletteAnimeResults(c, query)...) + } + + items = append(items, h.commandPaletteNavigationItems(query)...) + items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...) + items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...) + c.JSON(http.StatusOK, items) + return + } + + items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...) + items = append(items, h.commandPaletteNavigationItems(query)...) + items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...) + c.JSON(http.StatusOK, items) +} + +func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem { + all := []commandPaletteItem{ + {ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"}, + {ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"}, + {ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"}, + {ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"}, + } + if query == "" { + return all + } + + filtered := make([]commandPaletteItem, 0, len(all)) + for _, item := range all { + if commandPaletteMatches(query, item.Label, item.Subtitle) { + filtered = append(filtered, item) + } + } + return filtered +} + +func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem { + searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond) + defer cancel() + + res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5) + if err != nil { + return nil + } + + animes := wrapAnimes(res.Animes) + items := make([]commandPaletteItem, 0, len(animes)) + for _, anime := range animes { + items = append(items, commandPaletteItem{ + ID: fmt.Sprintf("anime:%d", anime.MalID), + Type: "anime", + Label: anime.DisplayTitle(), + Subtitle: strings.TrimSpace("Anime " + anime.Type), + Href: fmt.Sprintf("/anime/%d", anime.MalID), + Image: anime.ImageURL(), + }) + } + return items +} + +func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem { + items := make([]commandPaletteItem, 0, 5) + + watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5) + if err != nil { + return items + } + + for _, entry := range watchlist { + title := watchlistTitle(entry) + items = append(items, commandPaletteItem{ + ID: fmt.Sprintf("watchlist:%d", entry.AnimeID), + Type: "watchlist", + Label: title, + Subtitle: watchlistStatusLabel(entry.Status), + Href: fmt.Sprintf("/anime/%d", entry.AnimeID), + Image: entry.ImageUrl, + }) + if len(items) >= 5 { + return items + } + } + + return items +} + +func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem { + items := make([]commandPaletteItem, 0, 5) + + rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5) + if err != nil { + return items + } + + for _, row := range rows { + title := continueWatchingTitle(row) + episode := "" + href := fmt.Sprintf("/anime/%d/watch", row.AnimeID) + if row.CurrentEpisode.Valid { + episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64) + href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64) + } + items = append(items, commandPaletteItem{ + ID: fmt.Sprintf("continue:%d", row.AnimeID), + Type: "continue", + Label: "Continue watching " + title, + Subtitle: "Resume" + episode, + Href: href, + Image: row.ImageUrl, + }) + if len(items) >= 5 { + return items + } + } + + return items +} + +func commandPaletteMatches(query string, values ...string) bool { + needle := strings.ToLower(strings.TrimSpace(query)) + for _, value := range values { + if strings.Contains(strings.ToLower(value), needle) { + return true + } + } + return false +} + +func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string { + return row.DisplayTitle() +} + +func watchlistTitle(row domain.UserWatchListRow) string { + return row.DisplayTitle() +} + +func watchlistStatusLabel(status string) string { + switch status { + case "watching": + return "Watching" + case "plan_to_watch": + return "Plan to Watch" + default: + return "Watchlist" + } +} diff --git a/internal/anime/handler.go b/internal/anime/handler.go index e3165b0..f3e3686 100644 --- a/internal/anime/handler.go +++ b/internal/anime/handler.go @@ -3,14 +3,11 @@ package anime import ( "context" "fmt" - "mal/integrations/animeschedule" "mal/integrations/jikan" - "mal/internal/db" "mal/internal/domain" "mal/internal/observability" "mal/internal/server" "net/http" - "net/url" "strconv" "strings" "sync" @@ -34,11 +31,6 @@ type Service interface { domain.AnimeDetailsService } -type cachedWeekSchedule struct { - fetchedAt time.Time - value animeschedule.WeekSchedule -} - func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService) *AnimeHandler { return &AnimeHandler{ svc: svc, @@ -70,7 +62,6 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an } func (h *AnimeHandler) Register(r *gin.Engine) { - r.GET("/", h.HandleCatalog) r.GET("/api/catalog/airing", h.HandleCatalogAiring) r.GET("/api/catalog/popular", h.HandleCatalogPopular) @@ -364,84 +355,6 @@ func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) { }) } -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") @@ -753,190 +666,6 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { c.JSON(http.StatusOK, output) } -type commandPaletteItem struct { - ID string `json:"id"` - Type string `json:"type"` - Label string `json:"label"` - Subtitle string `json:"subtitle"` - Href string `json:"href"` - Image string `json:"image,omitempty"` - Icon string `json:"icon,omitempty"` -} - -func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) { - user := server.CurrentUser(c) - if user == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - query := strings.TrimSpace(c.Query("q")) - items := make([]commandPaletteItem, 0, 12) - - if query != "" { - items = append(items, commandPaletteItem{ - ID: "search:" + strings.ToLower(query), - Type: "search", - Label: fmt.Sprintf("Search anime for %q", query), - Subtitle: "Browse", - Href: "/browse?q=" + url.QueryEscape(query), - Icon: "search", - }) - - if len(query) >= 2 { - items = append(items, h.commandPaletteAnimeResults(c, query)...) - } - - items = append(items, h.commandPaletteNavigationItems(query)...) - items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...) - items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...) - c.JSON(http.StatusOK, items) - return - } - - items = append(items, h.commandPaletteContinueItems(c, user.ID, query)...) - items = append(items, h.commandPaletteNavigationItems(query)...) - items = append(items, h.commandPalettePersonalItems(c, user.ID, query)...) - c.JSON(http.StatusOK, items) -} - -func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPaletteItem { - all := []commandPaletteItem{ - {ID: "nav:discover", Type: "navigation", Label: "Go to Discover", Subtitle: "Navigation", Href: "/discover", Icon: "compass"}, - {ID: "nav:watchlist", Type: "navigation", Label: "Go to Watchlist", Subtitle: "Navigation", Href: "/watchlist", Icon: "bookmark"}, - {ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=desc", Icon: "trending"}, - {ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=desc", Icon: "play"}, - } - if query == "" { - return all - } - - filtered := make([]commandPaletteItem, 0, len(all)) - for _, item := range all { - if commandPaletteMatches(query, item.Label, item.Subtitle) { - filtered = append(filtered, item) - } - } - return filtered -} - -func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem { - searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond) - defer cancel() - - res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5) - if err != nil { - return nil - } - - animes := wrapAnimes(res.Animes) - items := make([]commandPaletteItem, 0, len(animes)) - for _, anime := range animes { - items = append(items, commandPaletteItem{ - ID: fmt.Sprintf("anime:%d", anime.MalID), - Type: "anime", - Label: anime.DisplayTitle(), - Subtitle: strings.TrimSpace("Anime " + anime.Type), - Href: fmt.Sprintf("/anime/%d", anime.MalID), - Image: anime.ImageURL(), - }) - } - return items -} - -func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem { - items := make([]commandPaletteItem, 0, 5) - - watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5) - if err != nil { - return items - } - - for _, entry := range watchlist { - title := watchlistTitle(entry) - items = append(items, commandPaletteItem{ - ID: fmt.Sprintf("watchlist:%d", entry.AnimeID), - Type: "watchlist", - Label: title, - Subtitle: watchlistStatusLabel(entry.Status), - Href: fmt.Sprintf("/anime/%d", entry.AnimeID), - Image: entry.ImageUrl, - }) - if len(items) >= 5 { - return items - } - } - - return items -} - -func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem { - items := make([]commandPaletteItem, 0, 5) - - rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5) - if err != nil { - return items - } - - for _, row := range rows { - title := continueWatchingTitle(row) - episode := "" - href := fmt.Sprintf("/anime/%d/watch", row.AnimeID) - if row.CurrentEpisode.Valid { - episode = fmt.Sprintf(" episode %d", row.CurrentEpisode.Int64) - href = fmt.Sprintf("%s?ep=%d", href, row.CurrentEpisode.Int64) - } - items = append(items, commandPaletteItem{ - ID: fmt.Sprintf("continue:%d", row.AnimeID), - Type: "continue", - Label: "Continue watching " + title, - Subtitle: "Resume" + episode, - Href: href, - Image: row.ImageUrl, - }) - if len(items) >= 5 { - return items - } - } - - return items -} - -func commandPaletteMatches(query string, values ...string) bool { - needle := strings.ToLower(strings.TrimSpace(query)) - for _, value := range values { - if strings.Contains(strings.ToLower(value), needle) { - return true - } - } - return false -} - -func continueWatchingTitle(row db.GetContinueWatchingEntriesRow) string { - if row.TitleEnglish.Valid && row.TitleEnglish.String != "" { - return row.TitleEnglish.String - } - return row.TitleOriginal -} - -func watchlistTitle(row domain.UserWatchListRow) string { - if row.TitleEnglish.Valid && row.TitleEnglish.String != "" { - return row.TitleEnglish.String - } - return row.TitleOriginal -} - -func watchlistStatusLabel(status string) string { - switch status { - case "watching": - return "Watching" - case "plan_to_watch": - return "Plan to Watch" - default: - return "Watchlist" - } -} - func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() diff --git a/internal/anime/schedule.go b/internal/anime/schedule.go new file mode 100644 index 0000000..fa499aa --- /dev/null +++ b/internal/anime/schedule.go @@ -0,0 +1,95 @@ +package anime + +import ( + "context" + "fmt" + "mal/integrations/animeschedule" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type cachedWeekSchedule struct { + fetchedAt time.Time + value animeschedule.WeekSchedule +} + +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() +} diff --git a/internal/anime/service.go b/internal/anime/service.go index c826ab2..1358baa 100644 --- a/internal/anime/service.go +++ b/internal/anime/service.go @@ -192,23 +192,40 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do limit := min(len(rankedIDs), 12) - animes := make([]domain.Anime, 0, limit) + animes := make([]domain.Anime, limit) + g.SetLimit(6) + for i := range limit { - anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id) - if fetchErr != nil { - observability.Warn( - "recommendation_anime_fetch_failed", - "anime", - "", - map[string]any{"anime_id": rankedIDs[i].id}, - fetchErr, - ) - continue - } - animes = append(animes, domain.Anime{Anime: anime}) + g.Go(func() error { + anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id) + if fetchErr != nil { + observability.Warn( + "recommendation_anime_fetch_failed", + "anime", + "", + map[string]any{"anime_id": rankedIDs[i].id}, + fetchErr, + ) + return nil + } + animes[i] = domain.Anime{Anime: anime} + return nil + }) } - return domain.DiscoverSectionData{Animes: animes}, nil + if err := g.Wait(); err != nil { + return domain.DiscoverSectionData{}, err + } + + // Filter out empty animes if any fetch failed silently + filtered := make([]domain.Anime, 0, len(animes)) + for _, a := range animes { + if a.MalID > 0 { + filtered = append(filtered, a) + } + } + + return domain.DiscoverSectionData{Animes: filtered}, nil } func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) { diff --git a/internal/db/helpers.go b/internal/db/helpers.go index 537f857..138cf50 100644 --- a/internal/db/helpers.go +++ b/internal/db/helpers.go @@ -18,3 +18,7 @@ func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal stri func (r GetUserWatchListRow) DisplayTitle() string { return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal) } + +func (r GetContinueWatchingEntriesRow) DisplayTitle() string { + return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal) +}