Files
seanime-docker/seanime-2.9.10/internal/api/anilist/client.go
2025-09-20 14:08:38 +01:00

408 lines
19 KiB
Go

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
}