package arn import ( "errors" "fmt" "sort" "sync" "github.com/aerogo/nano" ) // AnimeList is a list of anime list items. type AnimeList struct { UserID UserID `json:"userId" primary:"true"` Items []*AnimeListItem `json:"items"` sync.Mutex } // Add adds an anime to the list if it hasn't been added yet. func (list *AnimeList) Add(animeID AnimeID) error { if list.Contains(animeID) { return errors.New("Anime " + animeID + " has already been added") } creationDate := DateTimeUTC() item := &AnimeListItem{ AnimeID: animeID, Status: AnimeListStatusPlanned, Rating: AnimeListItemRating{}, Created: creationDate, Edited: creationDate, } if item.Anime() == nil { return errors.New("Invalid anime ID") } list.Lock() list.Items = append(list.Items, item) list.Unlock() return nil } // Remove removes the anime ID from the list. func (list *AnimeList) Remove(animeID AnimeID) bool { list.Lock() defer list.Unlock() for index, item := range list.Items { if item.AnimeID == animeID { list.Items = append(list.Items[:index], list.Items[index+1:]...) return true } } return false } // Contains checks if the list contains the anime ID already. func (list *AnimeList) Contains(animeID AnimeID) bool { list.Lock() defer list.Unlock() for _, item := range list.Items { if item.AnimeID == animeID { return true } } return false } // HasItemsWithStatus checks if the list contains an anime with the given status. func (list *AnimeList) HasItemsWithStatus(status string) bool { list.Lock() defer list.Unlock() for _, item := range list.Items { if item.Status == status { return true } } return false } // Find returns the list item with the specified anime ID, if available. func (list *AnimeList) Find(animeID AnimeID) *AnimeListItem { list.Lock() defer list.Unlock() for _, item := range list.Items { if item.AnimeID == animeID { return item } } return nil } // Import adds an anime to the list if it hasn't been added yet // and if it did exist it will update episode, rating and notes. func (list *AnimeList) Import(item *AnimeListItem) { existing := list.Find(item.AnimeID) // If it doesn't exist yet: Simply add it. if existing == nil { list.Lock() list.Items = append(list.Items, item) list.Unlock() return } // Temporary save it before changing the status // because status changes can modify the episode count. // This will prevent loss of "episodes watched" data. existingEpisodes := existing.Episodes // Status existing.Status = item.Status existing.OnStatusChange() // Episodes if item.Episodes > existingEpisodes { existing.Episodes = item.Episodes } else { existing.Episodes = existingEpisodes } existing.OnEpisodesChange() // Rating if existing.Rating.Overall == 0 { existing.Rating.Overall = item.Rating.Overall existing.Rating.Clamp() } if existing.Notes == "" { existing.Notes = item.Notes } if item.RewatchCount > existing.RewatchCount { existing.RewatchCount = item.RewatchCount } // Edited existing.Edited = DateTimeUTC() } // User returns the user this anime list belongs to. func (list *AnimeList) User() *User { user, _ := GetUser(list.UserID) return user } // Top returns the top entries. func (list *AnimeList) Top(count int) []*AnimeListItem { list.Lock() defer list.Unlock() sort.Slice(list.Items, func(i, j int) bool { a := list.Items[i] b := list.Items[j] if a.Rating.Overall == b.Rating.Overall { return a.Anime().Title.Canonical < b.Anime().Title.Canonical } return a.Rating.Overall > b.Rating.Overall }) if count > len(list.Items) { count = len(list.Items) } tmp := make([]*AnimeListItem, count) copy(tmp, list.Items[:count]) return tmp } // Watching ... func (list *AnimeList) Watching() *AnimeList { return list.FilterStatus(AnimeListStatusWatching) } // FilterStatus ... func (list *AnimeList) FilterStatus(status string) *AnimeList { newList := &AnimeList{ UserID: list.UserID, Items: []*AnimeListItem{}, } list.Lock() defer list.Unlock() for _, item := range list.Items { if item.Status == status { newList.Items = append(newList.Items, item) } } return newList } // WithoutPrivateItems returns a new anime list with the private items removed. func (list *AnimeList) WithoutPrivateItems() *AnimeList { list.Lock() defer list.Unlock() newList := &AnimeList{ UserID: list.UserID, Items: make([]*AnimeListItem, 0, len(list.Items)), } for _, item := range list.Items { if !item.Private { newList.Items = append(newList.Items, item) } } return newList } // SplitByStatus splits the anime list into multiple ones by status. func (list *AnimeList) SplitByStatus() map[string]*AnimeList { statusToList := map[string]*AnimeList{ AnimeListStatusWatching: {UserID: list.UserID}, AnimeListStatusCompleted: {UserID: list.UserID}, AnimeListStatusPlanned: {UserID: list.UserID}, AnimeListStatusHold: {UserID: list.UserID}, AnimeListStatusDropped: {UserID: list.UserID}, } list.Lock() defer list.Unlock() for _, item := range list.Items { statusList := statusToList[item.Status] statusList.Items = append(statusList.Items, item) } return statusToList } // NormalizeRatings normalizes all ratings so that they are perfectly stretched among the full scale. func (list *AnimeList) NormalizeRatings() { list.Lock() defer list.Unlock() mapped := map[float64]float64{} all := make([]float64, 0, len(list.Items)) for _, item := range list.Items { // Zero rating counts as not rated if item.Rating.Overall == 0 { continue } _, found := mapped[item.Rating.Overall] if !found { mapped[item.Rating.Overall] = item.Rating.Overall all = append(all, item.Rating.Overall) } } sort.Slice(all, func(i, j int) bool { return all[i] < all[j] }) count := len(all) // Prevent division by zero if count <= 1 { return } step := 9.9 / float64(count-1) currentRating := 0.1 for _, rating := range all { mapped[rating] = currentRating currentRating += step } for _, item := range list.Items { item.Rating.Overall = mapped[item.Rating.Overall] item.Rating.Clamp() } } // GetID returns the anime ID. func (list *AnimeList) GetID() string { return list.UserID } // TypeName returns the type name. func (list *AnimeList) TypeName() string { return "AnimeList" } // Self returns the object itself. func (list *AnimeList) Self() Loggable { return list } // Genres returns a map of genre names mapped to the list items that belong to that genre. func (list *AnimeList) Genres() map[string][]*AnimeListItem { genreToListItems := map[string][]*AnimeListItem{} for _, item := range list.Items { for _, genre := range item.Anime().Genres { genreToListItems[genre] = append(genreToListItems[genre], item) } } return genreToListItems } // TopGenres returns the most liked genres for the user's anime list. func (list *AnimeList) TopGenres(count int) []string { genreItems := list.Genres() genreAffinity := map[string]float64{} bestGenres := make([]string, 0, len(genreItems)) for genre, animeListItems := range genreItems { if genre == "Action" || genre == "Comedy" { continue } affinity := 0.0 for _, item := range animeListItems { if item.Status != AnimeListStatusCompleted { continue } if item.Rating.Overall != 0 { affinity += item.Rating.Overall - AverageRating } else { // Add 0.1 to avoid all affinities being 0 when a user doesn't have any rated anime. affinity += 0.1 } } genreAffinity[genre] = affinity bestGenres = append(bestGenres, genre) } sort.Slice(bestGenres, func(i, j int) bool { aAffinity := genreAffinity[bestGenres[i]] bAffinity := genreAffinity[bestGenres[j]] if aAffinity == bAffinity { return bestGenres[i] < bestGenres[j] } return aAffinity > bAffinity }) if len(bestGenres) > count { bestGenres = bestGenres[:count] } return bestGenres } // RemoveDuplicates removes duplicate entries. func (list *AnimeList) RemoveDuplicates() { list.Lock() defer list.Unlock() existed := map[string]bool{} newItems := make([]*AnimeListItem, 0, len(list.Items)) for _, item := range list.Items { _, exists := existed[item.AnimeID] if exists { fmt.Println(list.User().Nick, "removed anime list item duplicate", item.AnimeID) continue } newItems = append(newItems, item) existed[item.AnimeID] = true } list.Items = newItems } // StreamAnimeLists returns a stream of all anime. func StreamAnimeLists() <-chan *AnimeList { channel := make(chan *AnimeList, nano.ChannelBufferSize) go func() { for obj := range DB.All("AnimeList") { channel <- obj.(*AnimeList) } close(channel) }() return channel } // AllAnimeLists returns a slice of all anime. func AllAnimeLists() ([]*AnimeList, error) { all := make([]*AnimeList, 0, DB.Collection("AnimeList").Count()) stream := StreamAnimeLists() for obj := range stream { all = append(all, obj) } return all, nil } // GetAnimeList ... func GetAnimeList(userID UserID) (*AnimeList, error) { animeList, err := DB.Get("AnimeList", userID) if err != nil { return nil, err } return animeList.(*AnimeList), nil }