package handlers import ( "errors" "fmt" "seanime/internal/api/anilist" "seanime/internal/util/result" "strconv" "time" "github.com/labstack/echo/v4" ) // HandleGetAnimeCollection // // @summary returns the user's AniList anime collection. // @desc Calling GET will return the cached anime collection. // @desc The manga collection is also refreshed in the background, and upon completion, a WebSocket event is sent. // @desc Calling POST will refetch both the anime and manga collections. // @returns anilist.AnimeCollection // @route /api/v1/anilist/collection [GET,POST] func (h *Handler) HandleGetAnimeCollection(c echo.Context) error { bypassCache := c.Request().Method == "POST" if !bypassCache { // Get the user's anilist collection animeCollection, err := h.App.GetAnimeCollection(false) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, animeCollection) } animeCollection, err := h.App.RefreshAnimeCollection() if err != nil { return h.RespondWithError(c, err) } go func() { if h.App.Settings != nil && h.App.Settings.GetLibrary().EnableManga { _, _ = h.App.RefreshMangaCollection() } }() return h.RespondWithData(c, animeCollection) } // HandleGetRawAnimeCollection // // @summary returns the user's AniList anime collection without filtering out custom lists. // @desc Calling GET will return the cached anime collection. // @returns anilist.AnimeCollection // @route /api/v1/anilist/collection/raw [GET,POST] func (h *Handler) HandleGetRawAnimeCollection(c echo.Context) error { bypassCache := c.Request().Method == "POST" // Get the user's anilist collection animeCollection, err := h.App.GetRawAnimeCollection(bypassCache) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, animeCollection) } // HandleEditAnilistListEntry // // @summary updates the user's list entry on Anilist. // @desc This is used to edit an entry on AniList. // @desc The "type" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly. // @desc The client should refetch collection-dependent queries after this mutation. // @returns true // @route /api/v1/anilist/list-entry [POST] func (h *Handler) HandleEditAnilistListEntry(c echo.Context) error { type body struct { MediaId *int `json:"mediaId"` Status *anilist.MediaListStatus `json:"status"` Score *int `json:"score"` Progress *int `json:"progress"` StartDate *anilist.FuzzyDateInput `json:"startedAt"` EndDate *anilist.FuzzyDateInput `json:"completedAt"` Type string `json:"type"` } p := new(body) if err := c.Bind(p); err != nil { return h.RespondWithError(c, err) } err := h.App.AnilistPlatform.UpdateEntry( c.Request().Context(), *p.MediaId, p.Status, p.Score, p.Progress, p.StartDate, p.EndDate, ) if err != nil { return h.RespondWithError(c, err) } switch p.Type { case "anime": _, _ = h.App.RefreshAnimeCollection() case "manga": _, _ = h.App.RefreshMangaCollection() default: _, _ = h.App.RefreshAnimeCollection() _, _ = h.App.RefreshMangaCollection() } return h.RespondWithData(c, true) } //---------------------------------------------------------------------------------------------------------------------------------------------------- var ( detailsCache = result.NewCache[int, *anilist.AnimeDetailsById_Media]() ) // HandleGetAnilistAnimeDetails // // @summary returns more details about an AniList anime entry. // @desc This fetches more fields omitted from the base queries. // @param id - int - true - "The AniList anime ID" // @returns anilist.AnimeDetailsById_Media // @route /api/v1/anilist/media-details/{id} [GET] func (h *Handler) HandleGetAnilistAnimeDetails(c echo.Context) error { mId, err := strconv.Atoi(c.Param("id")) if err != nil { return h.RespondWithError(c, err) } if details, ok := detailsCache.Get(mId); ok { return h.RespondWithData(c, details) } details, err := h.App.AnilistPlatform.GetAnimeDetails(c.Request().Context(), mId) if err != nil { return h.RespondWithError(c, err) } detailsCache.Set(mId, details) return h.RespondWithData(c, details) } //---------------------------------------------------------------------------------------------------------------------------------------------------- var studioDetailsMap = result.NewResultMap[int, *anilist.StudioDetails]() // HandleGetAnilistStudioDetails // // @summary returns details about a studio. // @desc This fetches media produced by the studio. // @param id - int - true - "The AniList studio ID" // @returns anilist.StudioDetails // @route /api/v1/anilist/studio-details/{id} [GET] func (h *Handler) HandleGetAnilistStudioDetails(c echo.Context) error { mId, err := strconv.Atoi(c.Param("id")) if err != nil { return h.RespondWithError(c, err) } if details, ok := studioDetailsMap.Get(mId); ok { return h.RespondWithData(c, details) } details, err := h.App.AnilistPlatform.GetStudioDetails(c.Request().Context(), mId) if err != nil { return h.RespondWithError(c, err) } go func() { if details != nil { studioDetailsMap.Set(mId, details) } }() return h.RespondWithData(c, details) } //---------------------------------------------------------------------------------------------------------------------------------------------------- // HandleDeleteAnilistListEntry // // @summary deletes an entry from the user's AniList list. // @desc This is used to delete an entry on AniList. // @desc The "type" field is used to determine if the entry is an anime or manga and refreshes the collection accordingly. // @desc The client should refetch collection-dependent queries after this mutation. // @route /api/v1/anilist/list-entry [DELETE] // @returns bool func (h *Handler) HandleDeleteAnilistListEntry(c echo.Context) error { type body struct { MediaId *int `json:"mediaId"` Type *string `json:"type"` } p := new(body) if err := c.Bind(p); err != nil { return h.RespondWithError(c, err) } if p.Type == nil || p.MediaId == nil { return h.RespondWithError(c, errors.New("missing parameters")) } var listEntryID int switch *p.Type { case "anime": // Get the list entry ID animeCollection, err := h.App.GetAnimeCollection(false) if err != nil { return h.RespondWithError(c, err) } listEntry, found := animeCollection.GetListEntryFromAnimeId(*p.MediaId) if !found { return h.RespondWithError(c, errors.New("list entry not found")) } listEntryID = listEntry.ID case "manga": // Get the list entry ID mangaCollection, err := h.App.GetMangaCollection(false) if err != nil { return h.RespondWithError(c, err) } listEntry, found := mangaCollection.GetListEntryFromMangaId(*p.MediaId) if !found { return h.RespondWithError(c, errors.New("list entry not found")) } listEntryID = listEntry.ID } // Delete the list entry err := h.App.AnilistPlatform.DeleteEntry(c.Request().Context(), listEntryID) if err != nil { return h.RespondWithError(c, err) } switch *p.Type { case "anime": _, _ = h.App.RefreshAnimeCollection() case "manga": _, _ = h.App.RefreshMangaCollection() } return h.RespondWithData(c, true) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var ( anilistListAnimeCache = result.NewCache[string, *anilist.ListAnime]() anilistListRecentAnimeCache = result.NewCache[string, *anilist.ListRecentAnime]() // holds 1 value ) // HandleAnilistListAnime // // @summary returns a list of anime based on the search parameters. // @desc This is used by the "Discover" and "Advanced Search". // @route /api/v1/anilist/list-anime [POST] // @returns anilist.ListAnime func (h *Handler) HandleAnilistListAnime(c echo.Context) error { type body struct { Page *int `json:"page,omitempty"` Search *string `json:"search,omitempty"` PerPage *int `json:"perPage,omitempty"` Sort []*anilist.MediaSort `json:"sort,omitempty"` Status []*anilist.MediaStatus `json:"status,omitempty"` Genres []*string `json:"genres,omitempty"` AverageScoreGreater *int `json:"averageScore_greater,omitempty"` Season *anilist.MediaSeason `json:"season,omitempty"` SeasonYear *int `json:"seasonYear,omitempty"` Format *anilist.MediaFormat `json:"format,omitempty"` IsAdult *bool `json:"isAdult,omitempty"` } p := new(body) if err := c.Bind(p); err != nil { return h.RespondWithError(c, err) } if p.Page == nil || p.PerPage == nil { *p.Page = 1 *p.PerPage = 20 } isAdult := false if p.IsAdult != nil { isAdult = *p.IsAdult && h.App.Settings.GetAnilist().EnableAdultContent } cacheKey := anilist.ListAnimeCacheKey( p.Page, p.Search, p.PerPage, p.Sort, p.Status, p.Genres, p.AverageScoreGreater, p.Season, p.SeasonYear, p.Format, &isAdult, ) cached, ok := anilistListAnimeCache.Get(cacheKey) if ok { return h.RespondWithData(c, cached) } ret, err := anilist.ListAnimeM( p.Page, p.Search, p.PerPage, p.Sort, p.Status, p.Genres, p.AverageScoreGreater, p.Season, p.SeasonYear, p.Format, &isAdult, h.App.Logger, h.App.GetUserAnilistToken(), ) if err != nil { return h.RespondWithError(c, err) } if ret != nil { anilistListAnimeCache.SetT(cacheKey, ret, time.Minute*10) } return h.RespondWithData(c, ret) } // HandleAnilistListRecentAiringAnime // // @summary returns a list of recently aired anime. // @desc This is used by the "Schedule" page to display recently aired anime. // @route /api/v1/anilist/list-recent-anime [POST] // @returns anilist.ListRecentAnime func (h *Handler) HandleAnilistListRecentAiringAnime(c echo.Context) error { type body struct { Page *int `json:"page,omitempty"` Search *string `json:"search,omitempty"` PerPage *int `json:"perPage,omitempty"` AiringAtGreater *int `json:"airingAt_greater,omitempty"` AiringAtLesser *int `json:"airingAt_lesser,omitempty"` NotYetAired *bool `json:"notYetAired,omitempty"` Sort []*anilist.AiringSort `json:"sort,omitempty"` } p := new(body) if err := c.Bind(p); err != nil { return h.RespondWithError(c, err) } if p.Page == nil || p.PerPage == nil { *p.Page = 1 *p.PerPage = 50 } cacheKey := fmt.Sprintf("%v-%v-%v-%v-%v-%v", p.Page, p.Search, p.PerPage, p.AiringAtGreater, p.AiringAtLesser, p.NotYetAired) cached, ok := anilistListRecentAnimeCache.Get(cacheKey) if ok { return h.RespondWithData(c, cached) } ret, err := anilist.ListRecentAiringAnimeM( p.Page, p.Search, p.PerPage, p.AiringAtGreater, p.AiringAtLesser, p.NotYetAired, p.Sort, h.App.Logger, h.App.GetUserAnilistToken(), ) if err != nil { return h.RespondWithError(c, err) } anilistListRecentAnimeCache.SetT(cacheKey, ret, time.Hour*1) return h.RespondWithData(c, ret) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var anilistMissedSequelsCache = result.NewCache[int, []*anilist.BaseAnime]() // HandleAnilistListMissedSequels // // @summary returns a list of sequels not in the user's list. // @desc This is used by the "Discover" page to display sequels the user may have missed. // @route /api/v1/anilist/list-missed-sequels [GET] // @returns []anilist.BaseAnime func (h *Handler) HandleAnilistListMissedSequels(c echo.Context) error { cached, ok := anilistMissedSequelsCache.Get(1) if ok { return h.RespondWithData(c, cached) } // Get complete anime collection animeCollection, err := h.App.AnilistPlatform.GetAnimeCollectionWithRelations(c.Request().Context()) if err != nil { return h.RespondWithError(c, err) } ret, err := anilist.ListMissedSequels( animeCollection, h.App.Logger, h.App.GetUserAnilistToken(), ) if err != nil { return h.RespondWithError(c, err) } anilistMissedSequelsCache.SetT(1, ret, time.Hour*4) return h.RespondWithData(c, ret) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var anilistStatsCache = result.NewCache[int, *anilist.Stats]() // HandleGetAniListStats // // @summary returns the anilist stats. // @desc This returns the AniList stats for the user. // @route /api/v1/anilist/stats [GET] // @returns anilist.Stats func (h *Handler) HandleGetAniListStats(c echo.Context) error { cached, ok := anilistStatsCache.Get(0) if ok { return h.RespondWithData(c, cached) } stats, err := h.App.AnilistPlatform.GetViewerStats(c.Request().Context()) if err != nil { return h.RespondWithError(c, err) } ret, err := anilist.GetStats( c.Request().Context(), stats, ) if err != nil { return h.RespondWithError(c, err) } anilistStatsCache.SetT(0, ret, time.Hour*1) return h.RespondWithData(c, ret) }