node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
model:
filename: ./models_gen.go
client:
filename: ./client_gen.go
models:
DateTime:
model: github.com/99designs/gqlgen/graphql.Time
endpoint:
url: https://graphql.anilist.co
query:
- "./queries/*.graphql"
generate:
clientV2: true
clientInterfaceName: "GithubGraphQLClient"

View File

@@ -0,0 +1,407 @@
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,569 @@
package anilist
import (
"context"
"log"
"os"
"seanime/internal/test_utils"
"seanime/internal/util"
"github.com/Yamashou/gqlgenc/clientv2"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
)
// This file contains helper functions for testing the anilist package
func TestGetMockAnilistClient() AnilistClient {
return NewMockAnilistClient()
}
// MockAnilistClientImpl is a mock implementation of the AnilistClient, used for tests.
// It uses the real implementation of the AnilistClient to make requests then populates a cache with the results.
// This is to avoid making repeated requests to the AniList API during tests but still have realistic data.
type MockAnilistClientImpl struct {
realAnilistClient AnilistClient
logger *zerolog.Logger
}
func NewMockAnilistClient() *MockAnilistClientImpl {
return &MockAnilistClientImpl{
realAnilistClient: NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt),
logger: util.NewLogger(),
}
}
func (ac *MockAnilistClientImpl) IsAuthenticated() bool {
return ac.realAnilistClient.IsAuthenticated()
}
func (ac *MockAnilistClientImpl) BaseAnimeByMalID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByMalID, error) {
file, err := os.Open(test_utils.GetTestDataPath("BaseAnimeByMalID"))
defer file.Close()
if err != nil {
if os.IsNotExist(err) {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByMalID]: %d", *id)
ret, err := ac.realAnilistClient.BaseAnimeByMalID(context.Background(), id)
if err != nil {
return nil, err
}
data, err := json.Marshal([]*BaseAnimeByMalID{ret})
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByMalID"), data, 0644)
if err != nil {
log.Fatal(err)
}
return ret, nil
}
}
var media []*BaseAnimeByMalID
err = json.NewDecoder(file).Decode(&media)
if err != nil {
log.Fatal(err)
}
var ret *BaseAnimeByMalID
for _, m := range media {
if m.GetMedia().ID == *id {
ret = m
break
}
}
if ret == nil {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByMalID]: %d", *id)
ret, err := ac.realAnilistClient.BaseAnimeByMalID(context.Background(), id)
if err != nil {
return nil, err
}
media = append(media, ret)
data, err := json.Marshal(media)
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByMalID"), data, 0644)
if err != nil {
log.Fatal(err)
}
return ret, nil
}
ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [BaseAnimeByMalID]: %d", *id)
return ret, nil
}
func (ac *MockAnilistClientImpl) BaseAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseAnimeByID, error) {
file, err := os.Open(test_utils.GetTestDataPath("BaseAnimeByID"))
defer file.Close()
if err != nil {
if os.IsNotExist(err) {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByID]: %d", *id)
baseAnime, err := ac.realAnilistClient.BaseAnimeByID(context.Background(), id)
if err != nil {
return nil, err
}
data, err := json.Marshal([]*BaseAnimeByID{baseAnime})
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByID"), data, 0644)
if err != nil {
log.Fatal(err)
}
return baseAnime, nil
}
}
var media []*BaseAnimeByID
err = json.NewDecoder(file).Decode(&media)
if err != nil {
log.Fatal(err)
}
var baseAnime *BaseAnimeByID
for _, m := range media {
if m.GetMedia().ID == *id {
baseAnime = m
break
}
}
if baseAnime == nil {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [BaseAnimeByID]: %d", *id)
baseAnime, err := ac.realAnilistClient.BaseAnimeByID(context.Background(), id)
if err != nil {
return nil, err
}
media = append(media, baseAnime)
data, err := json.Marshal(media)
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("BaseAnimeByID"), data, 0644)
if err != nil {
log.Fatal(err)
}
return baseAnime, nil
}
ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [BaseAnimeByID]: %d", *id)
return baseAnime, nil
}
// AnimeCollection
// - Set userName to nil to use the boilerplate AnimeCollection
// - Set userName to a specific username to fetch and cache
func (ac *MockAnilistClientImpl) AnimeCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollection, error) {
if userName == nil {
file, err := os.Open(test_utils.GetDataPath("BoilerplateAnimeCollection"))
defer file.Close()
var ret *AnimeCollection
err = json.NewDecoder(file).Decode(&ret)
if err != nil {
log.Fatal(err)
}
ac.logger.Trace().Msgf("MockAnilistClientImpl: Using [BoilerplateAnimeCollection]")
return ret, nil
}
file, err := os.Open(test_utils.GetTestDataPath("AnimeCollection"))
defer file.Close()
if err != nil {
if os.IsNotExist(err) {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollection]: %s", *userName)
ret, err := ac.realAnilistClient.AnimeCollection(context.Background(), userName)
if err != nil {
return nil, err
}
data, err := json.Marshal(ret)
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollection"), data, 0644)
if err != nil {
log.Fatal(err)
}
return ret, nil
}
}
var ret *AnimeCollection
err = json.NewDecoder(file).Decode(&ret)
if err != nil {
log.Fatal(err)
}
if ret == nil {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollection]: %s", *userName)
ret, err := ac.realAnilistClient.AnimeCollection(context.Background(), userName)
if err != nil {
return nil, err
}
data, err := json.Marshal(ret)
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollection"), data, 0644)
if err != nil {
log.Fatal(err)
}
return ret, nil
}
ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [AnimeCollection]: %s", *userName)
return ret, nil
}
func (ac *MockAnilistClientImpl) AnimeCollectionWithRelations(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*AnimeCollectionWithRelations, error) {
if userName == nil {
file, err := os.Open(test_utils.GetDataPath("BoilerplateAnimeCollectionWithRelations"))
defer file.Close()
var ret *AnimeCollectionWithRelations
err = json.NewDecoder(file).Decode(&ret)
if err != nil {
log.Fatal(err)
}
ac.logger.Trace().Msgf("MockAnilistClientImpl: Using [BoilerplateAnimeCollectionWithRelations]")
return ret, nil
}
file, err := os.Open(test_utils.GetTestDataPath("AnimeCollectionWithRelations"))
defer file.Close()
if err != nil {
if os.IsNotExist(err) {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollectionWithRelations]: %s", *userName)
ret, err := ac.realAnilistClient.AnimeCollectionWithRelations(context.Background(), userName)
if err != nil {
return nil, err
}
data, err := json.Marshal(ret)
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollectionWithRelations"), data, 0644)
if err != nil {
log.Fatal(err)
}
return ret, nil
}
}
var ret *AnimeCollectionWithRelations
err = json.NewDecoder(file).Decode(&ret)
if err != nil {
log.Fatal(err)
}
if ret == nil {
ac.logger.Warn().Msgf("MockAnilistClientImpl: CACHE MISS [AnimeCollectionWithRelations]: %s", *userName)
ret, err := ac.realAnilistClient.AnimeCollectionWithRelations(context.Background(), userName)
if err != nil {
return nil, err
}
data, err := json.Marshal(ret)
if err != nil {
log.Fatal(err)
}
err = os.WriteFile(test_utils.GetTestDataPath("AnimeCollectionWithRelations"), data, 0644)
if err != nil {
log.Fatal(err)
}
return ret, nil
}
ac.logger.Trace().Msgf("MockAnilistClientImpl: CACHE HIT [AnimeCollectionWithRelations]: %s", *userName)
return ret, nil
}
type TestModifyAnimeCollectionEntryInput struct {
Status *MediaListStatus
Progress *int
Score *float64
AiredEpisodes *int
NextAiringEpisode *BaseAnime_NextAiringEpisode
}
// TestModifyAnimeCollectionEntry will modify an entry in the fetched anime collection.
// This is used to fine-tune the anime collection for testing purposes.
//
// Example: Setting a specific progress in case the origin anime collection has no progress
func TestModifyAnimeCollectionEntry(ac *AnimeCollection, mId int, input TestModifyAnimeCollectionEntryInput) *AnimeCollection {
if ac == nil {
panic("AnimeCollection is nil")
}
lists := ac.GetMediaListCollection().GetLists()
removedFromList := false
var rEntry *AnimeCollection_MediaListCollection_Lists_Entries
// Move the entry to the correct list
if input.Status != nil {
for _, list := range lists {
if list.Status == nil || list.Entries == nil {
continue
}
entries := list.GetEntries()
for idx, entry := range entries {
if entry.GetMedia().ID == mId {
// Remove from current list if status differs
if *list.Status != *input.Status {
removedFromList = true
rEntry = entry
// Ensure we're not going out of bounds
if idx >= 0 && idx < len(entries) {
// Safely remove the entry by re-slicing
list.Entries = append(entries[:idx], entries[idx+1:]...)
}
break
}
}
}
}
// Add the entry to the correct list if it was removed
if removedFromList && rEntry != nil {
for _, list := range lists {
if list.Status == nil {
continue
}
if *list.Status == *input.Status {
if list.Entries == nil {
list.Entries = make([]*AnimeCollection_MediaListCollection_Lists_Entries, 0)
}
// Add the removed entry to the new list
list.Entries = append(list.Entries, rEntry)
break
}
}
}
}
// Update the entry details
out:
for _, list := range lists {
entries := list.GetEntries()
for _, entry := range entries {
if entry.GetMedia().ID == mId {
if input.Status != nil {
entry.Status = input.Status
}
if input.Progress != nil {
entry.Progress = input.Progress
}
if input.Score != nil {
entry.Score = input.Score
}
if input.AiredEpisodes != nil {
entry.Media.Episodes = input.AiredEpisodes
}
if input.NextAiringEpisode != nil {
entry.Media.NextAiringEpisode = input.NextAiringEpisode
}
break out
}
}
}
return ac
}
func TestAddAnimeCollectionEntry(ac *AnimeCollection, mId int, input TestModifyAnimeCollectionEntryInput, realClient AnilistClient) *AnimeCollection {
if ac == nil {
panic("AnimeCollection is nil")
}
// Fetch the anime details
baseAnime, err := realClient.BaseAnimeByID(context.Background(), &mId)
if err != nil {
log.Fatal(err)
}
anime := baseAnime.GetMedia()
if input.NextAiringEpisode != nil {
anime.NextAiringEpisode = input.NextAiringEpisode
}
if input.AiredEpisodes != nil {
anime.Episodes = input.AiredEpisodes
}
lists := ac.GetMediaListCollection().GetLists()
// Add the entry to the correct list
if input.Status != nil {
for _, list := range lists {
if list.Status == nil {
continue
}
if *list.Status == *input.Status {
if list.Entries == nil {
list.Entries = make([]*AnimeCollection_MediaListCollection_Lists_Entries, 0)
}
list.Entries = append(list.Entries, &AnimeCollection_MediaListCollection_Lists_Entries{
Media: baseAnime.GetMedia(),
Status: input.Status,
Progress: input.Progress,
Score: input.Score,
})
break
}
}
}
return ac
}
func TestAddAnimeCollectionWithRelationsEntry(ac *AnimeCollectionWithRelations, mId int, input TestModifyAnimeCollectionEntryInput, realClient AnilistClient) *AnimeCollectionWithRelations {
if ac == nil {
panic("AnimeCollection is nil")
}
// Fetch the anime details
baseAnime, err := realClient.CompleteAnimeByID(context.Background(), &mId)
if err != nil {
log.Fatal(err)
}
anime := baseAnime.GetMedia()
//if input.NextAiringEpisode != nil {
// anime.NextAiringEpisode = input.NextAiringEpisode
//}
if input.AiredEpisodes != nil {
anime.Episodes = input.AiredEpisodes
}
lists := ac.GetMediaListCollection().GetLists()
// Add the entry to the correct list
if input.Status != nil {
for _, list := range lists {
if list.Status == nil {
continue
}
if *list.Status == *input.Status {
if list.Entries == nil {
list.Entries = make([]*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries, 0)
}
list.Entries = append(list.Entries, &AnimeCollectionWithRelations_MediaListCollection_Lists_Entries{
Media: baseAnime.GetMedia(),
Status: input.Status,
Progress: input.Progress,
Score: input.Score,
})
break
}
}
}
return ac
}
//
// WILL NOT IMPLEMENT
//
func (ac *MockAnilistClientImpl) UpdateMediaListEntry(ctx context.Context, mediaID *int, status *MediaListStatus, scoreRaw *int, progress *int, startedAt *FuzzyDateInput, completedAt *FuzzyDateInput, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntry, error) {
ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry")
return &UpdateMediaListEntry{}, nil
}
func (ac *MockAnilistClientImpl) UpdateMediaListEntryProgress(ctx context.Context, mediaID *int, progress *int, status *MediaListStatus, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryProgress, error) {
ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry progress")
return &UpdateMediaListEntryProgress{}, nil
}
func (ac *MockAnilistClientImpl) UpdateMediaListEntryRepeat(ctx context.Context, mediaID *int, repeat *int, interceptors ...clientv2.RequestInterceptor) (*UpdateMediaListEntryRepeat, error) {
ac.logger.Debug().Int("mediaId", *mediaID).Msg("anilist: Updating media list entry repeat")
return &UpdateMediaListEntryRepeat{}, nil
}
func (ac *MockAnilistClientImpl) DeleteEntry(ctx context.Context, mediaListEntryID *int, interceptors ...clientv2.RequestInterceptor) (*DeleteEntry, error) {
ac.logger.Debug().Int("entryId", *mediaListEntryID).Msg("anilist: Deleting media list entry")
return &DeleteEntry{}, nil
}
func (ac *MockAnilistClientImpl) AnimeDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*AnimeDetailsByID, error) {
ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching anime details")
return ac.realAnilistClient.AnimeDetailsByID(ctx, id, interceptors...)
}
func (ac *MockAnilistClientImpl) CompleteAnimeByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*CompleteAnimeByID, error) {
ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching complete media")
return ac.realAnilistClient.CompleteAnimeByID(ctx, id, interceptors...)
}
func (ac *MockAnilistClientImpl) 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.realAnilistClient.ListAnime(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, season, seasonYear, format, isAdult, interceptors...)
}
func (ac *MockAnilistClientImpl) 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.realAnilistClient.ListRecentAnime(ctx, page, perPage, airingAtGreater, airingAtLesser, notYetAired, interceptors...)
}
func (ac *MockAnilistClientImpl) GetViewer(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*GetViewer, error) {
ac.logger.Debug().Msg("anilist: Fetching viewer")
return ac.realAnilistClient.GetViewer(ctx, interceptors...)
}
func (ac *MockAnilistClientImpl) MangaCollection(ctx context.Context, userName *string, interceptors ...clientv2.RequestInterceptor) (*MangaCollection, error) {
ac.logger.Debug().Msg("anilist: Fetching manga collection")
return ac.realAnilistClient.MangaCollection(ctx, userName, interceptors...)
}
func (ac *MockAnilistClientImpl) 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.realAnilistClient.SearchBaseManga(ctx, page, perPage, sort, search, status, interceptors...)
}
func (ac *MockAnilistClientImpl) BaseMangaByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*BaseMangaByID, error) {
ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga")
return ac.realAnilistClient.BaseMangaByID(ctx, id, interceptors...)
}
func (ac *MockAnilistClientImpl) MangaDetailsByID(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*MangaDetailsByID, error) {
ac.logger.Debug().Int("mediaId", *id).Msg("anilist: Fetching manga details")
return ac.realAnilistClient.MangaDetailsByID(ctx, id, interceptors...)
}
func (ac *MockAnilistClientImpl) 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.realAnilistClient.ListManga(ctx, page, search, perPage, sort, status, genres, averageScoreGreater, startDateGreater, startDateLesser, format, countryOfOrigin, isAdult, interceptors...)
}
func (ac *MockAnilistClientImpl) StudioDetails(ctx context.Context, id *int, interceptors ...clientv2.RequestInterceptor) (*StudioDetails, error) {
ac.logger.Debug().Int("studioId", *id).Msg("anilist: Fetching studio details")
return ac.realAnilistClient.StudioDetails(ctx, id, interceptors...)
}
func (ac *MockAnilistClientImpl) ViewerStats(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*ViewerStats, error) {
ac.logger.Debug().Msg("anilist: Fetching stats")
return ac.realAnilistClient.ViewerStats(ctx, interceptors...)
}
func (ac *MockAnilistClientImpl) 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.realAnilistClient.SearchBaseAnimeByIds(ctx, ids, page, perPage, status, inCollection, sort, season, year, genre, format, interceptors...)
}
func (ac *MockAnilistClientImpl) 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.realAnilistClient.AnimeAiringSchedule(ctx, ids, season, seasonYear, previousSeason, previousSeasonYear, nextSeason, nextSeasonYear, interceptors...)
}
func (ac *MockAnilistClientImpl) AnimeAiringScheduleRaw(ctx context.Context, ids []*int, interceptors ...clientv2.RequestInterceptor) (*AnimeAiringScheduleRaw, error) {
ac.logger.Debug().Msg("anilist: Fetching schedule")
return ac.realAnilistClient.AnimeAiringScheduleRaw(ctx, ids, interceptors...)
}

View File

@@ -0,0 +1,73 @@
package anilist
import (
"context"
"github.com/goccy/go-json"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"os"
"seanime/internal/test_utils"
"testing"
)
// USE CASE: Generate a boilerplate Anilist AnimeCollection for testing purposes and save it to 'test/data/BoilerplateAnimeCollection'.
// The generated AnimeCollection will have all entries in the 'Planning' status.
// The generated AnimeCollection will be used to test various Anilist API methods.
// You can use TestModifyAnimeCollectionEntry to modify the generated AnimeCollection before using it in a test.
// - DO NOT RUN IF YOU DON'T PLAN TO GENERATE A NEW 'test/data/BoilerplateAnimeCollection'
func TestGenerateBoilerplateAnimeCollection(t *testing.T) {
t.Skip("This test is not meant to be run")
test_utils.InitTestProvider(t, test_utils.Anilist())
anilistClient := TestGetMockAnilistClient()
ac, err := anilistClient.AnimeCollection(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername)
if assert.NoError(t, err) {
lists := ac.GetMediaListCollection().GetLists()
entriesToAddToPlanning := make([]*AnimeListEntry, 0)
if assert.NoError(t, err) {
for _, list := range lists {
if list.Status != nil {
if list.GetStatus().String() != string(MediaListStatusPlanning) {
entries := list.GetEntries()
for _, entry := range entries {
entry.Progress = lo.ToPtr(0)
entry.Score = lo.ToPtr(0.0)
entry.Status = lo.ToPtr(MediaListStatusPlanning)
entriesToAddToPlanning = append(entriesToAddToPlanning, entry)
}
list.Entries = make([]*AnimeListEntry, 0)
}
}
}
newLists := make([]*AnimeCollection_MediaListCollection_Lists, 0)
for _, list := range lists {
if list.Status == nil {
continue
}
if *list.GetStatus() == MediaListStatusPlanning {
list.Entries = append(list.Entries, entriesToAddToPlanning...)
newLists = append(newLists, list)
} else {
newLists = append(newLists, list)
}
}
ac.MediaListCollection.Lists = newLists
data, err := json.Marshal(ac)
if assert.NoError(t, err) {
err = os.WriteFile(test_utils.GetDataPath("BoilerplateAnimeCollection"), data, 0644)
assert.NoError(t, err)
}
}
}
}

View File

@@ -0,0 +1,237 @@
package anilist
import (
"context"
"github.com/davecgh/go-spew/spew"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
//func TestHiddenFromStatus(t *testing.T) {
// test_utils.InitTestProvider(t, test_utils.Anilist())
//
// token := test_utils.ConfigData.Provider.AnilistJwt
// logger := util.NewLogger()
// //anilistClient := NewAnilistClient(test_utils.ConfigData.Provider.AnilistJwt)
//
// variables := map[string]interface{}{}
//
// variables["userName"] = test_utils.ConfigData.Provider.AnilistUsername
// variables["type"] = "ANIME"
//
// requestBody, err := json.Marshal(map[string]interface{}{
// "query": testQuery,
// "variables": variables,
// })
// require.NoError(t, err)
//
// data, err := customQuery(requestBody, logger, token)
// require.NoError(t, err)
//
// var mediaLists []*MediaList
//
// type retData struct {
// Page Page
// PageInfo PageInfo
// }
//
// var ret retData
// m, err := json.Marshal(data)
// require.NoError(t, err)
// if err := json.Unmarshal(m, &ret); err != nil {
// t.Fatalf("Failed to unmarshal data: %v", err)
// }
//
// mediaLists = append(mediaLists, ret.Page.MediaList...)
//
// util.Spew(ret.Page.PageInfo)
//
// var currentPage = 1
// var hasNextPage = false
// if ret.Page.PageInfo != nil && ret.Page.PageInfo.HasNextPage != nil {
// hasNextPage = *ret.Page.PageInfo.HasNextPage
// }
// for hasNextPage {
// currentPage++
// variables["page"] = currentPage
// requestBody, err = json.Marshal(map[string]interface{}{
// "query": testQuery,
// "variables": variables,
// })
// require.NoError(t, err)
// data, err = customQuery(requestBody, logger, token)
// require.NoError(t, err)
// m, err = json.Marshal(data)
// require.NoError(t, err)
// if err := json.Unmarshal(m, &ret); err != nil {
// t.Fatalf("Failed to unmarshal data: %v", err)
// }
// util.Spew(ret.Page.PageInfo)
// if ret.Page.PageInfo != nil && ret.Page.PageInfo.HasNextPage != nil {
// hasNextPage = *ret.Page.PageInfo.HasNextPage
// }
// mediaLists = append(mediaLists, ret.Page.MediaList...)
// }
//
// //res, err := anilistClient.AnimeCollection(context.Background(), &test_utils.ConfigData.Provider.AnilistUsername)
// //assert.NoError(t, err)
//
// for _, mediaList := range mediaLists {
// util.Spew(mediaList.Media.ID)
// if mediaList.Media.ID == 151514 {
// util.Spew(mediaList)
// }
// }
//
//}
//
//const testQuery = `query ($page: Int, $userName: String, $type: MediaType) {
// Page (page: $page, perPage: 100) {
// pageInfo {
// hasNextPage
// total
// perPage
// currentPage
// lastPage
// }
// mediaList (type: $type, userName: $userName) {
// status
// startedAt {
// year
// month
// day
// }
// completedAt {
// year
// month
// day
// }
// repeat
// score(format: POINT_100)
// progress
// progressVolumes
// notes
// media {
// siteUrl
// id
// idMal
// episodes
// chapters
// volumes
// status
// averageScore
// coverImage{
// large
// extraLarge
// }
// bannerImage
// title {
// userPreferred
// }
// }
// }
// }
// }`
func TestGetAnimeById(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.Anilist())
anilistClient := TestGetMockAnilistClient()
tests := []struct {
name string
mediaId int
}{
{
name: "Cowboy Bebop",
mediaId: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId)
assert.NoError(t, err)
assert.NotNil(t, res)
})
}
}
func TestListAnime(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.Anilist())
tests := []struct {
name string
Page *int
Search *string
PerPage *int
Sort []*MediaSort
Status []*MediaStatus
Genres []*string
AverageScoreGreater *int
Season *MediaSeason
SeasonYear *int
Format *MediaFormat
IsAdult *bool
}{
{
name: "Popular",
Page: lo.ToPtr(1),
Search: nil,
PerPage: lo.ToPtr(20),
Sort: []*MediaSort{lo.ToPtr(MediaSortTrendingDesc)},
Status: nil,
Genres: nil,
AverageScoreGreater: nil,
Season: nil,
SeasonYear: nil,
Format: nil,
IsAdult: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cacheKey := ListAnimeCacheKey(
tt.Page,
tt.Search,
tt.PerPage,
tt.Sort,
tt.Status,
tt.Genres,
tt.AverageScoreGreater,
tt.Season,
tt.SeasonYear,
tt.Format,
tt.IsAdult,
)
t.Log(cacheKey)
res, err := ListAnimeM(
tt.Page,
tt.Search,
tt.PerPage,
tt.Sort,
tt.Status,
tt.Genres,
tt.AverageScoreGreater,
tt.Season,
tt.SeasonYear,
tt.Format,
tt.IsAdult,
util.NewLogger(),
"",
)
assert.NoError(t, err)
assert.Equal(t, *tt.PerPage, len(res.GetPage().GetMedia()))
spew.Dump(res)
})
}
}

View File

@@ -0,0 +1,248 @@
package anilist
import (
"time"
"github.com/goccy/go-json"
)
type (
AnimeListEntry = AnimeCollection_MediaListCollection_Lists_Entries
AnimeList = AnimeCollection_MediaListCollection_Lists
EntryDate struct {
Year *int `json:"year,omitempty"`
Month *int `json:"month,omitempty"`
Day *int `json:"day,omitempty"`
}
)
func (ac *AnimeCollection) GetListEntryFromAnimeId(id int) (*AnimeListEntry, bool) {
if ac == nil || ac.MediaListCollection == nil {
return nil, false
}
var entry *AnimeCollection_MediaListCollection_Lists_Entries
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if e.Media.ID == id {
entry = e
break
}
}
}
if entry == nil {
return nil, false
}
return entry, true
}
func (ac *AnimeCollection) GetAllAnime() []*BaseAnime {
if ac == nil {
return make([]*BaseAnime, 0)
}
var ret []*BaseAnime
addedId := make(map[int]bool)
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if _, ok := addedId[e.Media.ID]; !ok {
ret = append(ret, e.Media)
addedId[e.Media.ID] = true
}
}
}
return ret
}
func (ac *AnimeCollection) FindAnime(mediaId int) (*BaseAnime, bool) {
if ac == nil {
return nil, false
}
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if e.Media.ID == mediaId {
return e.Media, true
}
}
}
return nil, false
}
func (ac *AnimeCollectionWithRelations) GetListEntryFromMediaId(id int) (*AnimeCollectionWithRelations_MediaListCollection_Lists_Entries, bool) {
if ac == nil || ac.MediaListCollection == nil {
return nil, false
}
var entry *AnimeCollectionWithRelations_MediaListCollection_Lists_Entries
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if e.Media.ID == id {
entry = e
break
}
}
}
if entry == nil {
return nil, false
}
return entry, true
}
func (ac *AnimeCollectionWithRelations) GetAllAnime() []*CompleteAnime {
var ret []*CompleteAnime
addedId := make(map[int]bool)
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if _, ok := addedId[e.Media.ID]; !ok {
ret = append(ret, e.Media)
addedId[e.Media.ID] = true
}
}
}
return ret
}
func (ac *AnimeCollectionWithRelations) FindAnime(mediaId int) (*CompleteAnime, bool) {
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if e.Media.ID == mediaId {
return e.Media, true
}
}
}
return nil, false
}
type IFuzzyDate interface {
GetYear() *int
GetMonth() *int
GetDay() *int
}
func FuzzyDateToString(d IFuzzyDate) string {
if d == nil {
return ""
}
return fuzzyDateToString(d.GetYear(), d.GetMonth(), d.GetDay())
}
func ToEntryStartDate(d *AnimeCollection_MediaListCollection_Lists_Entries_StartedAt) string {
if d == nil {
return ""
}
return fuzzyDateToString(d.GetYear(), d.GetMonth(), d.GetDay())
}
func ToEntryCompletionDate(d *AnimeCollection_MediaListCollection_Lists_Entries_CompletedAt) string {
if d == nil {
return ""
}
return fuzzyDateToString(d.GetYear(), d.GetMonth(), d.GetDay())
}
func fuzzyDateToString(year *int, month *int, day *int) string {
_year := 0
if year != nil {
_year = *year
}
if _year == 0 {
return ""
}
_month := 0
if month != nil {
_month = *month
}
_day := 0
if day != nil {
_day = *day
}
return time.Date(_year, time.Month(_month), _day, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)
}
// AddEntryToList adds an entry to the appropriate list based on the provided status.
// If no list exists with the given status, a new list is created.
func (mc *AnimeCollection_MediaListCollection) AddEntryToList(entry *AnimeCollection_MediaListCollection_Lists_Entries, status MediaListStatus) {
if mc == nil || entry == nil {
return
}
// Initialize Lists slice if nil
if mc.Lists == nil {
mc.Lists = make([]*AnimeCollection_MediaListCollection_Lists, 0)
}
// Find existing list with the target status
for _, list := range mc.Lists {
if list.Status != nil && *list.Status == status {
// Found the list, add the entry
if list.Entries == nil {
list.Entries = make([]*AnimeCollection_MediaListCollection_Lists_Entries, 0)
}
list.Entries = append(list.Entries, entry)
return
}
}
// No list found with the target status, create a new one
newList := &AnimeCollection_MediaListCollection_Lists{
Status: &status,
Entries: []*AnimeCollection_MediaListCollection_Lists_Entries{entry},
}
mc.Lists = append(mc.Lists, newList)
}
func (ac *AnimeCollection) Copy() *AnimeCollection {
if ac == nil {
return nil
}
marshaled, err := json.Marshal(ac)
if err != nil {
return nil
}
var copy AnimeCollection
err = json.Unmarshal(marshaled, &copy)
if err != nil {
return nil
}
return &copy
}
func (ac *AnimeList) CopyT() *AnimeCollection_MediaListCollection_Lists {
if ac == nil {
return nil
}
marshaled, err := json.Marshal(ac)
if err != nil {
return nil
}
var copy AnimeCollection_MediaListCollection_Lists
err = json.Unmarshal(marshaled, &copy)
if err != nil {
return nil
}
return &copy
}

View File

@@ -0,0 +1,115 @@
package anilist
import (
"fmt"
"github.com/goccy/go-json"
"seanime/internal/util"
"strconv"
)
func FetchBaseAnimeMap(ids []int) (ret map[int]*BaseAnime, err error) {
query := fmt.Sprintf(CompoundBaseAnimeDocument, newCompoundQuery(ids))
requestBody, err := json.Marshal(map[string]interface{}{
"query": query,
"variables": nil,
})
if err != nil {
return nil, err
}
data, err := customQuery(requestBody, util.NewLogger())
if err != nil {
return nil, err
}
var res map[string]*BaseAnime
dataB, err := json.Marshal(data)
if err != nil {
return nil, err
}
err = json.Unmarshal(dataB, &res)
if err != nil {
return nil, err
}
ret = make(map[int]*BaseAnime)
for k, v := range res {
id, err := strconv.Atoi(k[1:])
if err != nil {
return nil, err
}
ret[id] = v
}
return ret, nil
}
func newCompoundQuery(ids []int) string {
var query string
for _, id := range ids {
query += fmt.Sprintf(`
t%d: Media(id: %d) {
...baseAnime
}
`, id, id)
}
return query
}
const CompoundBaseAnimeDocument = `query CompoundQueryTest {
%s
}
fragment baseAnime on Media {
id
idMal
siteUrl
status(version: 2)
season
type
format
bannerImage
episodes
synonyms
isAdult
countryOfOrigin
meanScore
description
genres
duration
trailer {
id
site
thumbnail
}
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
}`

View File

@@ -0,0 +1,95 @@
package anilist
import (
"fmt"
"github.com/davecgh/go-spew/spew"
"github.com/goccy/go-json"
"github.com/stretchr/testify/require"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestCompoundQuery(t *testing.T) {
test_utils.InitTestProvider(t)
var ids = []int{171457, 21}
query := fmt.Sprintf(compoundQueryFormatTest, newCompoundQuery(ids))
t.Log(query)
requestBody, err := json.Marshal(map[string]interface{}{
"query": query,
"variables": nil,
})
require.NoError(t, err)
data, err := customQuery(requestBody, util.NewLogger())
require.NoError(t, err)
var res map[string]*BaseAnime
dataB, err := json.Marshal(data)
require.NoError(t, err)
err = json.Unmarshal(dataB, &res)
require.NoError(t, err)
spew.Dump(res)
}
const compoundQueryFormatTest = `query CompoundQueryTest {
%s
}
fragment baseAnime on Media {
id
idMal
siteUrl
status(version: 2)
season
type
format
bannerImage
episodes
synonyms
isAdult
countryOfOrigin
meanScore
description
genres
duration
trailer {
id
site
thumbnail
}
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
}`

View File

@@ -0,0 +1,140 @@
package anilist
import (
"bytes"
"compress/gzip"
"errors"
"fmt"
"net/http"
"seanime/internal/util"
"strconv"
"time"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
)
func CustomQuery(body map[string]interface{}, logger *zerolog.Logger, token string) (data interface{}, err error) {
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
return customQuery(bodyBytes, logger, token)
}
func customQuery(body []byte, logger *zerolog.Logger, token ...string) (data interface{}, err error) {
var rlRemainingStr string
reqTime := time.Now()
defer func() {
timeSince := time.Since(reqTime)
formattedDur := timeSince.Truncate(time.Millisecond).String()
if err != nil {
logger.Error().Str("duration", formattedDur).Str("rlr", rlRemainingStr).Err(err).Msg("anilist: Failed Request")
} else {
if timeSince > 600*time.Millisecond {
logger.Warn().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Long Request")
} else {
logger.Trace().Str("rtt", formattedDur).Str("rlr", rlRemainingStr).Msg("anilist: Successful Request")
}
}
}()
defer util.HandlePanicInModuleThen("api/anilist/custom_query", func() {
err = errors.New("panic in customQuery")
})
client := http.DefaultClient
var req *http.Request
req, err = http.NewRequest("POST", "https://graphql.anilist.co", bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if len(token) > 0 && token[0] != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token[0]))
}
// Send request
retryCount := 2
var resp *http.Response
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 nil, fmt.Errorf("failed to get request body: %w", err)
}
req.Body = newBody
}
resp, err = client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
rlRemainingStr = resp.Header.Get("X-Ratelimit-Remaining")
rlRetryAfterStr := resp.Header.Get("Retry-After")
rlRetryAfter, err := strconv.Atoi(rlRetryAfterStr)
if err == nil {
logger.Warn().Msgf("anilist: Rate limited, retrying in %d seconds", rlRetryAfter+1)
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 nil, fmt.Errorf("gzip decode failed: %w", err)
}
}
var res interface{}
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
var ok bool
reqErrors, ok := res.(map[string]interface{})["errors"].([]interface{})
if ok && len(reqErrors) > 0 {
firstError, foundErr := reqErrors[0].(map[string]interface{})
if foundErr {
return nil, errors.New(firstError["message"].(string))
}
}
data, ok = res.(map[string]interface{})["data"]
if !ok {
return nil, errors.New("failed to parse data")
}
return data, nil
}

View File

@@ -0,0 +1,27 @@
package anilist
//import (
//)
//
//func TestFuzzyDate(t *testing.T) {
//
// date := "2006-01-02T15:04:05Z"
//
// parsedDate, err := time.Parse(time.RFC3339, date)
// if err != nil {
// t.Fatal(err)
// }
//
// year := parsedDate.Year()
// month := int(parsedDate.Month())
// day := parsedDate.Day()
// t.Logf("Year: %d, Month: %d, Day: %d", year, month, day)
//
//}
//
//func TestDateTransformation(t *testing.T) {
//
// t.Logf(time.Date(2024, time.Month(1), 1, 0, 0, 0, 0, time.Local).UTC().Format(time.RFC3339))
//
//}

View File

@@ -0,0 +1,50 @@
package anilist
import (
"context"
"errors"
"github.com/rs/zerolog"
"seanime/internal/util/limiter"
"sync"
)
func (c *Client) AddMediaToPlanning(mIds []int, rateLimiter *limiter.Limiter, logger *zerolog.Logger) error {
if len(mIds) == 0 {
logger.Debug().Msg("anilist: No media added to planning list")
return nil
}
if rateLimiter == nil {
return errors.New("anilist: no rate limiter provided")
}
status := MediaListStatusPlanning
scoreRaw := 0
progress := 0
wg := sync.WaitGroup{}
for _, _id := range mIds {
wg.Add(1)
go func(id int) {
rateLimiter.Wait()
defer wg.Done()
_, err := c.UpdateMediaListEntry(
context.Background(),
&id,
&status,
&scoreRaw,
&progress,
nil,
nil,
)
if err != nil {
logger.Error().Msg("anilist: An error occurred while adding media to planning list: " + err.Error())
}
}(_id)
}
wg.Wait()
logger.Debug().Any("count", len(mIds)).Msg("anilist: Media added to planning list")
return nil
}

View File

@@ -0,0 +1,19 @@
package anilist
import "seanime/internal/hook_resolver"
// ListMissedSequelsRequestedEvent is triggered when the list missed sequels request is requested.
// Prevent default to skip the default behavior and return your own data.
type ListMissedSequelsRequestedEvent struct {
hook_resolver.Event
AnimeCollectionWithRelations *AnimeCollectionWithRelations `json:"animeCollectionWithRelations"`
Variables map[string]interface{} `json:"variables"`
Query string `json:"query"`
// Empty data object, will be used if the hook prevents the default behavior
List []*BaseAnime `json:"list"`
}
type ListMissedSequelsEvent struct {
hook_resolver.Event
List []*BaseAnime `json:"list"`
}

View File

@@ -0,0 +1,529 @@
package anilist
import (
"fmt"
"seanime/internal/hook"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
func ListMissedSequels(
animeCollectionWithRelations *AnimeCollectionWithRelations,
logger *zerolog.Logger,
token string,
) (ret []*BaseAnime, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
variables := map[string]interface{}{}
variables["page"] = 1
variables["perPage"] = 50
ids := make(map[int]struct{})
for _, list := range animeCollectionWithRelations.GetMediaListCollection().GetLists() {
if list.Status == nil || !(*list.Status == MediaListStatusCompleted || *list.Status == MediaListStatusRepeating || *list.Status == MediaListStatusPaused) || list.Entries == nil {
continue
}
for _, entry := range list.Entries {
if _, ok := ids[entry.GetMedia().GetID()]; !ok {
edges := entry.GetMedia().GetRelations().GetEdges()
var sequel *BaseAnime
for _, edge := range edges {
if edge.GetRelationType() != nil && *edge.GetRelationType() == MediaRelationSequel {
sequel = edge.GetNode()
break
}
}
if sequel == nil {
continue
}
// Check if sequel is already in the list
_, found := animeCollectionWithRelations.FindAnime(sequel.GetID())
if found {
continue
}
if *sequel.GetStatus() == MediaStatusFinished || *sequel.GetStatus() == MediaStatusReleasing {
ids[sequel.GetID()] = struct{}{}
}
}
}
}
idsSlice := make([]int, 0, len(ids))
for id := range ids {
idsSlice = append(idsSlice, id)
}
if len(idsSlice) == 0 {
return []*BaseAnime{}, nil
}
if len(idsSlice) > 10 {
idsSlice = idsSlice[:10]
}
variables["ids"] = idsSlice
variables["inCollection"] = false
variables["sort"] = MediaSortStartDateDesc
// Event
reqEvent := &ListMissedSequelsRequestedEvent{
AnimeCollectionWithRelations: animeCollectionWithRelations,
Variables: variables,
List: make([]*BaseAnime, 0),
Query: SearchBaseAnimeByIdsDocument,
}
err = hook.GlobalHookManager.OnListMissedSequelsRequested().Trigger(reqEvent)
if err != nil {
return nil, err
}
// If the hook prevented the default behavior, return the data
if reqEvent.DefaultPrevented {
return reqEvent.List, nil
}
requestBody, err := json.Marshal(map[string]interface{}{
"query": reqEvent.Query,
"variables": reqEvent.Variables,
})
if err != nil {
return nil, err
}
data, err := customQuery(requestBody, logger, token)
if err != nil {
return nil, err
}
m, err := json.Marshal(data)
if err != nil {
return nil, err
}
var searchRes *SearchBaseAnimeByIds
if err := json.Unmarshal(m, &searchRes); err != nil {
return nil, err
}
if searchRes == nil || searchRes.Page == nil || searchRes.Page.Media == nil {
return nil, fmt.Errorf("no data found")
}
// Event
event := &ListMissedSequelsEvent{
List: searchRes.Page.Media,
}
err = hook.GlobalHookManager.OnListMissedSequels().Trigger(event)
if err != nil {
return nil, err
}
return event.List, nil
}
func ListAnimeM(
Page *int,
Search *string,
PerPage *int,
Sort []*MediaSort,
Status []*MediaStatus,
Genres []*string,
AverageScoreGreater *int,
Season *MediaSeason,
SeasonYear *int,
Format *MediaFormat,
IsAdult *bool,
logger *zerolog.Logger,
token string,
) (*ListAnime, error) {
variables := map[string]interface{}{}
if Page != nil {
variables["page"] = *Page
}
if Search != nil {
variables["search"] = *Search
}
if PerPage != nil {
variables["perPage"] = *PerPage
}
if Sort != nil {
variables["sort"] = Sort
}
if Status != nil {
variables["status"] = Status
}
if Genres != nil {
variables["genres"] = Genres
}
if AverageScoreGreater != nil {
variables["averageScore_greater"] = *AverageScoreGreater
}
if Season != nil {
variables["season"] = *Season
}
if SeasonYear != nil {
variables["seasonYear"] = *SeasonYear
}
if Format != nil {
variables["format"] = *Format
}
if IsAdult != nil {
variables["isAdult"] = *IsAdult
}
requestBody, err := json.Marshal(map[string]interface{}{
"query": ListAnimeDocument,
"variables": variables,
})
if err != nil {
return nil, err
}
data, err := customQuery(requestBody, logger, token)
if err != nil {
return nil, err
}
var listMediaF ListAnime
m, err := json.Marshal(data)
if err != nil {
return nil, err
}
if err := json.Unmarshal(m, &listMediaF); err != nil {
return nil, err
}
return &listMediaF, nil
}
func ListMangaM(
Page *int,
Search *string,
PerPage *int,
Sort []*MediaSort,
Status []*MediaStatus,
Genres []*string,
AverageScoreGreater *int,
Year *int,
Format *MediaFormat,
CountryOfOrigin *string,
IsAdult *bool,
logger *zerolog.Logger,
token string,
) (*ListManga, error) {
variables := map[string]interface{}{}
if Page != nil {
variables["page"] = *Page
}
if Search != nil {
variables["search"] = *Search
}
if PerPage != nil {
variables["perPage"] = *PerPage
}
if Sort != nil {
variables["sort"] = Sort
}
if Status != nil {
variables["status"] = Status
}
if Genres != nil {
variables["genres"] = Genres
}
if AverageScoreGreater != nil {
variables["averageScore_greater"] = *AverageScoreGreater * 10
}
if Year != nil {
variables["startDate_greater"] = lo.ToPtr(fmt.Sprintf("%d0000", *Year))
variables["startDate_lesser"] = lo.ToPtr(fmt.Sprintf("%d0000", *Year+1))
}
if Format != nil {
variables["format"] = *Format
}
if CountryOfOrigin != nil {
variables["countryOfOrigin"] = *CountryOfOrigin
}
if IsAdult != nil {
variables["isAdult"] = *IsAdult
}
requestBody, err := json.Marshal(map[string]interface{}{
"query": ListMangaDocument,
"variables": variables,
})
if err != nil {
return nil, err
}
data, err := customQuery(requestBody, logger, token)
if err != nil {
return nil, err
}
var listMediaF ListManga
m, err := json.Marshal(data)
if err != nil {
return nil, err
}
if err := json.Unmarshal(m, &listMediaF); err != nil {
return nil, err
}
return &listMediaF, nil
}
func ListRecentAiringAnimeM(
Page *int,
Search *string,
PerPage *int,
AiringAtGreater *int,
AiringAtLesser *int,
NotYetAired *bool,
Sort []*AiringSort,
logger *zerolog.Logger,
token string,
) (*ListRecentAnime, error) {
variables := map[string]interface{}{}
if Page != nil {
variables["page"] = *Page
}
if Search != nil {
variables["search"] = *Search
}
if PerPage != nil {
variables["perPage"] = *PerPage
}
if AiringAtGreater != nil {
variables["airingAt_greater"] = *AiringAtGreater
}
if AiringAtLesser != nil {
variables["airingAt_lesser"] = *AiringAtLesser
}
if NotYetAired != nil {
variables["notYetAired"] = *NotYetAired
}
if Sort != nil {
variables["sort"] = Sort
} else {
variables["sort"] = []*AiringSort{lo.ToPtr(AiringSortTimeDesc)}
}
requestBody, err := json.Marshal(map[string]interface{}{
"query": ListRecentAiringAnimeQuery,
"variables": variables,
})
if err != nil {
return nil, err
}
data, err := customQuery(requestBody, logger, token)
if err != nil {
return nil, err
}
var listMediaF ListRecentAnime
m, err := json.Marshal(data)
if err != nil {
return nil, err
}
if err := json.Unmarshal(m, &listMediaF); err != nil {
return nil, err
}
return &listMediaF, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func ListAnimeCacheKey(
Page *int,
Search *string,
PerPage *int,
Sort []*MediaSort,
Status []*MediaStatus,
Genres []*string,
AverageScoreGreater *int,
Season *MediaSeason,
SeasonYear *int,
Format *MediaFormat,
IsAdult *bool,
) string {
key := "ListAnime"
if Page != nil {
key += fmt.Sprintf("_%d", *Page)
}
if Search != nil {
key += fmt.Sprintf("_%s", *Search)
}
if PerPage != nil {
key += fmt.Sprintf("_%d", *PerPage)
}
if Sort != nil {
key += fmt.Sprintf("_%v", Sort)
}
if Status != nil {
key += fmt.Sprintf("_%v", Status)
}
if Genres != nil {
key += fmt.Sprintf("_%v", Genres)
}
if AverageScoreGreater != nil {
key += fmt.Sprintf("_%d", *AverageScoreGreater)
}
if Season != nil {
key += fmt.Sprintf("_%s", *Season)
}
if SeasonYear != nil {
key += fmt.Sprintf("_%d", *SeasonYear)
}
if Format != nil {
key += fmt.Sprintf("_%s", *Format)
}
if IsAdult != nil {
key += fmt.Sprintf("_%t", *IsAdult)
}
return key
}
func ListMangaCacheKey(
Page *int,
Search *string,
PerPage *int,
Sort []*MediaSort,
Status []*MediaStatus,
Genres []*string,
AverageScoreGreater *int,
Season *MediaSeason,
SeasonYear *int,
Format *MediaFormat,
CountryOfOrigin *string,
IsAdult *bool,
) string {
key := "ListAnime"
if Page != nil {
key += fmt.Sprintf("_%d", *Page)
}
if Search != nil {
key += fmt.Sprintf("_%s", *Search)
}
if PerPage != nil {
key += fmt.Sprintf("_%d", *PerPage)
}
if Sort != nil {
key += fmt.Sprintf("_%v", Sort)
}
if Status != nil {
key += fmt.Sprintf("_%v", Status)
}
if Genres != nil {
key += fmt.Sprintf("_%v", Genres)
}
if AverageScoreGreater != nil {
key += fmt.Sprintf("_%d", *AverageScoreGreater)
}
if Season != nil {
key += fmt.Sprintf("_%s", *Season)
}
if SeasonYear != nil {
key += fmt.Sprintf("_%d", *SeasonYear)
}
if Format != nil {
key += fmt.Sprintf("_%s", *Format)
}
if CountryOfOrigin != nil {
key += fmt.Sprintf("_%s", *CountryOfOrigin)
}
if IsAdult != nil {
key += fmt.Sprintf("_%t", *IsAdult)
}
return key
}
const ListRecentAiringAnimeQuery = `query ListRecentAnime ($page: Int, $perPage: Int, $airingAt_greater: Int, $airingAt_lesser: Int, $sort: [AiringSort], $notYetAired: Boolean = false) {
Page(page: $page, perPage: $perPage) {
pageInfo {
hasNextPage
total
perPage
currentPage
lastPage
}
airingSchedules(notYetAired: $notYetAired, sort: $sort, airingAt_greater: $airingAt_greater, airingAt_lesser: $airingAt_lesser) {
id
airingAt
episode
timeUntilAiring
media {
... baseAnime
}
}
}
}
fragment baseAnime on Media {
id
idMal
siteUrl
status(version: 2)
season
type
format
bannerImage
episodes
synonyms
isAdult
countryOfOrigin
meanScore
description
genres
duration
trailer {
id
site
thumbnail
}
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
}
`

View File

@@ -0,0 +1,123 @@
package anilist
type MangaList = MangaCollection_MediaListCollection_Lists
type MangaListEntry = MangaCollection_MediaListCollection_Lists_Entries
func (ac *MangaCollection) GetListEntryFromMangaId(id int) (*MangaListEntry, bool) {
if ac == nil || ac.MediaListCollection == nil {
return nil, false
}
var entry *MangaCollection_MediaListCollection_Lists_Entries
for _, l := range ac.MediaListCollection.Lists {
if l.Entries == nil || len(l.Entries) == 0 {
continue
}
for _, e := range l.Entries {
if e.Media.ID == id {
entry = e
break
}
}
}
if entry == nil {
return nil, false
}
return entry, true
}
func (m *BaseManga) GetTitleSafe() string {
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
return "N/A"
}
func (m *BaseManga) GetRomajiTitleSafe() string {
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
return "N/A"
}
func (m *BaseManga) GetPreferredTitle() string {
if m.GetTitle().GetUserPreferred() != nil {
return *m.GetTitle().GetUserPreferred()
}
return m.GetTitleSafe()
}
func (m *BaseManga) GetCoverImageSafe() string {
if m.GetCoverImage().GetExtraLarge() != nil {
return *m.GetCoverImage().GetExtraLarge()
}
if m.GetCoverImage().GetLarge() != nil {
return *m.GetCoverImage().GetLarge()
}
if m.GetBannerImage() != nil {
return *m.GetBannerImage()
}
return ""
}
func (m *BaseManga) GetBannerImageSafe() string {
if m.GetBannerImage() != nil {
return *m.GetBannerImage()
}
return m.GetCoverImageSafe()
}
func (m *BaseManga) GetAllTitles() []*string {
titles := make([]*string, 0)
if m.HasRomajiTitle() {
titles = append(titles, m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, m.Title.English)
}
if m.HasSynonyms() && len(m.Synonyms) > 1 {
titles = append(titles, m.Synonyms...)
}
return titles
}
func (m *BaseManga) GetMainTitlesDeref() []string {
titles := make([]string, 0)
if m.HasRomajiTitle() {
titles = append(titles, *m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, *m.Title.English)
}
return titles
}
func (m *BaseManga) HasEnglishTitle() bool {
return m.Title.English != nil
}
func (m *BaseManga) HasRomajiTitle() bool {
return m.Title.Romaji != nil
}
func (m *BaseManga) HasSynonyms() bool {
return m.Synonyms != nil
}
func (m *BaseManga) GetStartYearSafe() int {
if m.GetStartDate() != nil && m.GetStartDate().GetYear() != nil {
return *m.GetStartDate().GetYear()
}
return 0
}
func (m *MangaListEntry) GetRepeatSafe() int {
if m.Repeat == nil {
return 0
}
return *m.Repeat
}

View File

@@ -0,0 +1,25 @@
package anilist
import (
"seanime/internal/util/result"
)
type BaseAnimeCache struct {
*result.Cache[int, *BaseAnime]
}
// NewBaseAnimeCache returns a new result.Cache[int, *BaseAnime].
// It is used to temporarily store the results of FetchMediaTree calls.
func NewBaseAnimeCache() *BaseAnimeCache {
return &BaseAnimeCache{result.NewCache[int, *BaseAnime]()}
}
type CompleteAnimeCache struct {
*result.Cache[int, *CompleteAnime]
}
// NewCompleteAnimeCache returns a new result.Cache[int, *CompleteAnime].
// It is used to temporarily store the results of FetchMediaTree calls.
func NewCompleteAnimeCache() *CompleteAnimeCache {
return &CompleteAnimeCache{result.NewCache[int, *CompleteAnime]()}
}

View File

@@ -0,0 +1,574 @@
package anilist
import (
"seanime/internal/util/comparison"
"github.com/samber/lo"
)
func (m *BaseAnime) GetTitleSafe() string {
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
return ""
}
func (m *BaseAnime) GetEnglishTitleSafe() string {
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
return ""
}
func (m *BaseAnime) GetRomajiTitleSafe() string {
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
return ""
}
func (m *BaseAnime) GetPreferredTitle() string {
if m.GetTitle().GetUserPreferred() != nil {
return *m.GetTitle().GetUserPreferred()
}
return m.GetTitleSafe()
}
func (m *BaseAnime) GetCoverImageSafe() string {
if m.GetCoverImage().GetExtraLarge() != nil {
return *m.GetCoverImage().GetExtraLarge()
}
if m.GetCoverImage().GetLarge() != nil {
return *m.GetCoverImage().GetLarge()
}
if m.GetBannerImage() != nil {
return *m.GetBannerImage()
}
return ""
}
func (m *BaseAnime) GetBannerImageSafe() string {
if m.GetBannerImage() != nil {
return *m.GetBannerImage()
}
return m.GetCoverImageSafe()
}
func (m *BaseAnime) IsMovieOrSingleEpisode() bool {
if m == nil {
return false
}
if m.GetTotalEpisodeCount() == 1 {
return true
}
return false
}
func (m *BaseAnime) GetSynonymsDeref() []string {
if m.Synonyms == nil {
return nil
}
return lo.Map(m.Synonyms, func(s *string, i int) string { return *s })
}
func (m *BaseAnime) GetSynonymsContainingSeason() []string {
if m.Synonyms == nil {
return nil
}
return lo.Filter(lo.Map(m.Synonyms, func(s *string, i int) string { return *s }), func(s string, i int) bool { return comparison.ValueContainsSeason(s) })
}
func (m *BaseAnime) GetStartYearSafe() int {
if m == nil || m.StartDate == nil || m.StartDate.Year == nil {
return 0
}
return *m.StartDate.Year
}
func (m *BaseAnime) IsMovie() bool {
if m == nil {
return false
}
if m.Format == nil {
return false
}
return *m.Format == MediaFormatMovie
}
func (m *BaseAnime) IsFinished() bool {
if m == nil {
return false
}
if m.Status == nil {
return false
}
return *m.Status == MediaStatusFinished
}
func (m *BaseAnime) GetAllTitles() []*string {
titles := make([]*string, 0)
if m.HasRomajiTitle() {
titles = append(titles, m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, m.Title.English)
}
if m.HasSynonyms() && len(m.Synonyms) > 1 {
titles = append(titles, lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })...)
}
return titles
}
func (m *BaseAnime) GetAllTitlesDeref() []string {
titles := make([]string, 0)
if m.HasRomajiTitle() {
titles = append(titles, *m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, *m.Title.English)
}
if m.HasSynonyms() && len(m.Synonyms) > 1 {
syn := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })
for _, s := range syn {
titles = append(titles, *s)
}
}
return titles
}
func (m *BaseAnime) GetMainTitles() []*string {
titles := make([]*string, 0)
if m.HasRomajiTitle() {
titles = append(titles, m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, m.Title.English)
}
return titles
}
func (m *BaseAnime) GetMainTitlesDeref() []string {
titles := make([]string, 0)
if m.HasRomajiTitle() {
titles = append(titles, *m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, *m.Title.English)
}
return titles
}
// GetCurrentEpisodeCount returns the current episode number for that media and -1 if it doesn't have one.
// i.e. -1 is returned if the media has no episodes AND the next airing episode is not set.
func (m *BaseAnime) GetCurrentEpisodeCount() int {
ceil := -1
if m.Episodes != nil {
ceil = *m.Episodes
}
if m.NextAiringEpisode != nil {
if m.NextAiringEpisode.Episode > 0 {
ceil = m.NextAiringEpisode.Episode - 1
}
}
return ceil
}
func (m *BaseAnime) GetCurrentEpisodeCountOrNil() *int {
n := m.GetCurrentEpisodeCount()
if n == -1 {
return nil
}
return &n
}
// GetTotalEpisodeCount returns the total episode number for that media and -1 if it doesn't have one
func (m *BaseAnime) GetTotalEpisodeCount() int {
ceil := -1
if m.Episodes != nil {
ceil = *m.Episodes
}
return ceil
}
// GetTotalEpisodeCount returns the total episode number for that media and -1 if it doesn't have one
func (m *BaseAnime) GetTotalEpisodeCountOrNil() *int {
return m.Episodes
}
// GetPossibleSeasonNumber returns the possible season number for that media and -1 if it doesn't have one.
// It looks at the synonyms and returns the highest season number found.
func (m *BaseAnime) GetPossibleSeasonNumber() int {
if m == nil || m.Synonyms == nil || len(m.Synonyms) == 0 {
return -1
}
titles := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })
if m.HasEnglishTitle() {
titles = append(titles, m.Title.English)
}
if m.HasRomajiTitle() {
titles = append(titles, m.Title.Romaji)
}
seasons := lo.Map(titles, func(s *string, i int) int { return comparison.ExtractSeasonNumber(*s) })
return lo.Max(seasons)
}
func (m *BaseAnime) HasEnglishTitle() bool {
return m.Title.English != nil
}
func (m *BaseAnime) HasRomajiTitle() bool {
return m.Title.Romaji != nil
}
func (m *BaseAnime) HasSynonyms() bool {
return m.Synonyms != nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (m *CompleteAnime) GetTitleSafe() string {
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
return "N/A"
}
func (m *CompleteAnime) GetRomajiTitleSafe() string {
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
return "N/A"
}
func (m *CompleteAnime) GetPreferredTitle() string {
if m.GetTitle().GetUserPreferred() != nil {
return *m.GetTitle().GetUserPreferred()
}
return m.GetTitleSafe()
}
func (m *CompleteAnime) GetCoverImageSafe() string {
if m.GetCoverImage().GetExtraLarge() != nil {
return *m.GetCoverImage().GetExtraLarge()
}
if m.GetCoverImage().GetLarge() != nil {
return *m.GetCoverImage().GetLarge()
}
if m.GetBannerImage() != nil {
return *m.GetBannerImage()
}
return ""
}
func (m *CompleteAnime) GetBannerImageSafe() string {
if m.GetBannerImage() != nil {
return *m.GetBannerImage()
}
return m.GetCoverImageSafe()
}
func (m *CompleteAnime) IsMovieOrSingleEpisode() bool {
if m == nil {
return false
}
if m.GetTotalEpisodeCount() == 1 {
return true
}
return false
}
func (m *CompleteAnime) IsMovie() bool {
if m == nil {
return false
}
if m.Format == nil {
return false
}
return *m.Format == MediaFormatMovie
}
func (m *CompleteAnime) IsFinished() bool {
if m == nil {
return false
}
if m.Status == nil {
return false
}
return *m.Status == MediaStatusFinished
}
func (m *CompleteAnime) GetAllTitles() []*string {
titles := make([]*string, 0)
if m.HasRomajiTitle() {
titles = append(titles, m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, m.Title.English)
}
if m.HasSynonyms() && len(m.Synonyms) > 1 {
titles = append(titles, lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })...)
}
return titles
}
func (m *CompleteAnime) GetAllTitlesDeref() []string {
titles := make([]string, 0)
if m.HasRomajiTitle() {
titles = append(titles, *m.Title.Romaji)
}
if m.HasEnglishTitle() {
titles = append(titles, *m.Title.English)
}
if m.HasSynonyms() && len(m.Synonyms) > 1 {
syn := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })
for _, s := range syn {
titles = append(titles, *s)
}
}
return titles
}
// GetCurrentEpisodeCount returns the current episode number for that media and -1 if it doesn't have one.
// i.e. -1 is returned if the media has no episodes AND the next airing episode is not set.
func (m *CompleteAnime) GetCurrentEpisodeCount() int {
ceil := -1
if m.Episodes != nil {
ceil = *m.Episodes
}
if m.NextAiringEpisode != nil {
if m.NextAiringEpisode.Episode > 0 {
ceil = m.NextAiringEpisode.Episode - 1
}
}
return ceil
}
// GetTotalEpisodeCount returns the total episode number for that media and -1 if it doesn't have one
func (m *CompleteAnime) GetTotalEpisodeCount() int {
ceil := -1
if m.Episodes != nil {
ceil = *m.Episodes
}
return ceil
}
// GetPossibleSeasonNumber returns the possible season number for that media and -1 if it doesn't have one.
// It looks at the synonyms and returns the highest season number found.
func (m *CompleteAnime) GetPossibleSeasonNumber() int {
if m == nil || m.Synonyms == nil || len(m.Synonyms) == 0 {
return -1
}
titles := lo.Filter(m.Synonyms, func(s *string, i int) bool { return comparison.ValueContainsSeason(*s) })
if m.HasEnglishTitle() {
titles = append(titles, m.Title.English)
}
if m.HasRomajiTitle() {
titles = append(titles, m.Title.Romaji)
}
seasons := lo.Map(titles, func(s *string, i int) int { return comparison.ExtractSeasonNumber(*s) })
return lo.Max(seasons)
}
func (m *CompleteAnime) HasEnglishTitle() bool {
return m.Title.English != nil
}
func (m *CompleteAnime) HasRomajiTitle() bool {
return m.Title.Romaji != nil
}
func (m *CompleteAnime) HasSynonyms() bool {
return m.Synonyms != nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
var EdgeNarrowFormats = []MediaFormat{MediaFormatTv, MediaFormatTvShort}
var EdgeBroaderFormats = []MediaFormat{MediaFormatTv, MediaFormatTvShort, MediaFormatOna, MediaFormatOva, MediaFormatMovie, MediaFormatSpecial}
func (m *CompleteAnime) FindEdge(relation string, formats []MediaFormat) (*BaseAnime, bool) {
if m.GetRelations() == nil {
return nil, false
}
edges := m.GetRelations().GetEdges()
for _, edge := range edges {
if edge.GetRelationType().String() == relation {
for _, fm := range formats {
if fm.String() == edge.GetNode().GetFormat().String() {
return edge.GetNode(), true
}
}
}
}
return nil, false
}
func (e *CompleteAnime_Relations_Edges) IsBroadRelationFormat() bool {
if e.GetNode() == nil {
return false
}
if e.GetNode().GetFormat() == nil {
return false
}
for _, fm := range EdgeBroaderFormats {
if fm.String() == e.GetNode().GetFormat().String() {
return true
}
}
return false
}
func (e *CompleteAnime_Relations_Edges) IsNarrowRelationFormat() bool {
if e.GetNode() == nil {
return false
}
if e.GetNode().GetFormat() == nil {
return false
}
for _, fm := range EdgeNarrowFormats {
if fm.String() == e.GetNode().GetFormat().String() {
return true
}
}
return false
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (m *CompleteAnime) ToBaseAnime() *BaseAnime {
if m == nil {
return nil
}
var trailer *BaseAnime_Trailer
if m.GetTrailer() != nil {
trailer = &BaseAnime_Trailer{
ID: m.GetTrailer().GetID(),
Site: m.GetTrailer().GetSite(),
Thumbnail: m.GetTrailer().GetThumbnail(),
}
}
var nextAiringEpisode *BaseAnime_NextAiringEpisode
if m.GetNextAiringEpisode() != nil {
nextAiringEpisode = &BaseAnime_NextAiringEpisode{
AiringAt: m.GetNextAiringEpisode().GetAiringAt(),
TimeUntilAiring: m.GetNextAiringEpisode().GetTimeUntilAiring(),
Episode: m.GetNextAiringEpisode().GetEpisode(),
}
}
var startDate *BaseAnime_StartDate
if m.GetStartDate() != nil {
startDate = &BaseAnime_StartDate{
Year: m.GetStartDate().GetYear(),
Month: m.GetStartDate().GetMonth(),
Day: m.GetStartDate().GetDay(),
}
}
var endDate *BaseAnime_EndDate
if m.GetEndDate() != nil {
endDate = &BaseAnime_EndDate{
Year: m.GetEndDate().GetYear(),
Month: m.GetEndDate().GetMonth(),
Day: m.GetEndDate().GetDay(),
}
}
return &BaseAnime{
ID: m.GetID(),
IDMal: m.GetIDMal(),
SiteURL: m.GetSiteURL(),
Format: m.GetFormat(),
Episodes: m.GetEpisodes(),
Status: m.GetStatus(),
Synonyms: m.GetSynonyms(),
BannerImage: m.GetBannerImage(),
Season: m.GetSeason(),
SeasonYear: m.GetSeasonYear(),
Type: m.GetType(),
IsAdult: m.GetIsAdult(),
CountryOfOrigin: m.GetCountryOfOrigin(),
Genres: m.GetGenres(),
Duration: m.GetDuration(),
Description: m.GetDescription(),
MeanScore: m.GetMeanScore(),
Trailer: trailer,
Title: &BaseAnime_Title{
UserPreferred: m.GetTitle().GetUserPreferred(),
Romaji: m.GetTitle().GetRomaji(),
English: m.GetTitle().GetEnglish(),
Native: m.GetTitle().GetNative(),
},
CoverImage: &BaseAnime_CoverImage{
ExtraLarge: m.GetCoverImage().GetExtraLarge(),
Large: m.GetCoverImage().GetLarge(),
Medium: m.GetCoverImage().GetMedium(),
Color: m.GetCoverImage().GetColor(),
},
StartDate: startDate,
EndDate: endDate,
NextAiringEpisode: nextAiringEpisode,
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (m *AnimeListEntry) GetProgressSafe() int {
if m == nil {
return 0
}
if m.Progress == nil {
return 0
}
return *m.Progress
}
func (m *AnimeListEntry) GetScoreSafe() float64 {
if m == nil {
return 0
}
if m.Score == nil {
return 0
}
return *m.Score
}
func (m *AnimeListEntry) GetRepeatSafe() int {
if m == nil {
return 0
}
if m.Repeat == nil {
return 0
}
return *m.Repeat
}
func (m *AnimeListEntry) GetStatusSafe() MediaListStatus {
if m == nil {
return ""
}
if m.Status == nil {
return ""
}
return *m.Status
}

View File

@@ -0,0 +1,155 @@
package anilist
import (
"context"
"github.com/samber/lo"
"seanime/internal/util"
"seanime/internal/util/limiter"
"seanime/internal/util/result"
"sync"
)
type (
CompleteAnimeRelationTree struct {
*result.Map[int, *CompleteAnime]
}
FetchMediaTreeRelation = string
)
const (
FetchMediaTreeSequels FetchMediaTreeRelation = "sequels"
FetchMediaTreePrequels FetchMediaTreeRelation = "prequels"
FetchMediaTreeAll FetchMediaTreeRelation = "all"
)
// NewCompleteAnimeRelationTree returns a new result.Map[int, *CompleteAnime].
// It is used to store the results of FetchMediaTree or FetchMediaTree calls.
func NewCompleteAnimeRelationTree() *CompleteAnimeRelationTree {
return &CompleteAnimeRelationTree{result.NewResultMap[int, *CompleteAnime]()}
}
func (m *BaseAnime) FetchMediaTree(rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache) (err error) {
if m == nil {
return nil
}
defer util.HandlePanicInModuleWithError("anilist/BaseAnime.FetchMediaTree", &err)
rl.Wait()
res, err := anilistClient.CompleteAnimeByID(context.Background(), &m.ID)
if err != nil {
return err
}
return res.GetMedia().FetchMediaTree(rel, anilistClient, rl, tree, cache)
}
// FetchMediaTree populates the CompleteAnimeRelationTree with the given media's sequels and prequels.
// It also takes a CompleteAnimeCache to store the fetched media in and avoid duplicate fetches.
// It also takes a limiter.Limiter to limit the number of requests made to the AniList API.
func (m *CompleteAnime) FetchMediaTree(rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache) (err error) {
if m == nil {
return nil
}
defer util.HandlePanicInModuleWithError("anilist/CompleteAnime.FetchMediaTree", &err)
if tree.Has(m.ID) {
cache.Set(m.ID, m)
return nil
}
cache.Set(m.ID, m)
tree.Set(m.ID, m)
if m.Relations == nil {
return nil
}
// Get all edges
edges := m.GetRelations().GetEdges()
// Filter edges
edges = lo.Filter(edges, func(_edge *CompleteAnime_Relations_Edges, _ int) bool {
return (*_edge.RelationType == MediaRelationSequel || *_edge.RelationType == MediaRelationPrequel) &&
*_edge.GetNode().Status != MediaStatusNotYetReleased &&
_edge.IsBroadRelationFormat() && !tree.Has(_edge.GetNode().ID)
})
if len(edges) == 0 {
return nil
}
doneCh := make(chan struct{})
processEdges(edges, rel, anilistClient, rl, tree, cache, doneCh)
for {
select {
case <-doneCh:
return nil
default:
}
}
}
// processEdges fetches the next node(s) for each edge in parallel.
func processEdges(edges []*CompleteAnime_Relations_Edges, rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache, doneCh chan struct{}) {
var wg sync.WaitGroup
wg.Add(len(edges))
for i, item := range edges {
go func(edge *CompleteAnime_Relations_Edges, _ int) {
defer wg.Done()
if edge == nil {
return
}
processEdge(edge, rel, anilistClient, rl, tree, cache)
}(item, i)
}
wg.Wait()
go func() {
close(doneCh)
}()
}
func processEdge(edge *CompleteAnime_Relations_Edges, rel FetchMediaTreeRelation, anilistClient AnilistClient, rl *limiter.Limiter, tree *CompleteAnimeRelationTree, cache *CompleteAnimeCache) {
defer util.HandlePanicInModuleThen("anilist/processEdge", func() {})
cacheV, ok := cache.Get(edge.GetNode().ID)
edgeCompleteAnime := cacheV
if !ok {
rl.Wait()
// Fetch the next node
res, err := anilistClient.CompleteAnimeByID(context.Background(), &edge.GetNode().ID)
if err == nil {
edgeCompleteAnime = res.GetMedia()
cache.Set(edgeCompleteAnime.ID, edgeCompleteAnime)
}
}
if edgeCompleteAnime == nil {
return
}
// Get the relation type to fetch for the next node
edgeRel := getEdgeRelation(edge, rel)
// Fetch the next node(s)
err := edgeCompleteAnime.FetchMediaTree(edgeRel, anilistClient, rl, tree, cache)
if err != nil {
return
}
}
// getEdgeRelation returns the relation to fetch for the next node based on the current edge and the relation to fetch.
// If the relation to fetch is FetchMediaTreeAll, it will return FetchMediaTreePrequels for prequels and FetchMediaTreeSequels for sequels.
//
// For example, if the current node is a sequel and the relation to fetch is FetchMediaTreeAll, it will return FetchMediaTreeSequels so that
// only sequels are fetched for the next node.
func getEdgeRelation(edge *CompleteAnime_Relations_Edges, rel FetchMediaTreeRelation) FetchMediaTreeRelation {
if rel == FetchMediaTreeAll {
if *edge.RelationType == MediaRelationPrequel {
return FetchMediaTreePrequels
}
if *edge.RelationType == MediaRelationSequel {
return FetchMediaTreeSequels
}
}
return rel
}

View File

@@ -0,0 +1,82 @@
package anilist
import (
"context"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
"seanime/internal/test_utils"
"seanime/internal/util/limiter"
"testing"
)
func TestBaseAnime_FetchMediaTree_BaseAnime(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.Anilist())
anilistClient := TestGetMockAnilistClient()
lim := limiter.NewAnilistLimiter()
completeAnimeCache := NewCompleteAnimeCache()
tests := []struct {
name string
mediaId int
edgeIds []int
}{
{
name: "Bungo Stray Dogs",
mediaId: 103223,
edgeIds: []int{
21311, // BSD1
21679, // BSD2
103223, // BSD3
141249, // BSD4
163263, // BSD5
},
},
{
name: "Re:Zero",
mediaId: 21355,
edgeIds: []int{
21355, // Re:Zero 1
108632, // Re:Zero 2
119661, // Re:Zero 2 Part 2
163134, // Re:Zero 3
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mediaF, err := anilistClient.CompleteAnimeByID(context.Background(), &tt.mediaId)
if assert.NoError(t, err) {
media := mediaF.GetMedia()
tree := NewCompleteAnimeRelationTree()
err = media.FetchMediaTree(
FetchMediaTreeAll,
anilistClient,
lim,
tree,
completeAnimeCache,
)
if assert.NoError(t, err) {
for _, treeId := range tt.edgeIds {
a, found := tree.Get(treeId)
assert.Truef(t, found, "expected tree to contain %d", treeId)
spew.Dump(a.GetTitleSafe())
}
}
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
query AnimeCollection ($userName: String) {
MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: ANIME) {
lists {
status
name
isCustomList
entries {
id
score(format: POINT_100)
progress
status
notes
repeat
private
startedAt {
year
month
day
}
completedAt {
year
month
day
}
media {
...baseAnime
}
}
}
}
}
query AnimeCollectionWithRelations ($userName: String) {
MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: ANIME) {
lists {
status
name
isCustomList
entries {
id
score(format: POINT_100)
progress
status
notes
repeat
private
startedAt {
year
month
day
}
completedAt {
year
month
day
}
media {
...completeAnime
}
}
}
}
}
query BaseAnimeByMalId ($id: Int) {
Media(idMal: $id, type: ANIME) {
...baseAnime
}
}
query BaseAnimeById ($id: Int) {
Media(id: $id, type: ANIME) {
...baseAnime
}
}
query SearchBaseAnimeByIds ($ids: [Int], $page: Int, $perPage: Int, $status: [MediaStatus], $inCollection: Boolean, $sort: [MediaSort], $season: MediaSeason, $year: Int, $genre: String, $format: MediaFormat) {
Page(page: $page, perPage: $perPage) {
pageInfo {
hasNextPage
},
media(id_in: $ids, type: ANIME, status_in: $status, onList: $inCollection, sort: $sort, season: $season, seasonYear: $year, genre: $genre, format: $format) {
...baseAnime
}
}
}
query CompleteAnimeById ($id: Int) {
Media(id: $id, type: ANIME) {
...completeAnime
}
}
# For view (will be cached)
query AnimeDetailsById ($id: Int) {
Media(id: $id, type: ANIME) {
siteUrl
id
duration
genres
averageScore
popularity
meanScore
description
trailer {
id
site
thumbnail
}
startDate {
year
month
day
}
endDate {
year
month
day
}
studios(isMain: true) {
nodes {
name
id
}
}
characters(sort: [ROLE]) {
edges {
id
role
name
node {
...baseCharacter
}
}
}
staff(sort: [RELEVANCE]) {
edges {
role
node {
name {
full
}
id
}
}
}
rankings {
context
type
rank
year
format
allTime
season
}
recommendations(page: 1, perPage: 8, sort: RATING_DESC) {
edges {
node {
mediaRecommendation {
id
idMal
siteUrl
status(version: 2)
isAdult
season
type
format
meanScore
description
episodes
trailer {
id
site
thumbnail
}
startDate {
year
month
day
}
coverImage {
extraLarge
large
medium
color
}
bannerImage
title {
romaji
english
native
userPreferred
}
}
}
}
}
relations {
edges {
relationType(version: 2)
node {
...baseAnime
}
}
}
}
}
query ListAnime(
$page: Int
$search: String
$perPage: Int
$sort: [MediaSort]
$status: [MediaStatus]
$genres: [String]
$averageScore_greater: Int
$season: MediaSeason
$seasonYear: Int
$format: MediaFormat
$isAdult: Boolean
) {
Page(page: $page, perPage: $perPage) {
pageInfo {
hasNextPage
total
perPage
currentPage
lastPage
}
media(
type: ANIME
search: $search
sort: $sort
status_in: $status
isAdult: $isAdult
format: $format
genre_in: $genres
averageScore_greater: $averageScore_greater
season: $season
seasonYear: $seasonYear
format_not: MUSIC
) {
...baseAnime
}
}
}
query ListRecentAnime ($page: Int, $perPage: Int, $airingAt_greater: Int, $airingAt_lesser: Int, $notYetAired: Boolean = false) {
Page(page: $page, perPage: $perPage) {
pageInfo {
hasNextPage
total
perPage
currentPage
lastPage
}
airingSchedules(notYetAired: $notYetAired, sort: TIME_DESC, airingAt_greater: $airingAt_greater, airingAt_lesser: $airingAt_lesser) {
id
airingAt
episode
timeUntilAiring
media {
... baseAnime
}
}
}
}
fragment baseAnime on Media {
id
idMal
siteUrl
status(version: 2)
season
type
format
seasonYear
bannerImage
episodes
synonyms
isAdult
countryOfOrigin
meanScore
description
genres
duration
trailer {
id
site
thumbnail
}
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
}
fragment completeAnime on Media {
id
idMal
siteUrl
status(version: 2)
season
seasonYear
type
format
bannerImage
episodes
synonyms
isAdult
countryOfOrigin
meanScore
description
genres
duration
trailer {
id
site
thumbnail
}
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
relations {
edges {
relationType(version: 2)
node {
...baseAnime
}
}
}
}
fragment baseCharacter on Character {
id
isFavourite
gender
age
dateOfBirth {
year
month
day
}
name {
full
native
alternative
}
image {
large
}
description
siteUrl
}
query AnimeAiringSchedule($ids: [Int],$season: MediaSeason, $seasonYear: Int, $previousSeason: MediaSeason, $previousSeasonYear: Int, $nextSeason: MediaSeason, $nextSeasonYear: Int) {
ongoing: Page {
media(id_in: $ids, type: ANIME, season: $season, seasonYear: $seasonYear, onList: true) {
...animeSchedule
}
}
ongoingNext: Page(page: 2) {
media(id_in: $ids, type: ANIME, season: $season, seasonYear: $seasonYear, onList: true) {
...animeSchedule
}
}
upcoming: Page {
media(id_in: $ids, type: ANIME, season: $nextSeason, seasonYear: $nextSeasonYear, sort: [START_DATE], onList: true) {
...animeSchedule
}
}
upcomingNext: Page(page: 2) {
media(id_in: $ids, type: ANIME, season: $nextSeason, seasonYear: $nextSeasonYear, sort: [START_DATE], onList: true) {
...animeSchedule
}
}
preceding: Page {
media(id_in: $ids, type: ANIME, season: $previousSeason, seasonYear: $previousSeasonYear, onList: true) {
...animeSchedule
}
}
}
query AnimeAiringScheduleRaw($ids: [Int]) {
Page {
media(id_in: $ids, type: ANIME, onList: true) {
...animeSchedule
}
}
}
fragment animeSchedule on Media {
id,
idMal
previous: airingSchedule(notYetAired: false, perPage: 30) {
nodes {
airingAt
timeUntilAiring
episode
}
},
upcoming: airingSchedule(notYetAired: true, perPage: 30) {
nodes {
airingAt
timeUntilAiring
episode
}
}
}

View File

@@ -0,0 +1,56 @@
mutation UpdateMediaListEntry (
$mediaId: Int
$status: MediaListStatus
$scoreRaw: Int
$progress: Int
$startedAt: FuzzyDateInput
$completedAt: FuzzyDateInput
) {
SaveMediaListEntry(
mediaId: $mediaId
status: $status
scoreRaw: $scoreRaw
progress: $progress
startedAt: $startedAt
completedAt: $completedAt
) {
id
}
}
mutation UpdateMediaListEntryProgress (
$mediaId: Int
$progress: Int
$status: MediaListStatus
) {
SaveMediaListEntry(
mediaId: $mediaId
progress: $progress
status: $status
) {
id
}
}
mutation DeleteEntry (
$mediaListEntryId: Int
) {
DeleteMediaListEntry(
id: $mediaListEntryId
) {
deleted
}
}
mutation UpdateMediaListEntryRepeat (
$mediaId: Int
$repeat: Int
) {
SaveMediaListEntry(
mediaId: $mediaId
repeat: $repeat
) {
id
}
}

View File

@@ -0,0 +1,200 @@
query MangaCollection ($userName: String) {
MediaListCollection(userName: $userName, forceSingleCompletedList: true, type: MANGA) {
lists {
status
name
isCustomList
entries {
id
score(format: POINT_100)
progress
status
notes
repeat
private
startedAt {
year
month
day
}
completedAt {
year
month
day
}
media {
...baseManga
}
}
}
}
}
query SearchBaseManga($page: Int, $perPage: Int, $sort: [MediaSort], $search: String, $status: [MediaStatus]){
Page(page: $page, perPage: $perPage){
pageInfo{
hasNextPage
},
media(type: MANGA, search: $search, sort: $sort, status_in: $status, format_not: NOVEL){
...baseManga
}
}
}
query BaseMangaById ($id: Int) {
Media(id: $id, type: MANGA) {
...baseManga
}
}
# For view (will be cached)
query MangaDetailsById ($id: Int) {
Media(id: $id, type: MANGA) {
siteUrl
id
duration
genres
rankings {
context
type
rank
year
format
allTime
season
}
characters(sort: [ROLE]) {
edges {
id
role
name
node {
...baseCharacter
}
}
}
recommendations(page: 1, perPage: 8, sort: RATING_DESC) {
edges {
node {
mediaRecommendation {
id
idMal
siteUrl
status(version: 2)
season
type
format
bannerImage
chapters
volumes
synonyms
isAdult
countryOfOrigin
meanScore
description
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
}
}
}
}
relations {
edges {
relationType(version: 2)
node {
...baseManga
}
}
}
}
}
query ListManga(
$page: Int
$search: String
$perPage: Int
$sort: [MediaSort]
$status: [MediaStatus]
$genres: [String]
$averageScore_greater: Int
$startDate_greater: FuzzyDateInt
$startDate_lesser: FuzzyDateInt
$format: MediaFormat
$countryOfOrigin: CountryCode
$isAdult: Boolean
) {
Page(page: $page, perPage: $perPage){
pageInfo{
hasNextPage
total
perPage
currentPage
lastPage
},
media(type: MANGA, isAdult: $isAdult, countryOfOrigin: $countryOfOrigin, search: $search, sort: $sort, status_in: $status, format: $format, genre_in: $genres, averageScore_greater: $averageScore_greater, startDate_greater: $startDate_greater, startDate_lesser: $startDate_lesser, format_not: NOVEL){
...baseManga
}
}
}
fragment baseManga on Media {
id
idMal
siteUrl
status(version: 2)
season
type
format
bannerImage
chapters
volumes
synonyms
isAdult
countryOfOrigin
meanScore
description
genres
title {
userPreferred
romaji
english
native
}
coverImage {
extraLarge
large
medium
color
}
startDate {
year
month
day
}
endDate {
year
month
day
}
}

View File

@@ -0,0 +1,126 @@
query ViewerStats {
Viewer {
statistics {
anime {
count
minutesWatched
episodesWatched
meanScore
formats {
...UserFormatStats
}
genres {
...UserGenreStats
}
statuses {
...UserStatusStats
}
studios {
...UserStudioStats
}
scores {
...UserScoreStats
}
startYears {
...UserStartYearStats
}
releaseYears {
...UserReleaseYearStats
}
}
manga {
count
chaptersRead
meanScore
formats {
...UserFormatStats
}
genres {
...UserGenreStats
}
statuses {
...UserStatusStats
}
studios {
...UserStudioStats
}
scores {
...UserScoreStats
}
startYears {
...UserStartYearStats
}
releaseYears {
...UserReleaseYearStats
}
}
}
}
}
fragment UserFormatStats on UserFormatStatistic {
format
meanScore
count
minutesWatched
mediaIds
chaptersRead
}
fragment UserGenreStats on UserGenreStatistic {
genre
meanScore
count
minutesWatched
mediaIds
chaptersRead
}
fragment UserStatusStats on UserStatusStatistic {
status
meanScore
count
minutesWatched
mediaIds
chaptersRead
}
fragment UserScoreStats on UserScoreStatistic {
score
meanScore
count
minutesWatched
mediaIds
chaptersRead
}
fragment UserStudioStats on UserStudioStatistic {
studio {
id
name
isAnimationStudio
}
meanScore
count
minutesWatched
mediaIds
chaptersRead
}
fragment UserStartYearStats on UserStartYearStatistic {
startYear
meanScore
count
minutesWatched
mediaIds
chaptersRead
}
fragment UserReleaseYearStats on UserReleaseYearStatistic {
releaseYear
meanScore
count
minutesWatched
mediaIds
chaptersRead
}

View File

@@ -0,0 +1,12 @@
query StudioDetails($id: Int) {
Studio(id: $id) {
id
isAnimationStudio
name
media (perPage: 80, sort: TRENDING_DESC, isMain: true) {
nodes {
...baseAnime
}
}
}
}

View File

@@ -0,0 +1,16 @@
query GetViewer {
Viewer {
name
avatar {
large
medium
}
bannerImage
isBlocked
options {
displayAdultContent
airingNotifications
profileColor
}
}
}

View File

@@ -0,0 +1,72 @@
package anilist
import (
"context"
"seanime/internal/util"
)
type (
Stats struct {
AnimeStats *AnimeStats `json:"animeStats"`
MangaStats *MangaStats `json:"mangaStats"`
}
AnimeStats struct {
Count int `json:"count"`
MinutesWatched int `json:"minutesWatched"`
EpisodesWatched int `json:"episodesWatched"`
MeanScore float64 `json:"meanScore"`
Genres []*UserGenreStats `json:"genres"`
Formats []*UserFormatStats `json:"formats"`
Statuses []*UserStatusStats `json:"statuses"`
Studios []*UserStudioStats `json:"studios"`
Scores []*UserScoreStats `json:"scores"`
StartYears []*UserStartYearStats `json:"startYears"`
ReleaseYears []*UserReleaseYearStats `json:"releaseYears"`
}
MangaStats struct {
Count int `json:"count"`
ChaptersRead int `json:"chaptersRead"`
MeanScore float64 `json:"meanScore"`
Genres []*UserGenreStats `json:"genres"`
Statuses []*UserStatusStats `json:"statuses"`
Scores []*UserScoreStats `json:"scores"`
StartYears []*UserStartYearStats `json:"startYears"`
ReleaseYears []*UserReleaseYearStats `json:"releaseYears"`
}
)
func GetStats(ctx context.Context, stats *ViewerStats) (ret *Stats, err error) {
defer util.HandlePanicInModuleWithError("api/anilist/GetStats", &err)
allStats := stats.GetViewer().GetStatistics()
ret = &Stats{
AnimeStats: &AnimeStats{
Count: allStats.GetAnime().GetCount(),
MinutesWatched: allStats.GetAnime().GetMinutesWatched(),
EpisodesWatched: allStats.GetAnime().GetEpisodesWatched(),
MeanScore: allStats.GetAnime().GetMeanScore(),
Genres: allStats.GetAnime().GetGenres(),
Formats: allStats.GetAnime().GetFormats(),
Statuses: allStats.GetAnime().GetStatuses(),
Studios: allStats.GetAnime().GetStudios(),
Scores: allStats.GetAnime().GetScores(),
StartYears: allStats.GetAnime().GetStartYears(),
ReleaseYears: allStats.GetAnime().GetReleaseYears(),
},
MangaStats: &MangaStats{
Count: allStats.GetManga().GetCount(),
ChaptersRead: allStats.GetManga().GetChaptersRead(),
MeanScore: allStats.GetManga().GetMeanScore(),
Genres: allStats.GetManga().GetGenres(),
Statuses: allStats.GetManga().GetStatuses(),
Scores: allStats.GetManga().GetScores(),
StartYears: allStats.GetManga().GetStartYears(),
ReleaseYears: allStats.GetManga().GetReleaseYears(),
},
}
return ret, nil
}

View File

@@ -0,0 +1,60 @@
package anilist
import (
"time"
)
type GetSeasonKind int
const (
GetSeasonKindCurrent GetSeasonKind = iota
GetSeasonKindNext
GetSeasonKindPrevious
)
func GetSeasonInfo(now time.Time, kind GetSeasonKind) (MediaSeason, int) {
month, year := now.Month(), now.Year()
getSeasonIndex := func(m time.Month) int {
switch {
case m >= 3 && m <= 5: // spring: 3, 4, 5
return 1
case m >= 6 && m <= 8: // summer: 6, 7, 8
return 2
case m >= 9 && m <= 11: // fall: 9, 10, 11
return 3
default: // winter: 12, 1, 2
return 0
}
}
seasons := []MediaSeason{MediaSeasonWinter, MediaSeasonSpring, MediaSeasonSummer, MediaSeasonFall}
var index int
switch kind {
case GetSeasonKindCurrent:
index = getSeasonIndex(month)
case GetSeasonKindNext:
nextMonth := month + 3
nextYear := year
if nextMonth > 12 {
nextMonth -= 12
nextYear++
}
index = getSeasonIndex(nextMonth)
year = nextYear
case GetSeasonKindPrevious:
prevMonth := month - 3
prevYear := year
if prevMonth <= 0 {
prevMonth += 12
prevYear--
}
index = getSeasonIndex(prevMonth)
year = prevYear
}
return seasons[index], year
}

View File

@@ -0,0 +1,34 @@
package anilist
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestGetSeason(t *testing.T) {
tests := []struct {
now time.Time
kind GetSeasonKind
expectedSeason MediaSeason
expectedYear int
}{
{time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonWinter, 2025},
{time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonSpring, 2025},
{time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonSummer, 2025},
{time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindCurrent, MediaSeasonFall, 2025},
{time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindNext, MediaSeasonWinter, 2026},
{time.Date(2025, 12, 31, 23, 59, 59, 999999999, time.UTC), GetSeasonKindCurrent, MediaSeasonWinter, 2025},
{time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), GetSeasonKindNext, MediaSeasonSpring, 2025},
}
for _, tt := range tests {
t.Run(tt.now.Format(time.RFC3339), func(t *testing.T) {
t.Logf("%s", tt.now.Format(time.RFC3339))
season, year := GetSeasonInfo(tt.now, tt.kind)
require.Equal(t, tt.expectedSeason, season, "Expected season %v, got %v", tt.expectedSeason, season)
require.Equal(t, tt.expectedYear, year, "Expected year %d, got %d", tt.expectedYear, year)
})
}
}

View File

@@ -0,0 +1,137 @@
package animap
import (
"errors"
"io"
"net/http"
"seanime/internal/constants"
"seanime/internal/hook"
"seanime/internal/util/result"
"strconv"
"github.com/goccy/go-json"
)
type (
Anime struct {
Title string `json:"title"`
Titles map[string]string `json:"titles,omitempty"`
StartDate string `json:"startDate,omitempty"` // YYYY-MM-DD
EndDate string `json:"endDate,omitempty"` // YYYY-MM-DD
Status string `json:"status"` // Finished, Airing, Upcoming, etc.
Type string `json:"type"` // TV, OVA, Movie, etc.
Episodes map[string]*Episode `json:"episodes,omitzero"` // Indexed by AniDB episode number, "1", "S1", etc.
Mappings *AnimeMapping `json:"mappings,omitzero"`
}
AnimeMapping struct {
AnidbID int `json:"anidb_id,omitempty"`
AnilistID int `json:"anilist_id,omitempty"`
KitsuID int `json:"kitsu_id,omitempty"`
TheTvdbID int `json:"thetvdb_id,omitempty"`
TheMovieDbID string `json:"themoviedb_id,omitempty"` // Can be int or string, forced to string
MalID int `json:"mal_id,omitempty"`
LivechartID int `json:"livechart_id,omitempty"`
AnimePlanetID string `json:"animeplanet_id,omitempty"` // Can be int or string, forced to string
AnisearchID int `json:"anisearch_id,omitempty"`
SimklID int `json:"simkl_id,omitempty"`
NotifyMoeID string `json:"notifymoe_id,omitempty"`
AnimecountdownID int `json:"animecountdown_id,omitempty"`
Type string `json:"type,omitempty"`
}
Episode struct {
AnidbEpisode string `json:"anidbEpisode"`
AnidbId int `json:"anidbEid"`
TvdbId int `json:"tvdbEid,omitempty"`
TvdbShowId int `json:"tvdbShowId,omitempty"`
AirDate string `json:"airDate,omitempty"` // YYYY-MM-DD
AnidbTitle string `json:"anidbTitle,omitempty"` // Title of the episode from AniDB
TvdbTitle string `json:"tvdbTitle,omitempty"` // Title of the episode from TVDB
Overview string `json:"overview,omitempty"`
Image string `json:"image,omitempty"`
Runtime int `json:"runtime,omitempty"` // minutes
Length string `json:"length,omitempty"` // Xm
SeasonNumber int `json:"seasonNumber,omitempty"`
SeasonName string `json:"seasonName,omitempty"`
Number int `json:"number"`
AbsoluteNumber int `json:"absoluteNumber,omitempty"`
}
)
//----------------------------------------------------------------------------------------------------------------------
type Cache struct {
*result.Cache[string, *Anime]
}
// FetchAnimapMedia fetches animap.Anime from the Animap API.
func FetchAnimapMedia(from string, id int) (*Anime, error) {
// Event
reqEvent := &AnimapMediaRequestedEvent{
From: from,
Id: id,
Media: &Anime{},
}
err := hook.GlobalHookManager.OnAnimapMediaRequested().Trigger(reqEvent)
if err != nil {
return nil, err
}
// If the hook prevented the default behavior, return the data
if reqEvent.DefaultPrevented {
return reqEvent.Media, nil
}
from = reqEvent.From
id = reqEvent.Id
apiUrl := constants.InternalMetadataURL + "/entry?" + from + "_id=" + strconv.Itoa(id)
request, err := http.NewRequest("GET", apiUrl, nil)
if err != nil {
return nil, err
}
request.Header.Set("X-Seanime-Version", "Seanime/"+constants.Version)
// Send an HTTP GET request
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, errors.New("not found on Animap")
}
// Read the response body
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
// Unmarshal the JSON data into AnimapData
var media Anime
if err := json.Unmarshal(responseBody, &media); err != nil {
return nil, err
}
// Event
event := &AnimapMediaEvent{
Media: &media,
}
err = hook.GlobalHookManager.OnAnimapMedia().Trigger(event)
if err != nil {
return nil, err
}
// If the hook prevented the default behavior, return the data
if event.DefaultPrevented {
return event.Media, nil
}
return event.Media, nil
}

View File

@@ -0,0 +1,19 @@
package animap
import "seanime/internal/hook_resolver"
// AnimapMediaRequestedEvent is triggered when the Animap media is requested.
// Prevent default to skip the default behavior and return your own data.
type AnimapMediaRequestedEvent struct {
hook_resolver.Event
From string `json:"from"`
Id int `json:"id"`
// Empty data object, will be used if the hook prevents the default behavior
Media *Anime `json:"media"`
}
// AnimapMediaEvent is triggered after processing AnimapMedia.
type AnimapMediaEvent struct {
hook_resolver.Event
Media *Anime `json:"media"`
}

View File

@@ -0,0 +1,156 @@
package anizip
import (
"errors"
"io"
"net/http"
"seanime/internal/hook"
"seanime/internal/util/result"
"strconv"
"github.com/goccy/go-json"
)
// AniZip is the API used for fetching anime metadata and mappings.
type (
Episode struct {
TvdbEid int `json:"tvdbEid,omitempty"`
AirDate string `json:"airdate,omitempty"`
SeasonNumber int `json:"seasonNumber,omitempty"`
EpisodeNumber int `json:"episodeNumber,omitempty"`
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber,omitempty"`
Title map[string]string `json:"title,omitempty"`
Image string `json:"image,omitempty"`
Summary string `json:"summary,omitempty"`
Overview string `json:"overview,omitempty"`
Runtime int `json:"runtime,omitempty"`
Length int `json:"length,omitempty"`
Episode string `json:"episode,omitempty"`
AnidbEid int `json:"anidbEid,omitempty"`
Rating string `json:"rating,omitempty"`
}
Mappings struct {
AnimeplanetID string `json:"animeplanet_id,omitempty"`
KitsuID int `json:"kitsu_id,omitempty"`
MalID int `json:"mal_id,omitempty"`
Type string `json:"type,omitempty"`
AnilistID int `json:"anilist_id,omitempty"`
AnisearchID int `json:"anisearch_id,omitempty"`
AnidbID int `json:"anidb_id,omitempty"`
NotifymoeID string `json:"notifymoe_id,omitempty"`
LivechartID int `json:"livechart_id,omitempty"`
ThetvdbID int `json:"thetvdb_id,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
ThemoviedbID string `json:"themoviedb_id,omitempty"`
}
Media struct {
Titles map[string]string `json:"titles"`
Episodes map[string]Episode `json:"episodes"`
EpisodeCount int `json:"episodeCount"`
SpecialCount int `json:"specialCount"`
Mappings *Mappings `json:"mappings"`
}
)
//----------------------------------------------------------------------------------------------------------------------
type Cache struct {
*result.Cache[string, *Media]
}
func NewCache() *Cache {
return &Cache{result.NewCache[string, *Media]()}
}
func GetCacheKey(from string, id int) string {
return from + strconv.Itoa(id)
}
//----------------------------------------------------------------------------------------------------------------------
// FetchAniZipMedia fetches anizip.Media from the AniZip API.
func FetchAniZipMedia(from string, id int) (*Media, error) {
// Event
reqEvent := &AnizipMediaRequestedEvent{
From: from,
Id: id,
Media: &Media{},
}
err := hook.GlobalHookManager.OnAnizipMediaRequested().Trigger(reqEvent)
if err != nil {
return nil, err
}
// If the hook prevented the default behavior, return the data
if reqEvent.DefaultPrevented {
return reqEvent.Media, nil
}
from = reqEvent.From
id = reqEvent.Id
apiUrl := "https://api.ani.zip/v1/episodes?" + from + "_id=" + strconv.Itoa(id)
// Send an HTTP GET request
response, err := http.Get(apiUrl)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, errors.New("not found on AniZip")
}
// Read the response body
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
// Unmarshal the JSON data into AniZipData
var media Media
if err := json.Unmarshal(responseBody, &media); err != nil {
return nil, err
}
// Event
event := &AnizipMediaEvent{
Media: &media,
}
err = hook.GlobalHookManager.OnAnizipMedia().Trigger(event)
if err != nil {
return nil, err
}
// If the hook prevented the default behavior, return the data
if event.DefaultPrevented {
return event.Media, nil
}
return event.Media, nil
}
// FetchAniZipMediaC is the same as FetchAniZipMedia but uses a cache.
// If the media is found in the cache, it will be returned.
// If the media is not found in the cache, it will be fetched and then added to the cache.
func FetchAniZipMediaC(from string, id int, cache *Cache) (*Media, error) {
cacheV, ok := cache.Get(GetCacheKey(from, id))
if ok {
return cacheV, nil
}
media, err := FetchAniZipMedia(from, id)
if err != nil {
return nil, err
}
cache.Set(GetCacheKey(from, id), media)
return media, nil
}

View File

@@ -0,0 +1,65 @@
package anizip
func (m *Media) GetTitle() string {
if m == nil {
return ""
}
if len(m.Titles["en"]) > 0 {
return m.Titles["en"]
}
return m.Titles["ro"]
}
func (m *Media) GetMappings() *Mappings {
if m == nil {
return &Mappings{}
}
return m.Mappings
}
func (m *Media) FindEpisode(ep string) (*Episode, bool) {
if m.Episodes == nil {
return nil, false
}
episode, found := m.Episodes[ep]
if !found {
return nil, false
}
return &episode, true
}
func (m *Media) GetMainEpisodeCount() int {
if m == nil {
return 0
}
return m.EpisodeCount
}
// GetOffset returns the offset of the first episode relative to the absolute episode number.
// e.g, if the first episode's absolute number is 13, then the offset is 12.
func (m *Media) GetOffset() int {
if m == nil {
return 0
}
firstEp, found := m.FindEpisode("1")
if !found {
return 0
}
if firstEp.AbsoluteEpisodeNumber == 0 {
return 0
}
return firstEp.AbsoluteEpisodeNumber - 1
}
func (e *Episode) GetTitle() string {
eng, ok := e.Title["en"]
if ok {
return eng
}
rom, ok := e.Title["x-jat"]
if ok {
return rom
}
return ""
}

View File

@@ -0,0 +1,37 @@
package anizip
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestFetchAniZipMedia(t *testing.T) {
tests := []struct {
name string
provider string
id int
expectedTitle string
}{
{
name: "Cowboy Bebop",
provider: "anilist",
id: 1,
expectedTitle: "Cowboy Bebop",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
media, err := FetchAniZipMedia(test.provider, test.id)
if assert.NoError(t, err) {
if assert.NotNil(t, media) {
assert.Equal(t, media.GetTitle(), test.expectedTitle)
}
}
})
}
}

View File

@@ -0,0 +1,19 @@
package anizip
import "seanime/internal/hook_resolver"
// AnizipMediaRequestedEvent is triggered when the AniZip media is requested.
// Prevent default to skip the default behavior and return your own data.
type AnizipMediaRequestedEvent struct {
hook_resolver.Event
From string `json:"from"`
Id int `json:"id"`
// Empty data object, will be used if the hook prevents the default behavior
Media *Media `json:"media"`
}
// AnizipMediaEvent is triggered after processing AnizipMedia.
type AnizipMediaEvent struct {
hook_resolver.Event
Media *Media `json:"media"`
}

View File

@@ -0,0 +1,185 @@
package filler
import (
"fmt"
"seanime/internal/util"
"strings"
"github.com/adrg/strutil/metrics"
"github.com/gocolly/colly"
"github.com/rs/zerolog"
)
type (
SearchOptions struct {
Titles []string
}
SearchResult struct {
Slug string
Title string
}
API interface {
Search(opts SearchOptions) (*SearchResult, error)
FindFillerData(slug string) (*Data, error)
}
Data struct {
FillerEpisodes []string `json:"fillerEpisodes"`
}
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type (
AnimeFillerList struct {
baseUrl string
userAgent string
logger *zerolog.Logger
}
)
func NewAnimeFillerList(logger *zerolog.Logger) *AnimeFillerList {
return &AnimeFillerList{
baseUrl: "https://www.animefillerlist.com",
userAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
func (af *AnimeFillerList) Search(opts SearchOptions) (result *SearchResult, err error) {
defer util.HandlePanicInModuleWithError("api/metadata/filler/Search", &err)
c := colly.NewCollector(
colly.UserAgent(af.userAgent),
)
ret := make([]*SearchResult, 0)
c.OnHTML("div.Group > ul > li > a", func(e *colly.HTMLElement) {
ret = append(ret, &SearchResult{
Slug: e.Attr("href"),
Title: e.Text,
})
})
err = c.Visit(fmt.Sprintf("%s/shows", af.baseUrl))
if err != nil {
return nil, err
}
if len(ret) == 0 {
return nil, fmt.Errorf("no results found")
}
lev := metrics.NewLevenshtein()
lev.CaseSensitive = false
compResults := make([]struct {
OriginalValue string
Value string
Distance int
}, 0)
for _, result := range ret {
firstTitle := result.Title
secondTitle := ""
// Check if a second title exists between parentheses
if strings.LastIndex(firstTitle, " (") != -1 && strings.LastIndex(firstTitle, ")") != -1 {
secondTitle = firstTitle[strings.LastIndex(firstTitle, " (")+2 : strings.LastIndex(firstTitle, ")")]
if !util.IsMostlyLatinString(secondTitle) {
secondTitle = ""
}
}
if secondTitle != "" {
firstTitle = firstTitle[:strings.LastIndex(firstTitle, " (")]
}
for _, mediaTitle := range opts.Titles {
compResults = append(compResults, struct {
OriginalValue string
Value string
Distance int
}{
OriginalValue: result.Title,
Value: firstTitle,
Distance: lev.Distance(mediaTitle, firstTitle),
})
if secondTitle != "" {
compResults = append(compResults, struct {
OriginalValue string
Value string
Distance int
}{
OriginalValue: result.Title,
Value: secondTitle,
Distance: lev.Distance(mediaTitle, secondTitle),
})
}
}
}
// Find the best match
bestResult := struct {
OriginalValue string
Value string
Distance int
}{}
for _, result := range compResults {
if bestResult.OriginalValue == "" || result.Distance <= bestResult.Distance {
if bestResult.OriginalValue != "" && result.Distance == bestResult.Distance && len(result.OriginalValue) > len(bestResult.OriginalValue) {
continue
}
bestResult = result
}
}
if bestResult.OriginalValue == "" {
return nil, fmt.Errorf("no results found")
}
if bestResult.Distance > 10 {
return nil, fmt.Errorf("no results found")
}
// Get the result
for _, r := range ret {
if r.Title == bestResult.OriginalValue {
return r, nil
}
}
return
}
func (af *AnimeFillerList) FindFillerData(slug string) (ret *Data, err error) {
defer util.HandlePanicInModuleWithError("api/metadata/filler/FindFillerEpisodes", &err)
c := colly.NewCollector(
colly.UserAgent(af.userAgent),
)
ret = &Data{
FillerEpisodes: make([]string, 0),
}
fillerEps := make([]string, 0)
c.OnHTML("tr.filler", func(e *colly.HTMLElement) {
fillerEps = append(fillerEps, e.ChildText("td.Number"))
})
err = c.Visit(fmt.Sprintf("%s%s", af.baseUrl, slug))
if err != nil {
return nil, err
}
ret.FillerEpisodes = fillerEps
return
}

View File

@@ -0,0 +1,24 @@
package filler
import (
"seanime/internal/util"
"testing"
"github.com/davecgh/go-spew/spew"
)
func TestAnimeFillerList_Search(t *testing.T) {
af := NewAnimeFillerList(util.NewLogger())
opts := SearchOptions{
Titles: []string{"Hunter x Hunter (2011)"},
}
ret, err := af.Search(opts)
if err != nil {
t.Error(err)
}
spew.Dump(ret)
}

View File

@@ -0,0 +1,186 @@
package mal
import (
"fmt"
"net/url"
)
const (
BaseAnimeFields string = "id,title,main_picture,alternative_titles,start_date,end_date,start_season,nsfw,synopsis,num_episodes,mean,rank,popularity,media_type,status"
)
type (
BasicAnime struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
AlternativeTitles struct {
Synonyms []string `json:"synonyms"`
En string `json:"en"`
Ja string `json:"ja"`
} `json:"alternative_titles"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
StartSeason struct {
Year int `json:"year"`
Season string `json:"season"`
} `json:"start_season"`
Synopsis string `json:"synopsis"`
NSFW string `json:"nsfw"`
NumEpisodes int `json:"num_episodes"`
Mean float32 `json:"mean"`
Rank int `json:"rank"`
Popularity int `json:"popularity"`
MediaType MediaType `json:"media_type"`
Status MediaStatus `json:"status"`
}
AnimeListEntry struct {
Node struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
} `json:"node"`
ListStatus struct {
Status MediaListStatus `json:"status"`
IsRewatching bool `json:"is_rewatching"`
NumEpisodesWatched int `json:"num_episodes_watched"`
Score int `json:"score"`
UpdatedAt string `json:"updated_at"`
} `json:"list_status"`
}
)
func (w *Wrapper) GetAnimeDetails(mId int) (*BasicAnime, error) {
w.logger.Debug().Int("mId", mId).Msg("mal: Getting anime details")
reqUrl := fmt.Sprintf("%s/anime/%d?fields=%s", ApiBaseURL, mId, BaseAnimeFields)
if w.AccessToken == "" {
return nil, fmt.Errorf("access token is empty")
}
var anime BasicAnime
err := w.doQuery("GET", reqUrl, nil, "application/json", &anime)
if err != nil {
w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to get anime details")
return nil, err
}
w.logger.Info().Int("mId", mId).Msg("mal: Fetched anime details")
return &anime, nil
}
func (w *Wrapper) GetAnimeCollection() ([]*AnimeListEntry, error) {
w.logger.Debug().Msg("mal: Getting anime collection")
reqUrl := fmt.Sprintf("%s/users/@me/animelist?fields=list_status&limit=1000", ApiBaseURL)
type response struct {
Data []*AnimeListEntry `json:"data"`
}
var data response
err := w.doQuery("GET", reqUrl, nil, "application/json", &data)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to get anime collection")
return nil, err
}
w.logger.Info().Msg("mal: Fetched anime collection")
return data.Data, nil
}
type AnimeListProgressParams struct {
NumEpisodesWatched *int
}
func (w *Wrapper) UpdateAnimeProgress(opts *AnimeListProgressParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating anime progress")
// Get anime details
anime, err := w.GetAnimeDetails(mId)
if err != nil {
return err
}
status := MediaListStatusWatching
if anime.Status == MediaStatusFinishedAiring && anime.NumEpisodes > 0 && anime.NumEpisodes <= *opts.NumEpisodesWatched {
status = MediaListStatusCompleted
}
if anime.NumEpisodes > 0 && *opts.NumEpisodesWatched > anime.NumEpisodes {
*opts.NumEpisodesWatched = anime.NumEpisodes
}
// Update MAL list entry
err = w.UpdateAnimeListStatus(&AnimeListStatusParams{
Status: &status,
NumEpisodesWatched: opts.NumEpisodesWatched,
}, mId)
if err == nil {
w.logger.Info().Int("mId", mId).Msg("mal: Updated anime progress")
}
return err
}
type AnimeListStatusParams struct {
Status *MediaListStatus
IsRewatching *bool
NumEpisodesWatched *int
Score *int
}
func (w *Wrapper) UpdateAnimeListStatus(opts *AnimeListStatusParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating anime list status")
reqUrl := fmt.Sprintf("%s/anime/%d/my_list_status", ApiBaseURL, mId)
// Build URL
urlData := url.Values{}
if opts.Status != nil {
urlData.Set("status", string(*opts.Status))
}
if opts.IsRewatching != nil {
urlData.Set("is_rewatching", fmt.Sprintf("%t", *opts.IsRewatching))
}
if opts.NumEpisodesWatched != nil {
urlData.Set("num_watched_episodes", fmt.Sprintf("%d", *opts.NumEpisodesWatched))
}
if opts.Score != nil {
urlData.Set("score", fmt.Sprintf("%d", *opts.Score))
}
encodedData := urlData.Encode()
err := w.doMutation("PATCH", reqUrl, encodedData)
if err != nil {
w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to update anime list status")
return err
}
return nil
}
func (w *Wrapper) DeleteAnimeListItem(mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Deleting anime list item")
reqUrl := fmt.Sprintf("%s/anime/%d/my_list_status", ApiBaseURL, mId)
err := w.doMutation("DELETE", reqUrl, "")
if err != nil {
w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to delete anime list item")
return err
}
w.logger.Info().Int("mId", mId).Msg("mal: Deleted anime list item")
return nil
}

View File

@@ -0,0 +1,62 @@
package mal
import (
"github.com/davecgh/go-spew/spew"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestGetAnimeDetails(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetAnimeDetails(51179)
spew.Dump(res)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
t.Log(res.Title)
}
func TestGetAnimeCollection(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetAnimeCollection()
if err != nil {
t.Fatalf("error while fetching anime collection, %v", err)
}
for _, entry := range res {
t.Log(entry.Node.Title)
if entry.Node.ID == 51179 {
spew.Dump(entry)
}
}
}
func TestUpdateAnimeListStatus(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList(), test_utils.MyAnimeListMutation())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
mId := 51179
progress := 2
status := MediaListStatusWatching
err := malWrapper.UpdateAnimeListStatus(&AnimeListStatusParams{
Status: &status,
NumEpisodesWatched: &progress,
}, mId)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
}

View File

@@ -0,0 +1,185 @@
package mal
import (
"fmt"
"net/url"
)
const (
BaseMangaFields string = "id,title,main_picture,alternative_titles,start_date,end_date,nsfw,synopsis,num_volumes,num_chapters,mean,rank,popularity,media_type,status"
)
type (
BasicManga struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
AlternativeTitles struct {
Synonyms []string `json:"synonyms"`
En string `json:"en"`
Ja string `json:"ja"`
} `json:"alternative_titles"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Synopsis string `json:"synopsis"`
NSFW string `json:"nsfw"`
NumVolumes int `json:"num_volumes"`
NumChapters int `json:"num_chapters"`
Mean float32 `json:"mean"`
Rank int `json:"rank"`
Popularity int `json:"popularity"`
MediaType MediaType `json:"media_type"`
Status MediaStatus `json:"status"`
}
MangaListEntry struct {
Node struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
} `json:"node"`
ListStatus struct {
Status MediaListStatus `json:"status"`
IsRereading bool `json:"is_rereading"`
NumVolumesRead int `json:"num_volumes_read"`
NumChaptersRead int `json:"num_chapters_read"`
Score int `json:"score"`
UpdatedAt string `json:"updated_at"`
} `json:"list_status"`
}
)
func (w *Wrapper) GetMangaDetails(mId int) (*BasicManga, error) {
w.logger.Debug().Int("mId", mId).Msg("mal: Getting manga details")
reqUrl := fmt.Sprintf("%s/manga/%d?fields=%s", ApiBaseURL, mId, BaseMangaFields)
if w.AccessToken == "" {
return nil, fmt.Errorf("access token is empty")
}
var manga BasicManga
err := w.doQuery("GET", reqUrl, nil, "application/json", &manga)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to get manga details")
return nil, err
}
w.logger.Info().Int("mId", mId).Msg("mal: Fetched manga details")
return &manga, nil
}
func (w *Wrapper) GetMangaCollection() ([]*MangaListEntry, error) {
w.logger.Debug().Msg("mal: Getting manga collection")
reqUrl := fmt.Sprintf("%s/users/@me/mangalist?fields=list_status&limit=1000", ApiBaseURL)
type response struct {
Data []*MangaListEntry `json:"data"`
}
var data response
err := w.doQuery("GET", reqUrl, nil, "application/json", &data)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to get manga collection")
return nil, err
}
w.logger.Info().Msg("mal: Fetched manga collection")
return data.Data, nil
}
type MangaListProgressParams struct {
NumChaptersRead *int
}
func (w *Wrapper) UpdateMangaProgress(opts *MangaListProgressParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating manga progress")
// Get manga details
manga, err := w.GetMangaDetails(mId)
if err != nil {
return err
}
status := MediaListStatusReading
if manga.Status == MediaStatusFinished && manga.NumChapters > 0 && manga.NumChapters <= *opts.NumChaptersRead {
status = MediaListStatusCompleted
}
if manga.NumChapters > 0 && *opts.NumChaptersRead > manga.NumChapters {
*opts.NumChaptersRead = manga.NumChapters
}
// Update MAL list entry
err = w.UpdateMangaListStatus(&MangaListStatusParams{
Status: &status,
NumChaptersRead: opts.NumChaptersRead,
}, mId)
if err == nil {
w.logger.Info().Int("mId", mId).Msg("mal: Updated manga progress")
}
return err
}
type MangaListStatusParams struct {
Status *MediaListStatus
IsRereading *bool
NumChaptersRead *int
Score *int
}
func (w *Wrapper) UpdateMangaListStatus(opts *MangaListStatusParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating manga list status")
reqUrl := fmt.Sprintf("%s/manga/%d/my_list_status", ApiBaseURL, mId)
// Build URL
urlData := url.Values{}
if opts.Status != nil {
urlData.Set("status", string(*opts.Status))
}
if opts.IsRereading != nil {
urlData.Set("is_rereading", fmt.Sprintf("%t", *opts.IsRereading))
}
if opts.NumChaptersRead != nil {
urlData.Set("num_chapters_read", fmt.Sprintf("%d", *opts.NumChaptersRead))
}
if opts.Score != nil {
urlData.Set("score", fmt.Sprintf("%d", *opts.Score))
}
encodedData := urlData.Encode()
err := w.doMutation("PATCH", reqUrl, encodedData)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to update manga list status")
return err
}
return nil
}
func (w *Wrapper) DeleteMangaListItem(mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Deleting manga list item")
reqUrl := fmt.Sprintf("%s/manga/%d/my_list_status", ApiBaseURL, mId)
err := w.doMutation("DELETE", reqUrl, "")
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to delete manga list item")
return err
}
w.logger.Info().Int("mId", mId).Msg("mal: Deleted manga list item")
return nil
}

View File

@@ -0,0 +1,62 @@
package mal
import (
"github.com/davecgh/go-spew/spew"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestGetMangaDetails(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetMangaDetails(13)
spew.Dump(res)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
t.Log(res.Title)
}
func TestGetMangaCollection(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetMangaCollection()
if err != nil {
t.Fatalf("error while fetching anime collection, %v", err)
}
for _, entry := range res {
t.Log(entry.Node.Title)
if entry.Node.ID == 13 {
spew.Dump(entry)
}
}
}
func TestUpdateMangaListStatus(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList(), test_utils.MyAnimeListMutation())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
mId := 13
progress := 1000
status := MediaListStatusReading
err := malWrapper.UpdateMangaListStatus(&MangaListStatusParams{
Status: &status,
NumChaptersRead: &progress,
}, mId)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
}

View File

@@ -0,0 +1,232 @@
package mal
import (
"errors"
"fmt"
"github.com/goccy/go-json"
"github.com/samber/lo"
"io"
"math"
"net/http"
"net/url"
"regexp"
"seanime/internal/util/comparison"
"seanime/internal/util/result"
"sort"
"strings"
)
type (
SearchResultPayload struct {
MediaType string `json:"media_type"`
StartYear int `json:"start_year"`
Aired string `json:"aired,omitempty"`
Score string `json:"score"`
Status string `json:"status"`
}
SearchResultAnime struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
ImageURL string `json:"image_url"`
ThumbnailURL string `json:"thumbnail_url"`
Payload *SearchResultPayload `json:"payload"`
ESScore float64 `json:"es_score"`
}
SearchResult struct {
Categories []*struct {
Type string `json:"type"`
Items []*SearchResultAnime `json:"items"`
} `json:"categories"`
}
SearchCache struct {
*result.Cache[int, *SearchResultAnime]
}
)
//----------------------------------------------------------------------------------------------------------------------
// SearchWithMAL uses MAL's search API to find suggestions that match the title provided.
func SearchWithMAL(title string, slice int) ([]*SearchResultAnime, error) {
url := "https://myanimelist.net/search/prefix.json?type=anime&v=1&keyword=" + url.QueryEscape(title)
res, err := http.Get(url)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request failed with status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var bodyMap SearchResult
err = json.Unmarshal(body, &bodyMap)
if err != nil {
return nil, fmt.Errorf("unmarshaling error: %v", err)
}
if bodyMap.Categories == nil {
return nil, fmt.Errorf("missing 'categories' in response")
}
items := make([]*SearchResultAnime, 0)
for _, cat := range bodyMap.Categories {
if cat.Type == "anime" {
items = append(items, cat.Items...)
}
}
if len(items) > slice {
return items[:slice], nil
}
return items, nil
}
// AdvancedSearchWithMAL is like SearchWithMAL, but it uses additional algorithms to find the best match.
func AdvancedSearchWithMAL(title string) (*SearchResultAnime, error) {
if len(title) == 0 {
return nil, fmt.Errorf("title is empty")
}
// trim the title
title = strings.ToLower(strings.TrimSpace(title))
// MAL typically doesn't use "cour"
re := regexp.MustCompile(`\bcour\b`)
title = re.ReplaceAllString(title, "part")
// fetch suggestions from MAL
suggestions, err := SearchWithMAL(title, 8)
if err != nil {
return nil, err
}
// sort the suggestions by score
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].ESScore > suggestions[j].ESScore
})
// keep anime that have aired
suggestions = lo.Filter(suggestions, func(n *SearchResultAnime, index int) bool {
return n.ESScore >= 0.1 && n.Payload.Status != "Not yet aired"
})
// reduce score if anime is older than 2006
suggestions = lo.Map(suggestions, func(n *SearchResultAnime, index int) *SearchResultAnime {
if n.Payload.StartYear < 2006 {
n.ESScore -= 0.1
}
return n
})
tparts := strings.Fields(title)
tsub := tparts[0]
if len(tparts) > 1 {
tsub += " " + tparts[1]
}
tsub = strings.TrimSpace(tsub)
//
t1, foundT1 := lo.Find(suggestions, func(n *SearchResultAnime) bool {
nTitle := strings.ToLower(n.Name)
_tsub := tparts[0]
if len(tparts) > 1 {
_tsub += " " + tparts[1]
}
_tsub = strings.TrimSpace(_tsub)
re := regexp.MustCompile(`\b(film|movie|season|part|(s\d{2}e?))\b`)
return strings.HasPrefix(nTitle, tsub) && n.Payload.MediaType == "TV" && !re.MatchString(nTitle)
})
// very generous
t2, foundT2 := lo.Find(suggestions, func(n *SearchResultAnime) bool {
nTitle := strings.ToLower(n.Name)
_tsub := tparts[0]
re := regexp.MustCompile(`\b(film|movie|season|part|(s\d{2}e?))\b`)
return strings.HasPrefix(nTitle, _tsub) && n.Payload.MediaType == "TV" && !re.MatchString(nTitle)
})
levResult, found := comparison.FindBestMatchWithLevenshtein(&title, lo.Map(suggestions, func(n *SearchResultAnime, index int) *string { return &n.Name }))
if !found {
return nil, errors.New("couldn't find a suggestion from levenshtein")
}
levSuggestion, found := lo.Find(suggestions, func(n *SearchResultAnime) bool {
return strings.ToLower(n.Name) == strings.ToLower(*levResult.Value)
})
if !found {
return nil, errors.New("couldn't locate lenshtein result")
}
if foundT1 {
d, found := comparison.FindBestMatchWithLevenshtein(&tsub, []*string{&title, new(string)})
if found && len(*d.Value) > 0 {
if d.Distance <= 1 {
return t1, nil
}
}
}
// Strong correlation using MAL
if suggestions[0].ESScore >= 4.5 {
return suggestions[0], nil
}
// Very Likely match using distance
if levResult.Distance <= 4 {
return levSuggestion, nil
}
if suggestions[0].ESScore < 5 {
// Likely match using [startsWith]
if foundT1 {
dev := math.Abs(t1.ESScore-suggestions[0].ESScore) < 2.0
if len(tsub) > 6 && dev {
return t1, nil
}
}
// Likely match using [startsWith]
if foundT2 {
dev := math.Abs(t2.ESScore-suggestions[0].ESScore) < 2.0
if len(tparts[0]) > 6 && dev {
return t2, nil
}
}
// Likely match using distance
if levSuggestion.ESScore >= 1 && !(suggestions[0].ESScore > 3) {
return suggestions[0], nil
}
// Less than likely match using MAL
return suggestions[0], nil
}
// Distance above threshold, falling back to first MAL suggestion above
if levResult.Distance >= 5 && suggestions[0].ESScore >= 1 {
return suggestions[0], nil
}
return nil, nil
}

View File

@@ -0,0 +1,34 @@
package mal
import (
"seanime/internal/test_utils"
"testing"
)
func TestSearchWithMAL(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
res, err := SearchWithMAL("bungo stray dogs", 4)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
for _, m := range res {
t.Log(m.Name)
}
}
func TestAdvancedSearchWithMal(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
res, err := AdvancedSearchWithMAL("sousou no frieren")
if err != nil {
t.Fatal("expected result, got error: ", err)
}
t.Log(res.Name)
}

View File

@@ -0,0 +1,40 @@
package mal
import "time"
type (
RequestOptions struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
MediaType string
MediaStatus string
MediaListStatus string
)
const (
MediaTypeTV MediaType = "tv" // Anime
MediaTypeOVA MediaType = "ova" // Anime
MediaTypeMovie MediaType = "movie" // Anime
MediaTypeSpecial MediaType = "special" // Anime
MediaTypeONA MediaType = "ona" // Anime
MediaTypeMusic MediaType = "music"
MediaTypeManga MediaType = "manga" // Manga
MediaTypeNovel MediaType = "novel" // Manga
MediaTypeOneShot MediaType = "oneshot" // Manga
MediaStatusFinishedAiring MediaStatus = "finished_airing" // Anime
MediaStatusCurrentlyAiring MediaStatus = "currently_airing" // Anime
MediaStatusNotYetAired MediaStatus = "not_yet_aired" // Anime
MediaStatusFinished MediaStatus = "finished" // Manga
MediaStatusCurrentlyPublishing MediaStatus = "currently_publishing" // Manga
MediaStatusNotYetPublished MediaStatus = "not_yet_published" // Manga
MediaListStatusReading MediaListStatus = "reading" // Manga
MediaListStatusWatching MediaListStatus = "watching" // Anime
MediaListStatusCompleted MediaListStatus = "completed"
MediaListStatusOnHold MediaListStatus = "on_hold"
MediaListStatusDropped MediaListStatus = "dropped"
MediaListStatusPlanToWatch MediaListStatus = "plan_to_watch" // Anime
MediaListStatusPlanToRead MediaListStatus = "plan_to_read" // Manga
)

View File

@@ -0,0 +1,160 @@
package mal
import (
"fmt"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
"io"
"net/http"
"net/url"
"seanime/internal/database/db"
"seanime/internal/database/models"
"strings"
"time"
)
const (
ApiBaseURL string = "https://api.myanimelist.net/v2"
)
type (
Wrapper struct {
AccessToken string
client *http.Client
logger *zerolog.Logger
}
)
func NewWrapper(accessToken string, logger *zerolog.Logger) *Wrapper {
return &Wrapper{
AccessToken: accessToken,
client: &http.Client{},
logger: logger,
}
}
func (w *Wrapper) doQuery(method, uri string, body io.Reader, contentType string, data interface{}) error {
req, err := http.NewRequest(method, uri, body)
if err != nil {
return err
}
req.Header.Add("Content-Type", contentType)
req.Header.Add("Authorization", "Bearer "+w.AccessToken)
// Make the HTTP request
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !((resp.StatusCode >= 200) && (resp.StatusCode <= 299)) {
return fmt.Errorf("invalid response status %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return err
}
return nil
}
func (w *Wrapper) doMutation(method, uri, encodedParams string) error {
var reader io.Reader
reader = nil
if encodedParams != "" {
reader = strings.NewReader(encodedParams)
}
req, err := http.NewRequest(method, uri, reader)
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Authorization", "Bearer "+w.AccessToken)
// Make the HTTP request
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !((resp.StatusCode >= 200) && (resp.StatusCode <= 299)) {
return fmt.Errorf("invalid response status %s", resp.Status)
}
return nil
}
func VerifyMALAuth(malInfo *models.Mal, db *db.Database, logger *zerolog.Logger) (*models.Mal, error) {
// Token has not expired
if malInfo.TokenExpiresAt.After(time.Now()) {
logger.Debug().Msg("mal: Token is still valid")
return malInfo, nil
}
// Token is expired, refresh it
client := &http.Client{}
// Build URL
urlData := url.Values{}
urlData.Set("grant_type", "refresh_token")
urlData.Set("refresh_token", malInfo.RefreshToken)
encodedData := urlData.Encode()
req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(encodedData))
if err != nil {
logger.Error().Err(err).Msg("mal: Failed to create request")
return malInfo, err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Authorization", "Basic "+malInfo.AccessToken)
// Response
res, err := client.Do(req)
if err != nil {
logger.Error().Err(err).Msg("mal: Failed to refresh token")
return malInfo, err
}
defer res.Body.Close()
type malAuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int32 `json:"expires_in"`
TokenType string `json:"token_type"`
}
ret := malAuthResponse{}
if err := json.NewDecoder(res.Body).Decode(&ret); err != nil {
return malInfo, err
}
if ret.AccessToken == "" {
logger.Error().Msgf("mal: Failed to refresh token %s", res.Status)
return malInfo, fmt.Errorf("mal: Failed to refresh token %s", res.Status)
}
// Save
updatedMalInfo := models.Mal{
BaseModel: models.BaseModel{
ID: 1,
UpdatedAt: time.Now(),
},
Username: "",
AccessToken: ret.AccessToken,
RefreshToken: ret.RefreshToken,
TokenExpiresAt: time.Now().Add(time.Duration(ret.ExpiresIn) * time.Second),
}
_, err = db.UpsertMalInfo(&updatedMalInfo)
if err != nil {
logger.Error().Err(err).Msg("mal: Failed to save updated MAL info")
return malInfo, err
}
logger.Info().Msg("mal: Refreshed token")
return &updatedMalInfo, nil
}

View File

@@ -0,0 +1,65 @@
package mangaupdates
import (
"bytes"
"github.com/davecgh/go-spew/spew"
"github.com/goccy/go-json"
"github.com/stretchr/testify/require"
"net/http"
"strings"
"testing"
"time"
)
func TestApi(t *testing.T) {
tests := []struct {
title string
startDate string
}{
{
title: "Dandadan",
startDate: "2021-04-06",
},
}
type searchReleaseBody struct {
Search string `json:"search"`
StartDate string `json:"start_date,omitempty"`
}
var apiUrl = "https://api.mangaupdates.com/v1/releases/search"
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {
client := http.Client{Timeout: 10 * time.Second}
body := searchReleaseBody{
Search: strings.ToLower(test.title),
StartDate: test.startDate,
}
bodyB, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(bodyB))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
var result interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
spew.Dump(result)
})
}
}

View File

@@ -0,0 +1,144 @@
package metadata
import (
"regexp"
"seanime/internal/api/anilist"
"seanime/internal/hook"
"seanime/internal/util"
"seanime/internal/util/filecache"
"strconv"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
type (
AnimeWrapperImpl struct {
metadata mo.Option[*AnimeMetadata]
baseAnime *anilist.BaseAnime
fileCacher *filecache.Cacher
logger *zerolog.Logger
}
)
func (aw *AnimeWrapperImpl) GetEpisodeMetadata(epNum int) (ret EpisodeMetadata) {
if aw == nil || aw.baseAnime == nil {
return
}
ret = EpisodeMetadata{
AnidbId: 0,
TvdbId: 0,
Title: "",
Image: "",
AirDate: "",
Length: 0,
Summary: "",
Overview: "",
EpisodeNumber: epNum,
Episode: strconv.Itoa(epNum),
SeasonNumber: 0,
AbsoluteEpisodeNumber: 0,
AnidbEid: 0,
}
defer util.HandlePanicInModuleThen("api/metadata/GetEpisodeMetadata", func() {})
reqEvent := &AnimeEpisodeMetadataRequestedEvent{}
reqEvent.MediaId = aw.baseAnime.GetID()
reqEvent.EpisodeNumber = epNum
reqEvent.EpisodeMetadata = &ret
_ = hook.GlobalHookManager.OnAnimeEpisodeMetadataRequested().Trigger(reqEvent)
epNum = reqEvent.EpisodeNumber
// Default prevented by hook, return the metadata
if reqEvent.DefaultPrevented {
if reqEvent.EpisodeMetadata == nil {
return ret
}
return *reqEvent.EpisodeMetadata
}
//
// Process
//
episode := mo.None[*EpisodeMetadata]()
if aw.metadata.IsAbsent() {
ret.Image = aw.baseAnime.GetBannerImageSafe()
} else {
episodeF, found := aw.metadata.MustGet().FindEpisode(strconv.Itoa(epNum))
if found {
episode = mo.Some(episodeF)
}
}
// If we don't have Animap metadata, just return the metadata containing the image
if episode.IsAbsent() {
return ret
}
ret = *episode.MustGet()
// If TVDB image is not set, use Animap image, if that is not set, use the AniList banner image
if ret.Image == "" {
// Set Animap image if TVDB image is not set
if episode.MustGet().Image != "" {
ret.Image = episode.MustGet().Image
} else {
// If Animap image is not set, use the base media image
ret.Image = aw.baseAnime.GetBannerImageSafe()
}
}
// Event
event := &AnimeEpisodeMetadataEvent{
EpisodeMetadata: &ret,
EpisodeNumber: epNum,
MediaId: aw.baseAnime.GetID(),
}
_ = hook.GlobalHookManager.OnAnimeEpisodeMetadata().Trigger(event)
if event.EpisodeMetadata == nil {
return ret
}
ret = *event.EpisodeMetadata
return ret
}
func ExtractEpisodeInteger(s string) (int, bool) {
pattern := "[0-9]+"
regex := regexp.MustCompile(pattern)
// Find the first match in the input string.
match := regex.FindString(s)
if match != "" {
// Convert the matched string to an integer.
num, err := strconv.Atoi(match)
if err != nil {
return 0, false
}
return num, true
}
return 0, false
}
func OffsetAnidbEpisode(s string, offset int) string {
pattern := "([0-9]+)"
regex := regexp.MustCompile(pattern)
// Replace the first matched integer with the incremented value.
result := regex.ReplaceAllStringFunc(s, func(matched string) string {
num, err := strconv.Atoi(matched)
if err == nil {
num = num + offset
return strconv.Itoa(num)
} else {
return matched
}
})
return result
}

View File

@@ -0,0 +1,26 @@
package metadata
import (
"testing"
)
func TestOffsetEpisode(t *testing.T) {
cases := []struct {
input string
expected string
}{
{"S1", "S2"},
{"OP1", "OP2"},
{"1", "2"},
{"OP", "OP"},
}
for _, c := range cases {
actual := OffsetAnidbEpisode(c.input, 1)
if actual != c.expected {
t.Errorf("OffsetAnidbEpisode(%s, 1) == %s, expected %s", c.input, actual, c.expected)
}
}
}

View File

@@ -0,0 +1,47 @@
package metadata
import "seanime/internal/hook_resolver"
// AnimeMetadataRequestedEvent is triggered when anime metadata is requested and right before the metadata is processed.
// This event is followed by [AnimeMetadataEvent] which is triggered when the metadata is available.
// Prevent default to skip the default behavior and return the modified metadata.
// If the modified metadata is nil, an error will be returned.
type AnimeMetadataRequestedEvent struct {
hook_resolver.Event
MediaId int `json:"mediaId"`
// Empty metadata object, will be used if the hook prevents the default behavior
AnimeMetadata *AnimeMetadata `json:"animeMetadata"`
}
// AnimeMetadataEvent is triggered when anime metadata is available and is about to be returned.
// Anime metadata can be requested in many places, ranging from displaying the anime entry to starting a torrent stream.
// This event is triggered after [AnimeMetadataRequestedEvent].
// If the modified metadata is nil, an error will be returned.
type AnimeMetadataEvent struct {
hook_resolver.Event
MediaId int `json:"mediaId"`
AnimeMetadata *AnimeMetadata `json:"animeMetadata"`
}
// AnimeEpisodeMetadataRequestedEvent is triggered when anime episode metadata is requested.
// Prevent default to skip the default behavior and return the overridden metadata.
// This event is triggered before [AnimeEpisodeMetadataEvent].
// If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned.
type AnimeEpisodeMetadataRequestedEvent struct {
hook_resolver.Event
// Empty metadata object, will be used if the hook prevents the default behavior
EpisodeMetadata *EpisodeMetadata `json:"animeEpisodeMetadata"`
EpisodeNumber int `json:"episodeNumber"`
MediaId int `json:"mediaId"`
}
// AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned.
// In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete.
// This event is triggered after [AnimeEpisodeMetadataRequestedEvent].
// If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned.
type AnimeEpisodeMetadataEvent struct {
hook_resolver.Event
EpisodeMetadata *EpisodeMetadata `json:"animeEpisodeMetadata"`
EpisodeNumber int `json:"episodeNumber"`
MediaId int `json:"mediaId"`
}

View File

@@ -0,0 +1,18 @@
package metadata
import (
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
"github.com/stretchr/testify/require"
)
func GetMockProvider(t *testing.T) Provider {
filecacher, err := filecache.NewCacher(t.TempDir())
require.NoError(t, err)
return NewProvider(&NewProviderImplOptions{
Logger: util.NewLogger(),
FileCacher: filecacher,
})
}

View File

@@ -0,0 +1,212 @@
package metadata
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/animap"
"seanime/internal/hook"
"seanime/internal/util/filecache"
"seanime/internal/util/result"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/samber/mo"
"golang.org/x/sync/singleflight"
)
type (
ProviderImpl struct {
logger *zerolog.Logger
fileCacher *filecache.Cacher
animeMetadataCache *result.BoundedCache[string, *AnimeMetadata]
singleflight *singleflight.Group
}
NewProviderImplOptions struct {
Logger *zerolog.Logger
FileCacher *filecache.Cacher
}
)
func GetAnimeMetadataCacheKey(platform Platform, mId int) string {
return fmt.Sprintf("%s$%d", platform, mId)
}
// NewProvider creates a new metadata provider.
func NewProvider(options *NewProviderImplOptions) Provider {
return &ProviderImpl{
logger: options.Logger,
fileCacher: options.FileCacher,
animeMetadataCache: result.NewBoundedCache[string, *AnimeMetadata](100),
singleflight: &singleflight.Group{},
}
}
// GetCache returns the anime metadata cache.
func (p *ProviderImpl) GetCache() *result.BoundedCache[string, *AnimeMetadata] {
return p.animeMetadataCache
}
// GetAnimeMetadata fetches anime metadata from api.ani.zip.
func (p *ProviderImpl) GetAnimeMetadata(platform Platform, mId int) (ret *AnimeMetadata, err error) {
cacheKey := GetAnimeMetadataCacheKey(platform, mId)
if cached, ok := p.animeMetadataCache.Get(cacheKey); ok {
return cached, nil
}
res, err, _ := p.singleflight.Do(cacheKey, func() (interface{}, error) {
return p.fetchAnimeMetadata(platform, mId)
})
if err != nil {
return nil, err
}
return res.(*AnimeMetadata), nil
}
func (p *ProviderImpl) fetchAnimeMetadata(platform Platform, mId int) (*AnimeMetadata, error) {
ret := &AnimeMetadata{
Titles: make(map[string]string),
Episodes: make(map[string]*EpisodeMetadata),
EpisodeCount: 0,
SpecialCount: 0,
Mappings: &AnimeMappings{},
}
// Invoke AnimeMetadataRequested hook
reqEvent := &AnimeMetadataRequestedEvent{
MediaId: mId,
AnimeMetadata: ret,
}
err := hook.GlobalHookManager.OnAnimeMetadataRequested().Trigger(reqEvent)
if err != nil {
return nil, err
}
mId = reqEvent.MediaId
// Default prevented by hook, return the metadata
if reqEvent.DefaultPrevented {
// Override the metadata
ret = reqEvent.AnimeMetadata
// Trigger the event
event := &AnimeMetadataEvent{
MediaId: mId,
AnimeMetadata: ret,
}
err = hook.GlobalHookManager.OnAnimeMetadata().Trigger(event)
if err != nil {
return nil, err
}
ret = event.AnimeMetadata
mId = event.MediaId
if ret == nil {
return nil, errors.New("no metadata was returned")
}
p.animeMetadataCache.SetT(GetAnimeMetadataCacheKey(platform, mId), ret, 1*time.Hour)
return ret, nil
}
m, err := animap.FetchAnimapMedia(string(platform), mId)
if err != nil || m == nil {
//return p.AnizipFallback(platform, mId)
return nil, err
}
ret.Titles = m.Titles
ret.EpisodeCount = 0
ret.SpecialCount = 0
ret.Mappings.AnimeplanetId = m.Mappings.AnimePlanetID
ret.Mappings.KitsuId = m.Mappings.KitsuID
ret.Mappings.MalId = m.Mappings.MalID
ret.Mappings.Type = m.Mappings.Type
ret.Mappings.AnilistId = m.Mappings.AnilistID
ret.Mappings.AnisearchId = m.Mappings.AnisearchID
ret.Mappings.AnidbId = m.Mappings.AnidbID
ret.Mappings.NotifymoeId = m.Mappings.NotifyMoeID
ret.Mappings.LivechartId = m.Mappings.LivechartID
ret.Mappings.ThetvdbId = m.Mappings.TheTvdbID
ret.Mappings.ImdbId = ""
ret.Mappings.ThemoviedbId = m.Mappings.TheMovieDbID
for key, ep := range m.Episodes {
firstChar := key[0]
if firstChar == 'S' {
ret.SpecialCount++
} else {
if firstChar >= '0' && firstChar <= '9' {
ret.EpisodeCount++
}
}
em := &EpisodeMetadata{
AnidbId: ep.AnidbId,
TvdbId: ep.TvdbId,
Title: ep.AnidbTitle,
Image: ep.Image,
AirDate: ep.AirDate,
Length: ep.Runtime,
Summary: strings.ReplaceAll(ep.Overview, "`", "'"),
Overview: strings.ReplaceAll(ep.Overview, "`", "'"),
EpisodeNumber: ep.Number,
Episode: key,
SeasonNumber: ep.SeasonNumber,
AbsoluteEpisodeNumber: ep.AbsoluteNumber,
AnidbEid: ep.AnidbId,
HasImage: ep.Image != "",
}
if em.Length == 0 && ep.Runtime > 0 {
em.Length = ep.Runtime
}
if em.Summary == "" && ep.Overview != "" {
em.Summary = ep.Overview
}
if em.Overview == "" && ep.Overview != "" {
em.Overview = ep.Overview
}
if ep.TvdbTitle != "" && ep.AnidbTitle == "Episode "+ep.AnidbEpisode {
em.Title = ep.TvdbTitle
}
ret.Episodes[key] = em
}
// Event
event := &AnimeMetadataEvent{
MediaId: mId,
AnimeMetadata: ret,
}
err = hook.GlobalHookManager.OnAnimeMetadata().Trigger(event)
if err != nil {
return nil, err
}
ret = event.AnimeMetadata
mId = event.MediaId
p.animeMetadataCache.SetT(GetAnimeMetadataCacheKey(platform, mId), ret, 1*time.Hour)
return ret, nil
}
// GetAnimeMetadataWrapper creates a new anime wrapper.
//
// Example:
//
// metadataProvider.GetAnimeMetadataWrapper(media, metadata)
// metadataProvider.GetAnimeMetadataWrapper(media, nil)
func (p *ProviderImpl) GetAnimeMetadataWrapper(media *anilist.BaseAnime, metadata *AnimeMetadata) AnimeMetadataWrapper {
aw := &AnimeWrapperImpl{
metadata: mo.None[*AnimeMetadata](),
baseAnime: media,
fileCacher: p.fileCacher,
logger: p.logger,
}
if metadata != nil {
aw.metadata = mo.Some(metadata)
}
return aw
}

View File

@@ -0,0 +1,165 @@
package metadata
import (
"seanime/internal/api/anilist"
"seanime/internal/util/result"
"strings"
"time"
)
const (
AnilistPlatform Platform = "anilist"
MalPlatform Platform = "mal"
)
type (
Platform string
Provider interface {
// GetAnimeMetadata fetches anime metadata for the given platform from a source.
// In this case, the source is api.ani.zip.
GetAnimeMetadata(platform Platform, mId int) (*AnimeMetadata, error)
GetCache() *result.BoundedCache[string, *AnimeMetadata]
// GetAnimeMetadataWrapper creates a wrapper for anime metadata.
GetAnimeMetadataWrapper(anime *anilist.BaseAnime, metadata *AnimeMetadata) AnimeMetadataWrapper
}
// AnimeMetadataWrapper is a container for anime metadata.
// This wrapper is used to get a more complete metadata object by getting data from multiple sources in the Provider.
// The user can request metadata to be fetched from TVDB as well, which will be stored in the cache.
AnimeMetadataWrapper interface {
// GetEpisodeMetadata combines metadata from multiple sources to create a single EpisodeMetadata object.
GetEpisodeMetadata(episodeNumber int) EpisodeMetadata
}
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type (
AnimeMetadata struct {
Titles map[string]string `json:"titles"`
Episodes map[string]*EpisodeMetadata `json:"episodes"`
EpisodeCount int `json:"episodeCount"`
SpecialCount int `json:"specialCount"`
Mappings *AnimeMappings `json:"mappings"`
currentEpisodeCount int `json:"-"`
}
AnimeMappings struct {
AnimeplanetId string `json:"animeplanetId"`
KitsuId int `json:"kitsuId"`
MalId int `json:"malId"`
Type string `json:"type"`
AnilistId int `json:"anilistId"`
AnisearchId int `json:"anisearchId"`
AnidbId int `json:"anidbId"`
NotifymoeId string `json:"notifymoeId"`
LivechartId int `json:"livechartId"`
ThetvdbId int `json:"thetvdbId"`
ImdbId string `json:"imdbId"`
ThemoviedbId string `json:"themoviedbId"`
}
EpisodeMetadata struct {
AnidbId int `json:"anidbId"`
TvdbId int `json:"tvdbId"`
Title string `json:"title"`
Image string `json:"image"`
AirDate string `json:"airDate"`
Length int `json:"length"`
Summary string `json:"summary"`
Overview string `json:"overview"`
EpisodeNumber int `json:"episodeNumber"`
Episode string `json:"episode"`
SeasonNumber int `json:"seasonNumber"`
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"`
AnidbEid int `json:"anidbEid"`
HasImage bool `json:"hasImage"` // Indicates if the episode has a real image
}
)
func (m *AnimeMetadata) GetTitle() string {
if m == nil {
return ""
}
if len(m.Titles["en"]) > 0 {
return m.Titles["en"]
}
return m.Titles["ro"]
}
func (m *AnimeMetadata) GetMappings() *AnimeMappings {
if m == nil {
return &AnimeMappings{}
}
return m.Mappings
}
func (m *AnimeMetadata) FindEpisode(ep string) (*EpisodeMetadata, bool) {
if m.Episodes == nil {
return nil, false
}
episode, found := m.Episodes[ep]
if !found {
return nil, false
}
return episode, true
}
func (m *AnimeMetadata) GetMainEpisodeCount() int {
if m == nil {
return 0
}
return m.EpisodeCount
}
func (m *AnimeMetadata) GetCurrentEpisodeCount() int {
if m == nil {
return 0
}
if m.currentEpisodeCount > 0 {
return m.currentEpisodeCount
}
count := 0
for _, ep := range m.Episodes {
firstChar := ep.Episode[0]
if firstChar >= '0' && firstChar <= '9' {
// Check if aired
if ep.AirDate != "" {
date, err := time.Parse("2006-01-02", ep.AirDate)
if err == nil {
if date.Before(time.Now()) || date.Equal(time.Now()) {
count++
}
}
}
}
}
m.currentEpisodeCount = count
return count
}
// GetOffset returns the offset of the first episode relative to the absolute episode number.
// e.g, if the first episode's absolute number is 13, then the offset is 12.
func (m *AnimeMetadata) GetOffset() int {
if m == nil {
return 0
}
firstEp, found := m.FindEpisode("1")
if !found {
return 0
}
if firstEp.AbsoluteEpisodeNumber == 0 {
return 0
}
return firstEp.AbsoluteEpisodeNumber - 1
}
func (e *EpisodeMetadata) GetTitle() string {
if e == nil {
return ""
}
return strings.ReplaceAll(e.Title, "`", "'")
}