Files
mal/internal/anime/service/service.go

367 lines
8.6 KiB
Go

package service
import (
"context"
"errors"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"math/rand"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
type animeService struct {
jikan *jikan.Client
repo domain.AnimeRepository
}
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService {
return &animeService{jikan: jikan, repo: repo}
}
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
var (
res jikan.TopAnimeResult
cw []db.GetContinueWatchingEntriesRow
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
var err error
switch section {
case "Airing":
res, err = s.jikan.GetSeasonsNow(gCtx, 1)
case "Popular":
res, err = s.jikan.GetTopAnime(gCtx, 1)
}
return err
})
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
return err
})
}
if err := g.Wait(); err != nil {
return domain.CatalogSectionData{}, err
}
animes := res.Animes
if len(animes) > 6 {
animes = animes[:6]
}
return domain.CatalogSectionData{
Animes: animes,
ContinueWatching: cw,
}, 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 := res.Animes
if len(animes) > 8 {
animes = animes[:8]
}
return domain.DiscoverSectionData{
Animes: animes,
}, nil
}
func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) {
if strings.TrimSpace(userID) == "" {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
watchlist, err := s.repo.GetUserWatchList(ctx, userID)
if err != nil {
return domain.DiscoverSectionData{}, err
}
seedIDs := make([]int, 0, 5)
for _, entry := range watchlist {
status := strings.TrimSpace(entry.Status)
if status != "watching" && status != "completed" {
continue
}
if entry.AnimeID <= 0 {
continue
}
seedIDs = append(seedIDs, int(entry.AnimeID))
if len(seedIDs) >= 5 {
break
}
}
if len(seedIDs) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
type ranked struct {
id int
votes int
}
recommended := map[int]ranked{}
var g errgroup.Group
g.SetLimit(4)
for _, seedID := range seedIDs {
g.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seedID)
if recErr != nil {
return recErr
}
for _, rec := range recs {
id := rec.Entry.MalID
if id <= 0 {
continue
}
if id == seedID {
continue
}
current, ok := recommended[id]
if !ok {
recommended[id] = ranked{id: id, votes: rec.Votes}
continue
}
current.votes += rec.Votes
recommended[id] = current
}
return nil
})
}
if err := g.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
if len(recommended) == 0 {
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
}
rankedIDs := make([]ranked, 0, len(recommended))
for _, item := range recommended {
rankedIDs = append(rankedIDs, item)
}
sort.Slice(rankedIDs, func(i, j int) bool {
if rankedIDs[i].votes == rankedIDs[j].votes {
return rankedIDs[i].id < rankedIDs[j].id
}
return rankedIDs[i].votes > rankedIDs[j].votes
})
limit := 12
if len(rankedIDs) < limit {
limit = len(rankedIDs)
}
animes := make([]domain.Anime, 0, limit)
for i := 0; i < limit; i++ {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id)
if fetchErr != nil {
continue
}
animes = append(animes, anime)
}
return domain.DiscoverSectionData{Animes: animes}, 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, 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
}
return animes, nil
}
return animes, nil
}
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
return s.jikan.GetAnimeByID(ctx, id)
}
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, 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) {
return s.jikan.GetAnimeGenres(ctx)
}
func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) {
return s.jikan.GetAnimeCharacters(ctx, id)
}
func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) {
return s.jikan.GetAnimeRecommendations(ctx, id)
}
func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.RelationEntry, error) {
return s.jikan.GetFullRelations(ctx, id)
}
func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) {
return s.jikan.GetEpisodes(ctx, id, page)
}
func (s *animeService) GetStaff(ctx context.Context, id int) ([]domain.StaffEntry, error) {
return s.jikan.GetAnimeStaff(ctx, id)
}
func (s *animeService) GetStatistics(ctx context.Context, id int) (domain.Statistics, error) {
return s.jikan.GetAnimeStatistics(ctx, id)
}
func (s *animeService) GetThemes(ctx context.Context, id int) (domain.ThemesData, error) {
return s.jikan.GetAnimeThemes(ctx, id)
}
func (s *animeService) GetReviews(ctx context.Context, id int, page int) ([]domain.ReviewEntry, bool, error) {
data, pag, err := s.jikan.GetAnimeReviews(ctx, id, page)
if err != nil {
return nil, false, err
}
return data, pag.HasNextPage, nil
}
func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) {
randomCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
anime, err := s.jikan.GetRandomAnime(randomCtx)
if err == nil {
return anime, nil
}
for _, fallback := range []func(context.Context, int) (jikan.TopAnimeResult, error){
s.jikan.GetSeasonsNow,
s.jikan.GetTopAnime,
s.jikan.GetSeasonsUpcoming,
} {
res, fallbackErr := fallback(ctx, 1)
if fallbackErr != nil || len(res.Animes) == 0 {
continue
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
return res.Animes[r.Intn(len(res.Animes))], nil
}
return domain.Anime{}, err
}
func (s *animeService) GetAllEpisodes(ctx context.Context, id int) ([]domain.EpisodeData, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, id)
if err != nil {
return nil, err
}
result := make([]domain.EpisodeData, len(episodes))
for i, ep := range episodes {
result[i] = domain.EpisodeData{
MalID: ep.MalID,
Title: ep.Title,
IsFiller: ep.Filler,
IsRecap: ep.Recap,
}
}
return result, nil
}