refactor: extract watchlist map to service, optimize command palette queries

This commit is contained in:
2026-05-20 17:03:29 +02:00
committed by Mikkel Elvers
parent c4bd5cc395
commit 5482a40d47
5 changed files with 168 additions and 141 deletions

View File

@@ -26,6 +26,28 @@ func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistServi
}
}
func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int]bool {
animeIDs := make([]int64, 0, len(animes))
for _, anime := range animes {
if anime.MalID > 0 {
animeIDs = append(animeIDs, int64(anime.MalID))
}
}
return h.watchlistMapForIDs(ctx, userID, animeIDs)
}
func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int]bool {
if userID == "" || len(animeIDs) == 0 {
return map[int]bool{}
}
watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs)
if err != nil {
return map[int]bool{}
}
return watchlistMap
}
func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/", h.HandleCatalog)
@@ -47,18 +69,11 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
user, _ := c.Get("User")
watchlistMap := make(map[int]bool)
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
}
c.HTML(http.StatusOK, "index.gohtml", gin.H{
"CurrentPath": "/",
"User": user,
"WatchlistMap": watchlistMap,
"WatchlistMap": map[int]bool{},
})
}
@@ -85,12 +100,9 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
return
}
watchlistMap := make(map[int]bool)
if userID != "" {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
watchlistMap := map[int]bool{}
if animes, ok := data["Animes"].([]domain.Anime); ok {
watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
}
data["Section"] = section
@@ -130,12 +142,9 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
return
}
watchlistMap := make(map[int]bool)
if userID != "" {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
watchlistMap := map[int]bool{}
if animes, ok := data["Animes"].([]domain.Anime); ok {
watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
}
data["Section"] = section
@@ -170,13 +179,11 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
}
user, _ := c.Get("User")
watchlistMap := make(map[int]bool)
userID := ""
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
userID = u.ID
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
@@ -246,27 +253,30 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
section := c.Query("section")
if section != "" && c.GetHeader("HX-Request") == "true" {
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second)
defer cancel()
var data any
var tplName string
var err error
switch section {
case "characters":
data, err = h.svc.GetCharacters(c.Request.Context(), id)
data, err = h.svc.GetCharacters(sectionCtx, id)
tplName = "anime_characters"
case "recommendations":
data, err = h.svc.GetRecommendations(c.Request.Context(), id)
data, err = h.svc.GetRecommendations(sectionCtx, id)
tplName = "anime_recommendations"
case "statistics":
data, err = h.svc.GetStatistics(c.Request.Context(), id)
data, err = h.svc.GetStatistics(sectionCtx, id)
tplName = "anime_statistics"
case "themes":
data, err = h.svc.GetThemes(c.Request.Context(), id)
data, err = h.svc.GetThemes(sectionCtx, id)
tplName = "anime_themes"
}
if err != nil {
c.Status(http.StatusInternalServerError)
c.String(http.StatusOK, "")
return
}
@@ -326,19 +336,22 @@ func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
userID = u.ID
}
relations, err := h.svc.GetRelations(c.Request.Context(), id)
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
relations, err := h.svc.GetRelations(relationsCtx, id)
if err != nil {
c.Status(http.StatusInternalServerError)
c.String(http.StatusOK, "")
return
}
watchlistMap := make(map[int]bool)
if userID != "" {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
relationAnimeIDs := make([]int64, 0, len(relations))
for _, relation := range relations {
if relation.Anime.MalID > 0 {
relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID))
}
}
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs)
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"_fragment": "watch_order",
@@ -362,13 +375,11 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
}
user, _ := c.Get("User")
watchlistMap := make(map[int]bool)
userID := ""
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
watchlistMap[int(e.AnimeID)] = true
}
userID = u.ID
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes)
type quickSearchResult struct {
ID int `json:"id"`
@@ -462,7 +473,7 @@ func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPale
}
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string) []commandPaletteItem {
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 1500*time.Millisecond)
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel()
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5)
@@ -487,35 +498,23 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string)
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
items := make([]commandPaletteItem, 0, 5)
watchlist, err := h.watchlistSvc.GetWatchlist(c.Request.Context(), userID)
watchlist, err := h.watchlistSvc.GetCommandPaletteWatchlist(c.Request.Context(), userID, query, 5)
if err != nil {
return items
}
watchlistCount := 0
for _, status := range []string{"watching", "plan_to_watch"} {
for _, entry := range watchlist {
if watchlistCount >= 5 {
return items
}
if entry.Status != status {
continue
}
title := watchlistTitle(entry)
if query != "" && !commandPaletteMatches(query, title, entry.Status) {
continue
}
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,
})
watchlistCount++
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
}
}
@@ -525,33 +524,29 @@ func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string
func (h *AnimeHandler) commandPaletteContinueItems(c *gin.Context, userID string, query string) []commandPaletteItem {
items := make([]commandPaletteItem, 0, 5)
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, "Continue")
if err == nil {
if rows, ok := data["ContinueWatching"].([]db.GetContinueWatchingEntriesRow); ok {
for _, row := range rows {
if len(items) >= 5 {
break
}
rows, err := h.watchlistSvc.GetCommandPaletteContinueWatching(c.Request.Context(), userID, query, 5)
if err != nil {
return items
}
title := continueWatchingTitle(row)
if query != "" && !commandPaletteMatches(query, title, "Continue watching") {
continue
}
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,
})
}
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
}
}
@@ -594,7 +589,10 @@ func watchlistStatusLabel(status string) string {
}
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
anime, err := h.svc.GetRandomAnime(c.Request.Context())
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
anime, err := h.svc.GetRandomAnime(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"})
return
@@ -607,13 +605,8 @@ func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
user, _ := c.Get("User")
inWatchlist := false
if u, ok := user.(*domain.User); ok {
entries, _ := h.watchlistSvc.GetWatchlist(c.Request.Context(), u.ID)
for _, e := range entries {
if int(e.AnimeID) == anime.MalID {
inWatchlist = true
break
}
}
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), u.ID, []int64{int64(anime.MalID)})
inWatchlist = watchlistMap[anime.MalID]
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -5,6 +5,8 @@ import (
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"math/rand"
"time"
"golang.org/x/sync/errgroup"
)
@@ -20,9 +22,8 @@ func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.An
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
watchlist []db.GetUserWatchListRow
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
)
g, gCtx := errgroup.WithContext(ctx)
@@ -38,18 +39,10 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
return err
})
if userID != "" {
g.Go(func() error {
if section == "Continue" {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
}
return nil
})
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
watchlist, err = s.repo.GetUserWatchList(gCtx, userID)
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
})
}
@@ -63,23 +56,14 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
animes = animes[:6]
}
watchlistMap := make(map[int64]bool)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
return map[string]any{
"Animes": animes,
"ContinueWatching": cw,
"WatchlistMap": watchlistMap,
}, nil
}
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) {
var (
res jikan.TopAnimeResult
watchlist []db.GetUserWatchListRow
)
var res jikan.TopAnimeResult
g, gCtx := errgroup.WithContext(ctx)
@@ -96,14 +80,6 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
return err
})
if userID != "" {
g.Go(func() error {
var err error
watchlist, err = s.repo.GetUserWatchList(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
@@ -113,14 +89,8 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
animes = animes[:8]
}
watchlistMap := make(map[int64]bool)
for _, entry := range watchlist {
watchlistMap[entry.AnimeID] = true
}
return map[string]any{
"Animes": animes,
"WatchlistMap": watchlistMap,
"Animes": animes,
}, nil
}
@@ -173,7 +143,27 @@ func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]doma
}
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
return s.jikan.GetRandomAnime(ctx)
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
anime, err := s.jikan.GetRandomAnime(randomCtx)
if err == nil {
return anime, nil
}
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
s.jikan.GetSeasonsNow,
s.jikan.GetTopAnime,
s.jikan.GetSeasonsUpcoming,
} {
res, fallbackErr := fallback(ctx, 1)
if fallbackErr != nil || len(res.Animes) == 0 {
continue
}
return res.Animes[rand.Intn(len(res.Animes))], nil
}
return domain.Anime{}, err
}
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {

View File

@@ -12,6 +12,9 @@ type WatchlistService interface {
UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error
RemoveEntry(ctx context.Context, userID string, animeID int64) error
GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error)
GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int]bool, error)
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]UserWatchListRow, error)
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error)
DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error
@@ -23,6 +26,9 @@ type WatchlistRepository interface {
UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error)
GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]db.GetUserWatchListRow, error)
GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error

View File

@@ -34,6 +34,18 @@ func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID strin
return r.queries.GetUserWatchList(ctx, userID)
}
func (r *watchlistRepository) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error) {
return r.queries.GetUserWatchlistAnimeIDs(ctx, userID, animeIDs)
}
func (r *watchlistRepository) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]db.GetUserWatchListRow, error) {
return r.queries.GetCommandPaletteWatchlist(ctx, userID, query, limit)
}
func (r *watchlistRepository) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
return r.queries.GetCommandPaletteContinueWatching(ctx, userID, query, limit)
}
func (r *watchlistRepository) GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.GetWatchListEntry(ctx, arg)
}

View File

@@ -55,6 +55,32 @@ func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]d
return s.repo.GetUserWatchList(ctx, userID)
}
func (s *watchlistService) GetWatchlistMap(ctx context.Context, userID string, animeIDs []int64) (map[int]bool, error) {
watchlistMap := make(map[int]bool)
if userID == "" || len(animeIDs) == 0 {
return watchlistMap, nil
}
matches, err := s.repo.GetUserWatchlistAnimeIDs(ctx, userID, animeIDs)
if err != nil {
return watchlistMap, err
}
for _, animeID := range matches {
watchlistMap[int(animeID)] = true
}
return watchlistMap, nil
}
func (s *watchlistService) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]domain.UserWatchListRow, error) {
return s.repo.GetCommandPaletteWatchlist(ctx, userID, query, limit)
}
func (s *watchlistService) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
return s.repo.GetCommandPaletteContinueWatching(ctx, userID, query, limit)
}
func (s *watchlistService) GetWatchListEntry(ctx context.Context, userID string, animeID int64) (db.WatchListEntry, error) {
return s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,