471 lines
13 KiB
Go
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)
|
|
}
|