refactor: decompose anime handler and parallelize for-you fetches
This commit is contained in:
193
internal/anime/command_palette.go
Normal file
193
internal/anime/command_palette.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,11 @@ package anime
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mal/integrations/animeschedule"
|
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/internal/observability"
|
"mal/internal/observability"
|
||||||
"mal/internal/server"
|
"mal/internal/server"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -34,11 +31,6 @@ type Service interface {
|
|||||||
domain.AnimeDetailsService
|
domain.AnimeDetailsService
|
||||||
}
|
}
|
||||||
|
|
||||||
type cachedWeekSchedule struct {
|
|
||||||
fetchedAt time.Time
|
|
||||||
value animeschedule.WeekSchedule
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
||||||
return &AnimeHandler{
|
return &AnimeHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
@@ -70,7 +62,6 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AnimeHandler) Register(r *gin.Engine) {
|
func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||||
|
|
||||||
r.GET("/", h.HandleCatalog)
|
r.GET("/", h.HandleCatalog)
|
||||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
||||||
r.GET("/api/catalog/popular", h.HandleCatalogPopular)
|
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) {
|
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
animeType := c.Query("type")
|
animeType := c.Query("type")
|
||||||
@@ -753,190 +666,6 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, output)
|
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) {
|
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
95
internal/anime/schedule.go
Normal file
95
internal/anime/schedule.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -192,23 +192,40 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
|
|||||||
|
|
||||||
limit := min(len(rankedIDs), 12)
|
limit := min(len(rankedIDs), 12)
|
||||||
|
|
||||||
animes := make([]domain.Anime, 0, limit)
|
animes := make([]domain.Anime, limit)
|
||||||
|
g.SetLimit(6)
|
||||||
|
|
||||||
for i := range limit {
|
for i := range limit {
|
||||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
|
g.Go(func() error {
|
||||||
if fetchErr != nil {
|
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
|
||||||
observability.Warn(
|
if fetchErr != nil {
|
||||||
"recommendation_anime_fetch_failed",
|
observability.Warn(
|
||||||
"anime",
|
"recommendation_anime_fetch_failed",
|
||||||
"",
|
"anime",
|
||||||
map[string]any{"anime_id": rankedIDs[i].id},
|
"",
|
||||||
fetchErr,
|
map[string]any{"anime_id": rankedIDs[i].id},
|
||||||
)
|
fetchErr,
|
||||||
continue
|
)
|
||||||
}
|
return nil
|
||||||
animes = append(animes, domain.Anime{Anime: anime})
|
}
|
||||||
|
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) {
|
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal stri
|
|||||||
func (r GetUserWatchListRow) DisplayTitle() string {
|
func (r GetUserWatchListRow) DisplayTitle() string {
|
||||||
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r GetContinueWatchingEntriesRow) DisplayTitle() string {
|
||||||
|
return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user