Merge branch 'upstream/main' into main
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 9m21s
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 9m21s
This commit is contained in:
391
internal/anime/browse_handler.go
Normal file
391
internal/anime/browse_handler.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type producerItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type browseQuery struct {
|
||||
q string
|
||||
animeType string
|
||||
status string
|
||||
orderBy string
|
||||
sort string
|
||||
sfw bool
|
||||
studioID int
|
||||
genres []int
|
||||
page int
|
||||
}
|
||||
|
||||
func producerQueryParams(c *gin.Context) (string, int, int, error) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if err != nil {
|
||||
return "", 0, 0, fmt.Errorf("invalid limit")
|
||||
}
|
||||
if limit < 1 || limit > 12 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
return q, page, limit, nil
|
||||
}
|
||||
|
||||
func producerItems(entries []jikan.ProducerListEntry) []producerItem {
|
||||
items := make([]producerItem, 0, len(entries))
|
||||
for _, producer := range entries {
|
||||
name := jikan.ProducerListEntryName(producer)
|
||||
if producer.MalID <= 0 || name == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, producerItem{ID: producer.MalID, Name: name})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func producerHTMLPayload(items []producerItem, hasNextPage bool, page int, q string, limit int) gin.H {
|
||||
return gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": items,
|
||||
"HasNextPage": hasNextPage,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
}
|
||||
}
|
||||
|
||||
func requestWantsHTML(c *gin.Context) bool {
|
||||
return strings.Contains(c.GetHeader("Accept"), "text/html")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
||||
q, page, limit, err := producerQueryParams(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if requestWantsHTML(c) {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload([]producerItem{}, false, page, q, limit))
|
||||
return
|
||||
}
|
||||
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"failed to load producers",
|
||||
map[string]any{"q": q, "page": page, "limit": limit},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
items := producerItems(res.Items)
|
||||
|
||||
if requestWantsHTML(c) {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload(items, res.HasNextPage, page, q, limit))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"hasNextPage": res.HasNextPage,
|
||||
"nextPage": page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func parseBrowseQuery(c *gin.Context) (browseQuery, error) {
|
||||
studioID := 0
|
||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id < 0 {
|
||||
return browseQuery{}, fmt.Errorf("invalid studio id")
|
||||
}
|
||||
studioID = id
|
||||
}
|
||||
|
||||
genres := make([]int, 0, len(c.QueryArray("genres")))
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, err := strconv.Atoi(g)
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid genre id")
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return browseQuery{}, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
return browseQuery{
|
||||
q: c.Query("q"),
|
||||
animeType: c.Query("type"),
|
||||
status: c.Query("status"),
|
||||
orderBy: c.Query("order_by"),
|
||||
sort: c.Query("sort"),
|
||||
sfw: c.Query("sfw") != "false",
|
||||
studioID: studioID,
|
||||
genres: genres,
|
||||
page: page,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func browseStudioName(ctx context.Context, svc Service, studioID int) string {
|
||||
if studioID <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
name, err := svc.GetProducerNameByID(ctx, studioID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func browseTemplateData(
|
||||
q browseQuery,
|
||||
studioName string,
|
||||
genresList []domain.Genre,
|
||||
animes []domain.Anime,
|
||||
user any,
|
||||
watchlistMap map[int64]bool,
|
||||
hasNextPage bool,
|
||||
) gin.H {
|
||||
return gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q.q,
|
||||
"Type": q.animeType,
|
||||
"Status": q.status,
|
||||
"OrderBy": q.orderBy,
|
||||
"Sort": q.sort,
|
||||
"Genres": q.genres,
|
||||
"Studio": q.studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": q.sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": animes,
|
||||
"HasNextPage": hasNextPage,
|
||||
"NextPage": q.page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) searchBrowse(ctx context.Context, query browseQuery) (jikan.SearchResult, error) {
|
||||
return h.svc.SearchAdvanced(
|
||||
ctx,
|
||||
query.q,
|
||||
query.animeType,
|
||||
query.status,
|
||||
query.orderBy,
|
||||
query.sort,
|
||||
query.genres,
|
||||
query.studioID,
|
||||
query.sfw,
|
||||
query.page,
|
||||
24,
|
||||
)
|
||||
}
|
||||
|
||||
func browseScrollData(
|
||||
query browseQuery,
|
||||
studioName string,
|
||||
animes []domain.Anime,
|
||||
watchlistMap map[int64]bool,
|
||||
hasNextPage bool,
|
||||
) gin.H {
|
||||
return gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": animes,
|
||||
"NextPage": query.page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"Query": query.q,
|
||||
"Type": query.animeType,
|
||||
"Status": query.status,
|
||||
"OrderBy": query.orderBy,
|
||||
"Sort": query.sort,
|
||||
"Genres": query.genres,
|
||||
"Studio": query.studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": query.sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuery, err error) {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"browse_search_failed",
|
||||
"anime",
|
||||
"failed to load browse results",
|
||||
map[string]any{
|
||||
"q": query.q,
|
||||
"type": query.animeType,
|
||||
"status": query.status,
|
||||
"order_by": query.orderBy,
|
||||
"sort": query.sort,
|
||||
"studio": query.studioID,
|
||||
"sfw": query.sfw,
|
||||
"page": query.page,
|
||||
},
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
query, err := parseBrowseQuery(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.searchBrowse(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
h.respondBrowseSearchError(c, query, err)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
studioName := browseStudioName(c.Request.Context(), h.svc, query.studioID)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseScrollData(query, studioName, animes, watchlistMap, res.HasNextPage))
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
browseData := browseTemplateData(query, studioName, genresList, animes, user, watchlistMap, res.HasNextPage)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
browseData["_fragment"] = "browse_content"
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
}
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Year int `json:"year"`
|
||||
Image string `json:"image"`
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
output := make([]quickSearchResult, len(animes))
|
||||
for i, anime := range animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.svc.GetRandomAnime(ctx)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"random_anime_fetch_failed",
|
||||
"anime",
|
||||
"failed to fetch random anime",
|
||||
nil,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
inWatchlist := false
|
||||
userID := server.CurrentUserID(c)
|
||||
if userID != "" {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": anime,
|
||||
"in_watchlist": inWatchlist,
|
||||
})
|
||||
}
|
||||
123
internal/anime/catalog_handler.go
Normal file
123
internal/anime/catalog_handler.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "search.gohtml", gin.H{
|
||||
"User": server.CurrentUser(c),
|
||||
"CurrentPath": "/search",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"top_pick_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = "TopPickForYou"
|
||||
data.Fragment = "top_pick_for_you_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleTopPicks(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
"top_picks_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
c.HTML(http.StatusOK, "top_picks.gohtml", gin.H{
|
||||
"CurrentPath": "/top-picks",
|
||||
"User": user,
|
||||
"Animes": data.Animes,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||
observability.WarnContext(c.Request.Context(),
|
||||
event,
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const commandPaletteAnimeLimit = 24
|
||||
|
||||
type commandPaletteItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -24,6 +24,12 @@ type commandPaletteItem struct {
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type commandPaletteResponse struct {
|
||||
Items []commandPaletteItem `json:"items"`
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
NextPage int `json:"nextPage,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
if user == nil {
|
||||
@@ -32,41 +38,49 @@ func (h *AnimeHandler) HandleCommandPalette(c *gin.Context) {
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
items := make([]commandPaletteItem, 0, 12)
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil || page < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]commandPaletteItem, 0, commandPaletteAnimeLimit)
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
hasNextPage := false
|
||||
if len(query) >= 2 {
|
||||
items = append(items, h.commandPaletteAnimeResults(c, query)...)
|
||||
var animeItems []commandPaletteItem
|
||||
animeItems, hasNextPage = h.commandPaletteAnimeResults(c, query, page)
|
||||
items = append(items, animeItems...)
|
||||
}
|
||||
|
||||
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)
|
||||
if page == 1 {
|
||||
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, commandPaletteResponse{
|
||||
Items: items,
|
||||
HasNextPage: hasNextPage,
|
||||
NextPage: page + 1,
|
||||
})
|
||||
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)
|
||||
c.JSON(http.StatusOK, commandPaletteResponse{Items: 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:home", Type: "navigation", Label: "Go to Home", Subtitle: "Navigation", Href: "/", Icon: "home"},
|
||||
{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"},
|
||||
{ID: "nav:top-picks", Type: "navigation", Label: "Open Top Picks", Subtitle: "Navigation", Href: "/top-picks", Icon: "sparkles"},
|
||||
{ID: "nav:popular", Type: "navigation", Label: "Browse popular", Subtitle: "Browse", Href: "/browse?order_by=popularity&sort=asc", Icon: "trending"},
|
||||
{ID: "nav:airing", Type: "navigation", Label: "Currently airing", Subtitle: "Browse", Href: "/browse?status=airing&order_by=popularity&sort=asc", Icon: "play"},
|
||||
}
|
||||
if query == "" {
|
||||
return all
|
||||
@@ -81,13 +95,10 @@ func (h *AnimeHandler) commandPaletteNavigationItems(query string) []commandPale
|
||||
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)
|
||||
func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string, page int) ([]commandPaletteItem, bool) {
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, page, commandPaletteAnimeLimit)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, false
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
@@ -102,7 +113,7 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string)
|
||||
Image: anime.ImageURL(),
|
||||
})
|
||||
}
|
||||
return items
|
||||
return items, res.HasNextPage
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) commandPalettePersonalItems(c *gin.Context, userID string, query string) []commandPaletteItem {
|
||||
|
||||
220
internal/anime/details_handler.go
Normal file
220
internal/anime/details_handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||
hasKnownSub := false
|
||||
for _, episode := range episodes {
|
||||
if episode.HasDub {
|
||||
return "Dub available"
|
||||
}
|
||||
if episode.HasSub || episode.SubOnly {
|
||||
hasKnownSub = true
|
||||
}
|
||||
}
|
||||
if hasKnownSub {
|
||||
return "Subtitled only"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
||||
if h.episodeSvc == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_audio_availability_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return ""
|
||||
}
|
||||
if episodeList.Source != "AllAnime" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
h.handleAnimeDetailsSection(c, id, section)
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.WarmDetailSections(id)
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 0
|
||||
var cwSeconds float64
|
||||
if user != nil {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"AudioAvailability": audioAvailability,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) handleAnimeDetailsSection(c *gin.Context, id int, section string) {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
data, tplName, err := h.loadAnimeDetailsSection(sectionCtx, id, section)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_section_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if section == "recommendations" {
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "anime_recommendations_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": tplName,
|
||||
"Items": data,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, section string) (any, string, error) {
|
||||
switch section {
|
||||
case "characters":
|
||||
data, err := h.svc.GetCharacters(ctx, id)
|
||||
return data, "anime_characters", err
|
||||
case "recommendations":
|
||||
data, err := h.svc.GetRecommendations(ctx, id)
|
||||
return data, "anime_recommendations", err
|
||||
case "statistics":
|
||||
data, err := h.svc.GetStatistics(ctx, id)
|
||||
return data, "anime_statistics", err
|
||||
case "themes":
|
||||
data, err := h.svc.GetThemes(ctx, id)
|
||||
return data, "anime_themes", err
|
||||
default:
|
||||
return nil, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Query("animeId"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
mode := jikan.NormalizeWatchOrderMode(c.Query("mode"))
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id, mode)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"relations_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order_loading",
|
||||
"AnimeID": id,
|
||||
"Mode": string(mode),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
"Relations": relations,
|
||||
"AnimeID": id,
|
||||
"Mode": string(mode),
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
@@ -2,38 +2,22 @@ package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
animeSectionTimeout = 12 * time.Second
|
||||
watchOrderTimeout = 15 * time.Second
|
||||
audioLookupTimeout = 8 * time.Second
|
||||
)
|
||||
|
||||
type AnimeHandler struct {
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
|
||||
scheduleCacheMu sync.Mutex
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
svc Service
|
||||
watchlistSvc domain.WatchlistService
|
||||
episodeSvc domain.EpisodeService
|
||||
scheduleCache map[string]cachedWeekSchedule
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
domain.AnimeCatalogService
|
||||
domain.AnimeDiscoverService
|
||||
domain.AnimeSearchService
|
||||
domain.AnimeDetailsService
|
||||
WarmDetailSections(id int)
|
||||
@@ -44,7 +28,7 @@ func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeS
|
||||
svc: svc,
|
||||
watchlistSvc: watchlistSvc,
|
||||
episodeSvc: episodeSvc,
|
||||
scheduleCache: map[string]cachedWeekSchedule{},
|
||||
scheduleCache: make(map[string]cachedWeekSchedule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,63 +54,14 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
|
||||
return watchlistMap
|
||||
}
|
||||
|
||||
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
|
||||
hasKnownSub := false
|
||||
for _, episode := range episodes {
|
||||
if episode.HasDub {
|
||||
return "Dub available"
|
||||
}
|
||||
if episode.HasSub || episode.SubOnly {
|
||||
hasKnownSub = true
|
||||
}
|
||||
}
|
||||
if hasKnownSub {
|
||||
return "Subtitled only"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string {
|
||||
if h.episodeSvc == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_audio_availability_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": anime.MalID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return ""
|
||||
}
|
||||
if episodeList.Source != "AllAnime" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return animeAudioAvailabilityLabel(episodeList.Episodes)
|
||||
}
|
||||
|
||||
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)
|
||||
r.GET("/api/catalog/continue", h.HandleCatalogContinue)
|
||||
r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou)
|
||||
r.GET("/discover", h.HandleDiscover)
|
||||
r.GET("/discover/top-picks", h.HandleDiscoverTopPicksForYou)
|
||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
||||
r.GET("/schedule", h.HandleSchedule)
|
||||
r.GET("/api/schedule", h.HandleScheduleSection)
|
||||
r.GET("/search", h.HandleSearch)
|
||||
r.GET("/top-picks", h.HandleTopPicks)
|
||||
r.GET("/browse", h.HandleBrowse)
|
||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||
@@ -136,700 +71,3 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||
r.GET("/api/jikan/random/anime", h.HandleRandomAnime)
|
||||
r.GET("/api/jikan/producers", h.HandleProducers)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleProducers(c *gin.Context) {
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 12
|
||||
}
|
||||
if limit > 12 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": []any{},
|
||||
"HasNextPage": false,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"producers_fetch_failed",
|
||||
"anime",
|
||||
"failed to load producers",
|
||||
map[string]any{"q": q, "page": page, "limit": limit},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
items := make([]item, 0, len(res.Items))
|
||||
for _, p := range res.Items {
|
||||
name := jikan.ProducerListEntryName(p)
|
||||
if p.MalID <= 0 || name == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, item{ID: p.MalID, Name: name})
|
||||
}
|
||||
|
||||
if strings.Contains(c.GetHeader("Accept"), "text/html") {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "studio_dropdown_items",
|
||||
"StudioItems": items,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Page": page,
|
||||
"NextPage": page + 1,
|
||||
"Query": q,
|
||||
"Limit": limit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"hasNextPage": res.HasNextPage,
|
||||
"nextPage": page + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
c.HTML(http.StatusOK, "index.gohtml", gin.H{
|
||||
"CurrentPath": "/",
|
||||
"User": user,
|
||||
"WatchlistMap": map[int64]bool{},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Airing")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Popular")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) {
|
||||
h.renderCatalogSection(c, "Continue")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) {
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = "TopPickForYou"
|
||||
data.Fragment = "top_pick_for_you_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "catalog_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "index.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscover(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTopPicksForYou(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
data, err := h.svc.GetTopPicksForYou(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_picks_for_you_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
c.HTML(http.StatusOK, "discover.gohtml", gin.H{
|
||||
"_fragment": "",
|
||||
"CurrentPath": "/discover",
|
||||
"User": user,
|
||||
"Animes": data.Animes,
|
||||
"WatchlistMap": watchlistMap,
|
||||
"IsTopPicks": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Trending")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Upcoming")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
||||
h.renderDiscoverSection(c, "Top")
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||
userID := server.CurrentUserID(c)
|
||||
data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section)
|
||||
if err != nil {
|
||||
h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err)
|
||||
return
|
||||
}
|
||||
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes)
|
||||
|
||||
data.Section = section
|
||||
data.Fragment = "discover_section"
|
||||
data.WatchlistMap = watchlistMap
|
||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) {
|
||||
observability.Warn(
|
||||
event,
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"user_id": userID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleSchedule(c *gin.Context) {
|
||||
user := server.CurrentUser(c)
|
||||
year, week := parseYearWeek(c)
|
||||
c.HTML(http.StatusOK, "schedule.gohtml", gin.H{
|
||||
"CurrentPath": "/schedule",
|
||||
"User": user,
|
||||
"ScheduleYear": year,
|
||||
"ScheduleWeek": week,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) {
|
||||
year, week := parseYearWeek(c)
|
||||
timezone := scheduleTimezone(c)
|
||||
|
||||
schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone)
|
||||
if err != nil {
|
||||
prevYear, prevWeek := adjacentISOWeek(year, week, -1)
|
||||
nextYear, nextWeek := adjacentISOWeek(year, week, 1)
|
||||
observability.Warn(
|
||||
"animeschedule_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"year": year,
|
||||
"week": week,
|
||||
"timezone": timezone,
|
||||
},
|
||||
err,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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_scraped",
|
||||
"ScheduleDays": days,
|
||||
"ScheduleYear": schedule.Year,
|
||||
"ScheduleWeek": schedule.Week,
|
||||
"PrevYear": prevYear,
|
||||
"PrevWeek": prevWeek,
|
||||
"NextYear": nextYear,
|
||||
"NextWeek": nextWeek,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
animeType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
orderBy := c.Query("order_by")
|
||||
sort := c.Query("sort")
|
||||
sfw := c.Query("sfw") != "false"
|
||||
studioID := 0
|
||||
if raw := strings.TrimSpace(c.Query("studio")); raw != "" {
|
||||
id, err := strconv.Atoi(raw)
|
||||
if err != nil || id < 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id")
|
||||
return
|
||||
}
|
||||
studioID = id
|
||||
}
|
||||
|
||||
var genres []int
|
||||
for _, g := range c.QueryArray("genres") {
|
||||
id, err := strconv.Atoi(g)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id")
|
||||
return
|
||||
}
|
||||
if id > 0 {
|
||||
genres = append(genres, id)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"browse_search_failed",
|
||||
"anime",
|
||||
"failed to load browse results",
|
||||
map[string]any{
|
||||
"q": q,
|
||||
"type": animeType,
|
||||
"status": status,
|
||||
"order_by": orderBy,
|
||||
"sort": sort,
|
||||
"studio": studioID,
|
||||
"sfw": sfw,
|
||||
"page": page,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
studioName := ""
|
||||
if studioID > 0 {
|
||||
name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID)
|
||||
if err == nil {
|
||||
studioName = name
|
||||
}
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "browse.gohtml", gin.H{
|
||||
"_fragment": "anime_card_scroll",
|
||||
"Animes": animes,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"Studio": studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": sfw,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
genresList, _ := h.svc.GetGenres(c.Request.Context())
|
||||
browseData := gin.H{
|
||||
"CurrentPath": "/browse",
|
||||
"Query": q,
|
||||
"Type": animeType,
|
||||
"Status": status,
|
||||
"OrderBy": orderBy,
|
||||
"Sort": sort,
|
||||
"Genres": genres,
|
||||
"Studio": studioID,
|
||||
"StudioName": studioName,
|
||||
"SFW": sfw,
|
||||
"GenresList": genresList,
|
||||
"Animes": animes,
|
||||
"HasNextPage": res.HasNextPage,
|
||||
"NextPage": page + 1,
|
||||
"User": user,
|
||||
"WatchlistMap": watchlistMap,
|
||||
}
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" {
|
||||
browseData["_fragment"] = "browse_content"
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "browse.gohtml", browseData)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
section := c.Query("section")
|
||||
if section != "" && c.GetHeader("HX-Request") == "true" {
|
||||
sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
var data any
|
||||
var tplName string
|
||||
var err error
|
||||
switch section {
|
||||
case "characters":
|
||||
data, err = h.svc.GetCharacters(sectionCtx, id)
|
||||
tplName = "anime_characters"
|
||||
case "recommendations":
|
||||
data, err = h.svc.GetRecommendations(sectionCtx, id)
|
||||
tplName = "anime_recommendations"
|
||||
|
||||
case "statistics":
|
||||
data, err = h.svc.GetStatistics(sectionCtx, id)
|
||||
tplName = "anime_statistics"
|
||||
case "themes":
|
||||
data, err = h.svc.GetThemes(sectionCtx, id)
|
||||
tplName = "anime_themes"
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"anime_section_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"section": section,
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
if section == "recommendations" {
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "anime_recommendations_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": tplName,
|
||||
"Items": data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnimeByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.WarmDetailSections(id)
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
status := ""
|
||||
var watchlistIDs []int64
|
||||
ep := 0
|
||||
var cwSeconds float64
|
||||
if user != nil {
|
||||
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil {
|
||||
status = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
}
|
||||
|
||||
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id))
|
||||
if err == nil && cwEntry.CurrentEpisode.Valid {
|
||||
ep = int(cwEntry.CurrentEpisode.Int64)
|
||||
cwSeconds = cwEntry.CurrentTimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime)
|
||||
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"Anime": anime,
|
||||
"AudioAvailability": audioAvailability,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Query("animeId"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
|
||||
relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout)
|
||||
defer cancel()
|
||||
|
||||
relations, err := h.svc.GetRelations(relationsCtx, id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"relations_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"anime_id": id,
|
||||
},
|
||||
err,
|
||||
)
|
||||
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||
"_fragment": "watch_order_loading",
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
"Relations": relations,
|
||||
"AnimeID": id,
|
||||
"WatchlistMap": watchlistMap,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
|
||||
userID := server.CurrentUserID(c)
|
||||
animes := wrapAnimes(res.Animes)
|
||||
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes)
|
||||
|
||||
type quickSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Year int `json:"year"`
|
||||
Image string `json:"image"`
|
||||
InWatchlist bool `json:"in_watchlist"`
|
||||
}
|
||||
|
||||
output := make([]quickSearchResult, len(animes))
|
||||
for i, anime := range animes {
|
||||
output[i] = quickSearchResult{
|
||||
ID: anime.MalID,
|
||||
Title: anime.DisplayTitle(),
|
||||
Type: anime.Type,
|
||||
Year: anime.Year,
|
||||
Image: anime.ImageURL(),
|
||||
InWatchlist: watchlistMap[int64(anime.MalID)],
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, output)
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
anime, err := h.svc.GetRandomAnime(ctx)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"random_anime_fetch_failed",
|
||||
"anime",
|
||||
"failed to fetch random anime",
|
||||
nil,
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if anime.MalID == 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
inWatchlist := false
|
||||
userID := server.CurrentUserID(c)
|
||||
if userID != "" {
|
||||
watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)})
|
||||
inWatchlist = watchlistMap[int64(anime.MalID)]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": anime,
|
||||
"in_watchlist": inWatchlist,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
|
||||
return
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page")
|
||||
return
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"anime_reviews_fetch_failed",
|
||||
"anime",
|
||||
"failed to load reviews",
|
||||
map[string]any{"anime_id": id, "page": page},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && page > 1 {
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"_fragment": "review_cards",
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", id),
|
||||
"Reviews": reviews,
|
||||
"NextPage": page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": id,
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ var Module = fx.Options(
|
||||
NewAnimeService,
|
||||
fx.As(new(Service)),
|
||||
fx.As(new(domain.AnimeCatalogService)),
|
||||
fx.As(new(domain.AnimeDiscoverService)),
|
||||
fx.As(new(domain.AnimeSearchService)),
|
||||
fx.As(new(domain.AnimeDetailsService)),
|
||||
fx.As(new(domain.AnimePlaybackService)),
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -270,6 +275,24 @@ func scoreRecommendationCandidate(
|
||||
score += themeScore * forYouThemeMatchWeight
|
||||
score += studioScore * forYouStudioMatchWeight
|
||||
score += demographicScore * forYouDemographicMatchWeight
|
||||
score += recommendationCandidateScoreAdjustments(now, profile, candidate)
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
}
|
||||
}
|
||||
|
||||
func recommendationCandidateScoreAdjustments(
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
candidate jikan.Anime,
|
||||
) float64 {
|
||||
var score float64
|
||||
|
||||
if candidate.Score > 0 {
|
||||
score += min(candidate.Score/10.0, 1.0)
|
||||
@@ -280,31 +303,41 @@ func scoreRecommendationCandidate(
|
||||
if profile.prefersAiring && candidate.Airing {
|
||||
score += 0.5
|
||||
}
|
||||
if profile.prefersRecent && candidate.Year > 0 && now.Year()-candidate.Year <= 4 {
|
||||
if profile.prefersRecent && isRecentCandidate(now, candidate.Year) {
|
||||
score += 0.45
|
||||
}
|
||||
if candidate.Year > 0 && now.Year()-candidate.Year > 15 {
|
||||
if isClassicCandidate(now, candidate.Year) {
|
||||
score -= 0.2
|
||||
}
|
||||
if candidate.Status == "Not yet aired" {
|
||||
score -= 0.35
|
||||
}
|
||||
if candidate.Aired.From != "" {
|
||||
if airedAt, err := time.Parse(time.RFC3339, candidate.Aired.From); err == nil {
|
||||
if now.Sub(airedAt) <= forYouFreshReleaseWindow {
|
||||
score += 0.3
|
||||
}
|
||||
}
|
||||
if isFreshRelease(now, candidate.Aired.From) {
|
||||
score += 0.3
|
||||
}
|
||||
|
||||
return recommendationCandidate{
|
||||
anime: candidate,
|
||||
score: score,
|
||||
genreMatches: genreMatches,
|
||||
themeMatches: themeMatches,
|
||||
studioMatches: studioMatches,
|
||||
demographicMatches: demographicMatches,
|
||||
return score
|
||||
}
|
||||
|
||||
func isRecentCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year <= 4
|
||||
}
|
||||
|
||||
func isClassicCandidate(now time.Time, year int) bool {
|
||||
return year > 0 && now.Year()-year > 15
|
||||
}
|
||||
|
||||
func isFreshRelease(now time.Time, airedFrom string) bool {
|
||||
if airedFrom == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
airedAt, err := time.Parse(time.RFC3339, airedFrom)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return now.Sub(airedAt) <= forYouFreshReleaseWindow
|
||||
}
|
||||
|
||||
func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity) (int, float64) {
|
||||
@@ -501,3 +534,298 @@ func recentFeatureCounts(
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
type candidateStore struct {
|
||||
watchlistAnimeIDs map[int]struct{}
|
||||
byID map[int]rankedCandidate
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newCandidateStore(watchlist []db.GetUserWatchListRow) *candidateStore {
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
return &candidateStore{
|
||||
watchlistAnimeIDs: watchlistAnimeIDs,
|
||||
byID: map[int]rankedCandidate{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *candidateStore) upsert(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := s.watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
current, ok := s.byID[candidate.id]
|
||||
if !ok {
|
||||
s.byID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
s.byID[candidate.id] = current
|
||||
}
|
||||
|
||||
func (s *candidateStore) ranked() []rankedCandidate {
|
||||
ranked := make([]rankedCandidate, 0, len(s.byID))
|
||||
for _, item := range s.byID {
|
||||
ranked = append(ranked, item)
|
||||
}
|
||||
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(ranked[i].collaborativeScore, ranked[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(ranked[j].collaborativeScore, ranked[j].profileSearchScore)
|
||||
if left == right {
|
||||
return ranked[i].id < ranked[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
return ranked
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) fetchSeedAnimes(ctx context.Context, seedPool []recommendationSeed) ([]jikan.Anime, error) {
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return seedAnimes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) collectCollaborativeCandidates(ctx context.Context, seedPool []recommendationSeed, store *candidateStore) error {
|
||||
var g errgroup.Group
|
||||
g.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
g.Go(func() error {
|
||||
recs, err := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 || id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) collectProfileSearchCandidates(ctx context.Context, profile userTasteProfile, store *candidateStore) error {
|
||||
queries := buildProfileSearchQueries(profile)
|
||||
var g errgroup.Group
|
||||
g.SetLimit(3)
|
||||
|
||||
for _, query := range queries {
|
||||
g.Go(func() error {
|
||||
res, err := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
store.upsert(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
func (s *animeService) scoreRankedCandidates(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
profile userTasteProfile,
|
||||
ranked []rankedCandidate,
|
||||
) ([]recommendationCandidate, error) {
|
||||
limit := min(len(ranked), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := ranked[i]
|
||||
g.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, err := s.jikan.GetAnimeByID(ctx, item.id)
|
||||
if err != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
err,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *animeService) getTopPicksForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
seedAnimes, err := s.fetchSeedAnimes(ctx, seedPool)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
store := newCandidateStore(watchlist)
|
||||
|
||||
if err := s.collectCollaborativeCandidates(ctx, seedPool, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
if err := s.collectProfileSearchCandidates(ctx, profile, store); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
ranked := store.ranked()
|
||||
if len(ranked) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
candidates, err := s.scoreRankedCandidates(ctx, now, profile, ranked)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
76
internal/anime/reviews_handler.go
Normal file
76
internal/anime/reviews_handler.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package anime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mal/internal/server"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type reviewsQuery struct {
|
||||
animeID int
|
||||
page int
|
||||
}
|
||||
|
||||
func parseReviewsQuery(c *gin.Context) (reviewsQuery, error) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid anime id")
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if err != nil {
|
||||
return reviewsQuery{}, fmt.Errorf("invalid page")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
return reviewsQuery{animeID: id, page: page}, nil
|
||||
}
|
||||
|
||||
func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) {
|
||||
query, err := parseReviewsQuery(c)
|
||||
if err != nil {
|
||||
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), query.animeID, query.page)
|
||||
if err != nil {
|
||||
server.RespondError(
|
||||
c,
|
||||
http.StatusInternalServerError,
|
||||
"anime_reviews_fetch_failed",
|
||||
"anime",
|
||||
"failed to load reviews",
|
||||
map[string]any{"anime_id": query.animeID, "page": query.page},
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
user := server.CurrentUser(c)
|
||||
|
||||
if c.GetHeader("HX-Request") == "true" && query.page > 1 {
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"_fragment": "review_cards",
|
||||
"Reviews": reviews,
|
||||
"NextPage": query.page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": query.animeID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "reviews.gohtml", gin.H{
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d/reviews", query.animeID),
|
||||
"Reviews": reviews,
|
||||
"NextPage": query.page + 1,
|
||||
"HasNextPage": hasNextPage,
|
||||
"AnimeID": query.animeID,
|
||||
"User": user,
|
||||
})
|
||||
}
|
||||
@@ -45,9 +45,9 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
||||
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
|
||||
const ttl = 10 * time.Minute
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
h.Lock()
|
||||
cached, ok := h.scheduleCache[cacheKey]
|
||||
h.scheduleCacheMu.Unlock()
|
||||
h.Unlock()
|
||||
|
||||
if ok && time.Since(cached.fetchedAt) < ttl {
|
||||
return cached.value, nil
|
||||
@@ -58,9 +58,9 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int,
|
||||
return animeschedule.WeekSchedule{}, err
|
||||
}
|
||||
|
||||
h.scheduleCacheMu.Lock()
|
||||
h.Lock()
|
||||
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
|
||||
h.scheduleCacheMu.Unlock()
|
||||
h.Unlock()
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// Package anime provides anime catalog, discovery, search, and details services.
|
||||
// Package anime provides anime catalog, search, and details services.
|
||||
package anime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mal/integrations/jikan"
|
||||
"mal/internal/db"
|
||||
"mal/internal/domain"
|
||||
"mal/internal/observability"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -76,355 +72,6 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) {
|
||||
var res jikan.TopAnimeResult
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
switch section {
|
||||
case "Trending":
|
||||
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
|
||||
case "Upcoming":
|
||||
res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1)
|
||||
case "Top":
|
||||
res, err = s.jikan.GetTopAnime(gCtx, 1)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return domain.DiscoverSectionData{}, err
|
||||
}
|
||||
|
||||
animes := wrapAnimes(res.Animes)
|
||||
if len(animes) > 8 {
|
||||
animes = animes[:8]
|
||||
}
|
||||
|
||||
return domain.DiscoverSectionData{
|
||||
Animes: animes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
|
||||
return s.getTopPicksForYou(ctx, userID, forYouFullResultLimit)
|
||||
}
|
||||
|
||||
func (s *animeService) getTopPicksForYou(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
resultLimit int,
|
||||
) (domain.CatalogSectionData, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seedPool := buildRecommendationSeeds(now, watchlist)
|
||||
if len(seedPool) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
type rankedCandidate struct {
|
||||
id int
|
||||
collaborativeScore float64
|
||||
profileSearchScore float64
|
||||
anime jikan.Anime
|
||||
hasAnime bool
|
||||
}
|
||||
|
||||
watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
|
||||
for _, entry := range watchlist {
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
|
||||
}
|
||||
|
||||
candidatesByID := map[int]rankedCandidate{}
|
||||
var candidatesByIDMu sync.Mutex
|
||||
upsertCandidate := func(candidate rankedCandidate) {
|
||||
if candidate.id <= 0 {
|
||||
return
|
||||
}
|
||||
if _, exists := watchlistAnimeIDs[candidate.id]; exists {
|
||||
return
|
||||
}
|
||||
|
||||
candidatesByIDMu.Lock()
|
||||
defer candidatesByIDMu.Unlock()
|
||||
|
||||
current, ok := candidatesByID[candidate.id]
|
||||
if !ok {
|
||||
candidatesByID[candidate.id] = candidate
|
||||
return
|
||||
}
|
||||
|
||||
current.collaborativeScore += candidate.collaborativeScore
|
||||
current.profileSearchScore += candidate.profileSearchScore
|
||||
if candidate.hasAnime {
|
||||
current.anime = candidate.anime
|
||||
current.hasAnime = true
|
||||
}
|
||||
candidatesByID[candidate.id] = current
|
||||
}
|
||||
|
||||
seedAnimes := make([]jikan.Anime, len(seedPool))
|
||||
var seedFetchGroup errgroup.Group
|
||||
seedFetchGroup.SetLimit(4)
|
||||
|
||||
for i, seed := range seedPool {
|
||||
seedFetchGroup.Go(func() error {
|
||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
seedAnimes[i] = anime
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := seedFetchGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profile := buildTasteProfile(now, seedPool, seedAnimes)
|
||||
|
||||
var recommendationGroup errgroup.Group
|
||||
recommendationGroup.SetLimit(4)
|
||||
|
||||
for _, seed := range seedPool {
|
||||
recommendationGroup.Go(func() error {
|
||||
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
|
||||
if recErr != nil {
|
||||
return recErr
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if i >= forYouMaxRecommendations {
|
||||
break
|
||||
}
|
||||
id := rec.Entry.MalID
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if id == seed.animeID {
|
||||
continue
|
||||
}
|
||||
upsertCandidate(rankedCandidate{
|
||||
id: id,
|
||||
collaborativeScore: float64(rec.Votes) * seed.weight,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := recommendationGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
profileQueries := buildProfileSearchQueries(profile)
|
||||
var profileSearchGroup errgroup.Group
|
||||
profileSearchGroup.SetLimit(3)
|
||||
|
||||
for _, query := range profileQueries {
|
||||
profileSearchGroup.Go(func() error {
|
||||
res, searchErr := s.jikan.SearchAdvanced(
|
||||
ctx,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"score",
|
||||
"desc",
|
||||
query.genreIDs,
|
||||
query.studioID,
|
||||
true,
|
||||
1,
|
||||
forYouProfileSearchLimit,
|
||||
)
|
||||
if searchErr != nil {
|
||||
observability.Warn(
|
||||
"top_pick_profile_search_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{
|
||||
"genres": query.genreIDs,
|
||||
"studio_id": query.studioID,
|
||||
},
|
||||
searchErr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i, anime := range res.Animes {
|
||||
if anime.MalID <= 0 {
|
||||
continue
|
||||
}
|
||||
upsertCandidate(rankedCandidate{
|
||||
id: anime.MalID,
|
||||
profileSearchScore: query.weight * profileSearchRankWeight(i),
|
||||
anime: anime,
|
||||
hasAnime: true,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := profileSearchGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
if len(candidatesByID) == 0 {
|
||||
return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil
|
||||
}
|
||||
|
||||
rankedIDs := make([]rankedCandidate, 0, len(candidatesByID))
|
||||
for _, item := range candidatesByID {
|
||||
rankedIDs = append(rankedIDs, item)
|
||||
}
|
||||
sort.Slice(rankedIDs, func(i, j int) bool {
|
||||
left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore)
|
||||
right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore)
|
||||
if left == right {
|
||||
return rankedIDs[i].id < rankedIDs[j].id
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
|
||||
limit := min(len(rankedIDs), forYouCandidateFetchLimit)
|
||||
candidates := make([]recommendationCandidate, 0, limit)
|
||||
var candidatesMu sync.Mutex
|
||||
var detailGroup errgroup.Group
|
||||
detailGroup.SetLimit(6)
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
item := rankedIDs[i]
|
||||
detailGroup.Go(func() error {
|
||||
anime := item.anime
|
||||
if !item.hasAnime || !hasTasteMetadata(anime) {
|
||||
fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id)
|
||||
if fetchErr != nil {
|
||||
observability.Warn(
|
||||
"recommendation_anime_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"anime_id": item.id},
|
||||
fetchErr,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
anime = fetchedAnime
|
||||
}
|
||||
|
||||
candidate := scoreRecommendationCandidate(
|
||||
now,
|
||||
profile,
|
||||
anime,
|
||||
item.collaborativeScore,
|
||||
item.profileSearchScore,
|
||||
)
|
||||
candidatesMu.Lock()
|
||||
candidates = append(candidates, candidate)
|
||||
candidatesMu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := detailGroup.Wait(); err != nil {
|
||||
return domain.CatalogSectionData{}, err
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].score == candidates[j].score {
|
||||
return candidates[i].anime.MalID < candidates[j].anime.MalID
|
||||
}
|
||||
return candidates[i].score > candidates[j].score
|
||||
})
|
||||
|
||||
return domain.CatalogSectionData{
|
||||
Animes: rerankRecommendationCandidates(candidates, resultLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return []domain.Anime{}, nil
|
||||
}
|
||||
|
||||
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, 50)
|
||||
for _, entry := range watchlist {
|
||||
status := strings.TrimSpace(entry.Status)
|
||||
if status != "watching" && status != "plan_to_watch" {
|
||||
continue
|
||||
}
|
||||
if !entry.Airing.Valid || !entry.Airing.Bool {
|
||||
continue
|
||||
}
|
||||
if entry.AnimeID <= 0 {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, int(entry.AnimeID))
|
||||
if len(ids) >= 50 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []domain.Anime{}, nil
|
||||
}
|
||||
|
||||
animes := make([]domain.Anime, 0, len(ids))
|
||||
var g errgroup.Group
|
||||
g.SetLimit(6)
|
||||
var mu sync.Mutex
|
||||
|
||||
for _, id := range ids {
|
||||
g.Go(func() error {
|
||||
anime, fetchErr := s.jikan.GetAnimeByID(ctx, id)
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
mu.Lock()
|
||||
animes = append(animes, domain.Anime{Anime: anime})
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, err
|
||||
}
|
||||
observability.Warn(
|
||||
"schedule_partial_fetch_failed",
|
||||
"anime",
|
||||
"",
|
||||
map[string]any{"user_id": userID, "count": len(ids)},
|
||||
err,
|
||||
)
|
||||
return animes, nil
|
||||
}
|
||||
|
||||
return animes, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||
anime, err := s.jikan.GetAnimeByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -525,8 +172,8 @@ func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id)
|
||||
func (s *animeService) GetRelations(ctx context.Context, id int, mode jikan.WatchOrderMode) ([]jikan.RelationEntry, error) {
|
||||
return s.jikan.GetFullRelations(ctx, id, mode)
|
||||
}
|
||||
|
||||
func (s *animeService) WarmDetailSections(id int) {
|
||||
|
||||
Reference in New Issue
Block a user