feat: add studio filter to search pipeline

This commit is contained in:
2026-05-25 17:59:17 +02:00
parent e54d6b8142
commit 29c0c0bb18
4 changed files with 119 additions and 9 deletions

View File

@@ -8,8 +8,8 @@ import (
"strings" "strings"
) )
// SearchAdvanced performs a filtered anime search with type, status, ordering, and genre filters. // SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (SearchResult, error) { func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
} }
@@ -26,7 +26,7 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
genresParam = strings.Join(ids, ",") genresParam = strings.Join(ids, ",")
} }
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, sfw, page, limit) cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
var result SearchResponse var result SearchResponse
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page) reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
@@ -42,6 +42,9 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
if status != "" { if status != "" {
reqURL += "&status=" + url.QueryEscape(status) reqURL += "&status=" + url.QueryEscape(status)
} }
if studioID > 0 {
reqURL += "&producers=" + strconv.Itoa(studioID)
}
if orderBy != "" { if orderBy != "" {
reqURL += "&order_by=" + url.QueryEscape(orderBy) reqURL += "&order_by=" + url.QueryEscape(orderBy)
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"mal/integrations/jikan"
"mal/internal/db" "mal/internal/db"
"mal/internal/domain" "mal/internal/domain"
"net/http" "net/http"
@@ -66,6 +67,75 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/api/search-quick", h.HandleQuickSearch) r.GET("/api/search-quick", h.HandleQuickSearch)
r.GET("/api/command-palette", h.HandleCommandPalette) r.GET("/api/command-palette", h.HandleCommandPalette)
r.GET("/api/jikan/random/anime", h.HandleRandomAnime) 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, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit < 1 {
limit = 12
}
if limit > 12 {
limit = 12
}
res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit)
if err != nil {
log.Printf("failed to fetch producers list (q=%q page=%d limit=%d): %v", q, page, 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
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
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) { func (h *AnimeHandler) HandleCatalog(c *gin.Context) {
@@ -155,6 +225,10 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
orderBy := c.Query("order_by") orderBy := c.Query("order_by")
sort := c.Query("sort") sort := c.Query("sort")
sfw := c.Query("sfw") != "false" sfw := c.Query("sfw") != "false"
studioID, _ := strconv.Atoi(c.Query("studio"))
if studioID < 0 {
studioID = 0
}
var genres []int var genres []int
for _, g := range c.QueryArray("genres") { for _, g := range c.QueryArray("genres") {
@@ -169,7 +243,7 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
page = 1 page = 1
} }
res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24) res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -182,6 +256,14 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
} }
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.Animes) watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, res.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 { if c.GetHeader("HX-Request") == "true" && page > 1 {
c.HTML(http.StatusOK, "browse.gohtml", gin.H{ c.HTML(http.StatusOK, "browse.gohtml", gin.H{
"_fragment": "anime_card_scroll", "_fragment": "anime_card_scroll",
@@ -194,6 +276,8 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"OrderBy": orderBy, "OrderBy": orderBy,
"Sort": sort, "Sort": sort,
"Genres": genres, "Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw, "SFW": sfw,
"WatchlistMap": watchlistMap, "WatchlistMap": watchlistMap,
}) })
@@ -212,6 +296,8 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"OrderBy": orderBy, "OrderBy": orderBy,
"Sort": sort, "Sort": sort,
"Genres": genres, "Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw, "SFW": sfw,
"GenresList": genresList, "GenresList": genresList,
"Animes": res.Animes, "Animes": res.Animes,
@@ -231,6 +317,8 @@ func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
"OrderBy": orderBy, "OrderBy": orderBy,
"Sort": sort, "Sort": sort,
"Genres": genres, "Genres": genres,
"Studio": studioID,
"StudioName": studioName,
"SFW": sfw, "SFW": sfw,
"GenresList": genresList, "GenresList": genresList,
"Animes": res.Animes, "Animes": res.Animes,
@@ -367,7 +455,7 @@ func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) {
return return
} }
res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, true, 1, 5) res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5)
if err != nil { if err != nil {
c.JSON(http.StatusOK, []any{}) c.JSON(http.StatusOK, []any{})
return return
@@ -475,7 +563,7 @@ func (h *AnimeHandler) commandPaletteAnimeResults(c *gin.Context, query string)
searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond) searchCtx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
defer cancel() defer cancel()
res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, true, 1, 5) res, err := h.svc.SearchAdvanced(searchCtx, query, "", "", "", "", nil, 0, true, 1, 5)
if err != nil { if err != nil {
return nil return nil
} }

View File

@@ -98,8 +98,25 @@ func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime,
return s.jikan.GetAnimeByID(ctx, id) return s.jikan.GetAnimeByID(ctx, id)
} }
func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) { func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) {
return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit) return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, studioID, sfw, page, limit)
}
func (s *animeService) GetProducerNameByID(ctx context.Context, id int) (string, error) {
res, err := s.jikan.GetProducerByID(ctx, id)
if err != nil {
return "", err
}
for _, t := range res.Data.Titles {
if t.Title != "" {
return t.Title, nil
}
}
return "", nil
}
func (s *animeService) GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error) {
return s.jikan.GetProducers(ctx, query, page, limit)
} }
func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) { func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) {

View File

@@ -20,7 +20,9 @@ type AnimeService interface {
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error) GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error) GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error)
GetAnimeByID(ctx context.Context, id int) (Anime, error) GetAnimeByID(ctx context.Context, id int) (Anime, error)
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetProducerNameByID(ctx context.Context, id int) (string, error)
GetProducers(ctx context.Context, query string, page int, limit int) (jikan.ProducerListResult, error)
GetGenres(ctx context.Context) ([]Genre, error) GetGenres(ctx context.Context) ([]Genre, error)
GetCharacters(ctx context.Context, id int) ([]Character, error) GetCharacters(ctx context.Context, id int) ([]Character, error)
GetRecommendations(ctx context.Context, id int) ([]Recommendation, error) GetRecommendations(ctx context.Context, id int) ([]Recommendation, error)