package anilist import ( "compress/gzip" "context" "errors" "fmt" "io" "net/http" "seanime/internal/events" "seanime/internal/util" "strconv" "time" "github.com/Yamashou/gqlgenc/clientv2" "github.com/Yamashou/gqlgenc/graphqljson" "github.com/goccy/go-json" "github.com/rs/zerolog" ) var ( // ErrNotAuthenticated is returned when trying to access an Anilist API endpoint that requires authentication, // but the client is not authenticated. ErrNotAuthenticated = errors.New("not authenticated") ) type AnilistClient interface { IsAuthenticated() bool AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) } type ( // AnilistClientImpl is a wrapper around the AniList API client. AnilistClientImpl struct { Client *Client logger *zerolog.Logger token string // The token used for authentication with the AniList API } ) // NewAnilistClient creates a new AnilistClientImpl with the given token. // The token is used for authorization when making requests to the AniList API. func NewAnilistClient(token string) *AnilistClientImpl { ac := &AnilistClientImpl{ token: token, Client: &Client{ Client: clientv2.NewClient(http.DefaultClient, "https://graphql.anilist.co", nil, func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if len(token) > 0 { req.Header.Set("Authorization", "Bearer "+token) } return next(ctx, req, gqlInfo, res) }), }, logger: util.NewLogger(), } ac.Client.Client.CustomDo = ac.customDoFunc return ac } func (ac *AnilistClientImpl) IsAuthenticated() bool { if ac.Client == nil || ac.Client.Client == nil { return false } if len(ac.token) == 0 { return false } // If the token is not empty, we are authenticated return true } //////////////////////////////// // Authenticated //////////////////////////////// func (ac *AnilistClientImpl) UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry") return ac.Client.UpdateMediaListEntry(ctx, mediaID, status, scoreRaw, progress, startedAt, completedAt, interceptors...) } func (ac *AnilistClientImpl) UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry progress") return ac.Client.UpdateMediaListEntryProgress(ctx, mediaID, progress, status, interceptors...) } func (ac *AnilistClientImpl) UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry repeat") return ac.Client.UpdateMediaListEntryRepeat(ctx, mediaID, repeat, interceptors...) } func (ac *AnilistClientImpl) DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Int("entryId", *mediaListEntryID).Msg("anilist: Deleting media list entry") return ac.Client.DeleteEntry(ctx, mediaListEntryID, interceptors...) } func (ac *AnilistClientImpl) AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Msg("anilist: Fetching anime collection") return ac.Client.AnimeCollection(ctx, userName, interceptors...) } func (ac *AnilistClientImpl) AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Msg("anilist: Fetching anime collection with relations") return ac.Client.AnimeCollectionWithRelations(ctx, userName, interceptors...) } func (ac *AnilistClientImpl) GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Msg("anilist: Fetching viewer") return ac.Client.GetViewer(ctx, interceptors...) } func (ac *AnilistClientImpl) MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Msg("anilist: Fetching manga collection") return ac.Client.MangaCollection(ctx, userName, interceptors...) } func (ac *AnilistClientImpl) ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) { if !ac.IsAuthenticated() { return nil, ErrNotAuthenticated } ac.logger.Debug().Msg("anilist: Fetching stats") return ac.Client.ViewerStats(ctx, interceptors...) } //////////////////////////////// // Not authenticated //////////////////////////////// func (ac *AnilistClientImpl) BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) { return ac.Client.BaseAnimeByMalID(ctx, id, interceptors...) } func (ac *AnilistClientImpl) BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) { ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching anime") return ac.Client.BaseAnimeByID(ctx, id, interceptors...) } func (ac *AnilistClientImpl) AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) { ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching anime details") return ac.Client.AnimeDetailsByID(ctx, id, interceptors...) } func (ac *AnilistClientImpl) CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) { ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching complete media") return ac.Client.CompleteAnimeByID(ctx, id, interceptors...) } func (ac *AnilistClientImpl) ListAnime(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, season *MediaSeason, seasonYear *int, format *MediaFormat, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListAnime, error) { ac.logger.Debug().Msg("anilist: Fetching media list") return ac.Client.ListAnime(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, season, seasonYear, format, isAdult, interceptors...) } func (ac *AnilistClientImpl) ListRecentAnime(ctx context.Context, page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool, interceptors ...clientv2.RequestInterceptor) (*ListRecentAnime, error) { ac.logger.Debug().Msg("anilist: Fetching recent media list") return ac.Client.ListRecentAnime(ctx, page, perPage, airingAtGreater, airingAtLesser, notYetAired, interceptors...) } func (ac *AnilistClientImpl) SearchBaseManga(ctx context.Context, page *int, perPage *int, sort []*MediaSort, search *string, status []*MediaStatus, interceptors ...clientv2.RequestInterceptor) (*SearchBaseManga, error) { ac.logger.Debug().Msg("anilist: Searching manga") return ac.Client.SearchBaseManga(ctx, page, perPage, sort, search, status, interceptors...) } func (ac *AnilistClientImpl) BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) { ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga") return ac.Client.BaseMangaByID(ctx, id, interceptors...) } func (ac *AnilistClientImpl) MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) { ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga details") return ac.Client.MangaDetailsByID(ctx, id, interceptors...) } func (ac *AnilistClientImpl) ListManga(ctx context.Context, page *int, search *string, perPage *int, sort []*MediaSort, status []*MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *MediaFormat, countryOfOrigin *string, isAdult *bool, interceptors ...clientv2.RequestInterceptor) (*ListManga, error) { ac.logger.Debug().Msg("anilist: Fetching manga list") return ac.Client.ListManga(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, startDateGreater, startDateLesser, format, countryOfOrigin, isAdult, interceptors...) } func (ac *AnilistClientImpl) StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) { ac.logger.Debug().Int("studioId", *id).Msg("anilist: Fetching studio details") return ac.Client.StudioDetails(ctx, id, interceptors...) } func (ac *AnilistClientImpl) SearchBaseAnimeByIds(ctx context.Context, ids []*int, page *int, perPage *int, status []*MediaStatus, inCollection *bool, sort []*MediaSort, season *MediaSeason, year *int, genre *string, format *MediaFormat, interceptors ...clientv2.RequestInterceptor) (*SearchBaseAnimeByIds, error) { ac.logger.Debug().Msg("anilist: Searching anime by ids") return ac.Client.SearchBaseAnimeByIds(ctx, ids, page, perPage, status, inCollection, sort, season, year, genre, format, interceptors...) } func (ac *AnilistClientImpl) AnimeAiringSchedule(ctx context.Context, ids []*int, season *MediaSeason, seasonYear *int, previousSeason *MediaSeason, previousSeasonYear *int, nextSeason *MediaSeason, nextSeasonYear *int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringSchedule, error) { ac.logger.Debug().Msg("anilist: Fetching schedule") return ac.Client.AnimeAiringSchedule(ctx, ids, season, seasonYear, previousSeason, previousSeasonYear, nextSeason, nextSeasonYear, interceptors...) } func (ac *AnilistClientImpl) AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) { ac.logger.Debug().Msg("anilist: Fetching schedule") return ac.Client.AnimeAiringScheduleRaw(ctx, ids, interceptors...) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var sentRateLimitWarningTime = time.Now().Add(-10 * time.Second) // customDoFunc is a custom request interceptor function that handles rate limiting and retries. func (ac *AnilistClientImpl) customDoFunc(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}) (err error) { var rlRemainingStr string reqTime := time.Now() defer func() { timeSince := time.Since(reqTime) formattedDur := timeSince.Truncate(time.Millisecond).String() if err != nil { ac.logger.Error().Str("duration", formattedDur).Str("rlr", rlRemainingStr).Err(err).Msg("anilist: Failed Request") } else { if timeSince > 900*time.Millisecond { ac.logger.Warn().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Successful Request (slow)") } else { ac.logger.Info().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Successful Request") } } }() client := http.DefaultClient var resp *http.Response retryCount := 2 for i := 0; i < retryCount; i++ { // Reset response body for retry if resp != nil && resp.Body != nil { resp.Body.Close() } // Recreate the request body if it was read in a previous attempt if req.GetBody != nil { newBody, err := req.GetBody() if err != nil { return fmt.Errorf("failed to get request body: %w", err) } req.Body = newBody } resp, err = client.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } rlRemainingStr = resp.Header.Get("X-Ratelimit-Remaining") rlRetryAfterStr := resp.Header.Get("Retry-After") //println("Remaining:", rlRemainingStr, " | RetryAfter:", rlRetryAfterStr) // If we have a rate limit, sleep for the time rlRetryAfter, err := strconv.Atoi(rlRetryAfterStr) if err == nil { ac.logger.Warn().Msgf("anilist: Rate limited, retrying in %d seconds", rlRetryAfter+1) if time.Since(sentRateLimitWarningTime) > 10*time.Second { events.GlobalWSEventManager.SendEvent(events.WarningToast, "anilist: Rate limited, retrying in "+strconv.Itoa(rlRetryAfter+1)+" seconds") sentRateLimitWarningTime = time.Now() } select { case <-time.After(time.Duration(rlRetryAfter+1) * time.Second): continue } } if rlRemainingStr == "" { select { case <-time.After(5 * time.Second): continue } } break } defer resp.Body.Close() if resp.Header.Get("Content-Encoding") == "gzip" { resp.Body, err = gzip.NewReader(resp.Body) if err != nil { return fmt.Errorf("gzip decode failed: %w", err) } } var body []byte body, err = io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) } err = parseResponse(body, resp.StatusCode, res) return } func parseResponse(body []byte, httpCode int, result interface{}) error { errResponse := &clientv2.ErrorResponse{} isKOCode := httpCode < 200 || 299 < httpCode if isKOCode { errResponse.NetworkError = &clientv2.HTTPError{ Code: httpCode, Message: fmt.Sprintf("Response body %s", string(body)), } } // some servers return a graphql error with a non OK http code, try anyway to parse the body if err := unmarshal(body, result); err != nil { var gqlErr *clientv2.GqlErrorList if errors.As(err, &gqlErr) { errResponse.GqlErrors = &gqlErr.Errors } else if !isKOCode { return err } } if errResponse.HasErrors() { return errResponse } return nil } // response is a GraphQL layer response from a handler. type response struct { Data json.RawMessage `json:"data"` Errors json.RawMessage `json:"errors"` } func unmarshal(data []byte, res interface{}) error { ParseDataWhenErrors := false resp := response{} if err := json.Unmarshal(data, &resp); err != nil { return fmt.Errorf("failed to decode data %s: %w", string(data), err) } var err error if resp.Errors != nil && len(resp.Errors) > 0 { // try to parse standard graphql error err = &clientv2.GqlErrorList{} if e := json.Unmarshal(data, err); e != nil { return fmt.Errorf("faild to parse graphql errors. Response content %s - %w", string(data), e) } // if ParseDataWhenErrors is true, try to parse data as well if !ParseDataWhenErrors { return err } } if errData := graphqljson.UnmarshalData(resp.Data, res); errData != nil { // if ParseDataWhenErrors is true, and we failed to unmarshal data, return the actual error if ParseDataWhenErrors { return err } return fmt.Errorf("failed to decode data into response %s: %w", string(data), errData) } return err }