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

471 lines
13 KiB
Go

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)
}