package handlers import ( "errors" "net/http" "net/url" "seanime/internal/api/anilist" "seanime/internal/extension" "seanime/internal/manga" manga_providers "seanime/internal/manga/providers" "seanime/internal/util/result" "strconv" "strings" "time" "github.com/labstack/echo/v4" ) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var ( baseMangaCache = result.NewCache[int, *anilist.BaseManga]() mangaDetailsCache = result.NewCache[int, *anilist.MangaDetailsById_Media]() ) // HandleGetAnilistMangaCollection // // @summary returns the user's AniList manga collection. // @route /api/v1/manga/anilist/collection [GET] // @returns anilist.MangaCollection func (h *Handler) HandleGetAnilistMangaCollection(c echo.Context) error { type body struct { BypassCache bool `json:"bypassCache"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } collection, err := h.App.GetMangaCollection(b.BypassCache) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, collection) } // HandleGetRawAnilistMangaCollection // // @summary returns the user's AniList manga collection. // @route /api/v1/manga/anilist/collection/raw [GET,POST] // @returns anilist.MangaCollection func (h *Handler) HandleGetRawAnilistMangaCollection(c echo.Context) error { bypassCache := c.Request().Method == "POST" // Get the user's anilist collection mangaCollection, err := h.App.GetRawMangaCollection(bypassCache) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, mangaCollection) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // HandleGetMangaCollection // // @summary returns the user's main manga collection. // @desc This is an object that contains all the user's manga entries in a structured format. // @route /api/v1/manga/collection [GET] // @returns manga.Collection func (h *Handler) HandleGetMangaCollection(c echo.Context) error { animeCollection, err := h.App.GetMangaCollection(false) if err != nil { return h.RespondWithError(c, err) } collection, err := manga.NewCollection(&manga.NewCollectionOptions{ MangaCollection: animeCollection, Platform: h.App.AnilistPlatform, }) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, collection) } // HandleGetMangaEntry // // @summary returns a manga entry for the given AniList manga id. // @desc This is used by the manga media entry pages to get all the data about the anime. It includes metadata and AniList list data. // @route /api/v1/manga/entry/{id} [GET] // @param id - int - true - "AniList manga media ID" // @returns manga.Entry func (h *Handler) HandleGetMangaEntry(c echo.Context) error { id, err := strconv.Atoi(c.Param("id")) if err != nil { return h.RespondWithError(c, err) } animeCollection, err := h.App.GetMangaCollection(false) if err != nil { return h.RespondWithError(c, err) } entry, err := manga.NewEntry(c.Request().Context(), &manga.NewEntryOptions{ MediaId: id, Logger: h.App.Logger, FileCacher: h.App.FileCacher, Platform: h.App.AnilistPlatform, MangaCollection: animeCollection, }) if err != nil { return h.RespondWithError(c, err) } if entry != nil { baseMangaCache.SetT(entry.MediaId, entry.Media, 1*time.Hour) } return h.RespondWithData(c, entry) } // HandleGetMangaEntryDetails // // @summary returns more details about an AniList manga entry. // @desc This fetches more fields omitted from the base queries. // @route /api/v1/manga/entry/{id}/details [GET] // @param id - int - true - "AniList manga media ID" // @returns anilist.MangaDetailsById_Media func (h *Handler) HandleGetMangaEntryDetails(c echo.Context) error { id, err := strconv.Atoi(c.Param("id")) if err != nil { return h.RespondWithError(c, err) } if detailsMedia, found := mangaDetailsCache.Get(id); found { return h.RespondWithData(c, detailsMedia) } details, err := h.App.AnilistPlatform.GetMangaDetails(c.Request().Context(), id) if err != nil { return h.RespondWithError(c, err) } mangaDetailsCache.SetT(id, details, 1*time.Hour) return h.RespondWithData(c, details) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // HandleGetMangaLatestChapterNumbersMap // // @summary returns the latest chapter number for all manga entries. // @route /api/v1/manga/latest-chapter-numbers [GET] // @returns map[int][]manga.MangaLatestChapterNumberItem func (h *Handler) HandleGetMangaLatestChapterNumbersMap(c echo.Context) error { ret, err := h.App.MangaRepository.GetMangaLatestChapterNumbersMap() if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, ret) } // HandleRefetchMangaChapterContainers // // @summary refetches the chapter containers for all manga entries previously cached. // @route /api/v1/manga/refetch-chapter-containers [POST] // @returns bool func (h *Handler) HandleRefetchMangaChapterContainers(c echo.Context) error { type body struct { SelectedProviderMap map[int]string `json:"selectedProviderMap"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } mangaCollection, err := h.App.GetMangaCollection(false) if err != nil { return h.RespondWithError(c, err) } err = h.App.MangaRepository.RefreshChapterContainers(mangaCollection, b.SelectedProviderMap) if err != nil { return h.RespondWithError(c, err) } return nil } // HandleEmptyMangaEntryCache // // @summary empties the cache for a manga entry. // @desc This will empty the cache for a manga entry (chapter lists and pages), allowing the client to fetch fresh data. // @desc HandleGetMangaEntryChapters should be called after this to fetch the new chapter list. // @desc Returns 'true' if the operation was successful. // @route /api/v1/manga/entry/cache [DELETE] // @returns bool func (h *Handler) HandleEmptyMangaEntryCache(c echo.Context) error { type body struct { MediaId int `json:"mediaId"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } err := h.App.MangaRepository.EmptyMangaCache(b.MediaId) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, true) } // HandleGetMangaEntryChapters // // @summary returns the chapters for a manga entry based on the provider. // @route /api/v1/manga/chapters [POST] // @returns manga.ChapterContainer func (h *Handler) HandleGetMangaEntryChapters(c echo.Context) error { type body struct { MediaId int `json:"mediaId"` Provider string `json:"provider"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } var titles []*string baseManga, found := baseMangaCache.Get(b.MediaId) if !found { var err error baseManga, err = h.App.AnilistPlatform.GetManga(c.Request().Context(), b.MediaId) if err != nil { return h.RespondWithError(c, err) } titles = baseManga.GetAllTitles() baseMangaCache.SetT(b.MediaId, baseManga, 24*time.Hour) } else { titles = baseManga.GetAllTitles() } container, err := h.App.MangaRepository.GetMangaChapterContainer(&manga.GetMangaChapterContainerOptions{ Provider: b.Provider, MediaId: b.MediaId, Titles: titles, Year: baseManga.GetStartYearSafe(), }) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, container) } // HandleGetMangaEntryPages // // @summary returns the pages for a manga entry based on the provider and chapter id. // @desc This will return the pages for a manga chapter. // @desc If the app is offline and the chapter is not downloaded, it will return an error. // @desc If the app is online and the chapter is not downloaded, it will return the pages from the provider. // @desc If the chapter is downloaded, it will return the appropriate struct. // @desc If 'double page' is requested, it will fetch image sizes and include the dimensions in the response. // @route /api/v1/manga/pages [POST] // @returns manga.PageContainer func (h *Handler) HandleGetMangaEntryPages(c echo.Context) error { type body struct { MediaId int `json:"mediaId"` Provider string `json:"provider"` ChapterId string `json:"chapterId"` DoublePage bool `json:"doublePage"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } container, err := h.App.MangaRepository.GetMangaPageContainer(b.Provider, b.MediaId, b.ChapterId, b.DoublePage, h.App.IsOffline()) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, container) } // HandleGetMangaEntryDownloadedChapters // // @summary returns all download chapters for a manga entry, // @route /api/v1/manga/downloaded-chapters/{id} [GET] // @param id - int - true - "AniList manga media ID" // @returns []manga.ChapterContainer func (h *Handler) HandleGetMangaEntryDownloadedChapters(c echo.Context) error { mId, err := strconv.Atoi(c.Param("id")) if err != nil { return h.RespondWithError(c, err) } mangaCollection, err := h.App.GetMangaCollection(false) if err != nil { return h.RespondWithError(c, err) } container, err := h.App.MangaRepository.GetDownloadedMangaChapterContainers(mId, mangaCollection) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, container) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// var ( anilistListMangaCache = result.NewCache[string, *anilist.ListManga]() ) // HandleAnilistListManga // // @summary returns a list of manga based on the search parameters. // @desc This is used by "Advanced Search" and search function. // @route /api/v1/manga/anilist/list [POST] // @returns anilist.ListManga func (h *Handler) HandleAnilistListManga(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"` Year *int `json:"year,omitempty"` CountryOfOrigin *string `json:"countryOfOrigin,omitempty"` IsAdult *bool `json:"isAdult,omitempty"` Format *anilist.MediaFormat `json:"format,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.ListMangaCacheKey( p.Page, p.Search, p.PerPage, p.Sort, p.Status, p.Genres, p.AverageScoreGreater, nil, p.Year, p.Format, p.CountryOfOrigin, &isAdult, ) cached, ok := anilistListMangaCache.Get(cacheKey) if ok { return h.RespondWithData(c, cached) } ret, err := anilist.ListMangaM( p.Page, p.Search, p.PerPage, p.Sort, p.Status, p.Genres, p.AverageScoreGreater, p.Year, p.Format, p.CountryOfOrigin, &isAdult, h.App.Logger, h.App.GetUserAnilistToken(), ) if err != nil { return h.RespondWithError(c, err) } if ret != nil { anilistListMangaCache.SetT(cacheKey, ret, time.Minute*10) } return h.RespondWithData(c, ret) } // HandleUpdateMangaProgress // // @summary updates the progress of a manga entry. // @desc Note: MyAnimeList is not supported // @route /api/v1/manga/update-progress [POST] // @returns bool func (h *Handler) HandleUpdateMangaProgress(c echo.Context) error { type body struct { MediaId int `json:"mediaId"` MalId int `json:"malId,omitempty"` ChapterNumber int `json:"chapterNumber"` TotalChapters int `json:"totalChapters"` } b := new(body) if err := c.Bind(b); err != nil { return h.RespondWithError(c, err) } // Update the progress on AniList err := h.App.AnilistPlatform.UpdateEntryProgress( c.Request().Context(), b.MediaId, b.ChapterNumber, &b.TotalChapters, ) if err != nil { return h.RespondWithError(c, err) } _, _ = h.App.RefreshMangaCollection() // Refresh the AniList collection return h.RespondWithData(c, true) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // HandleMangaManualSearch // // @summary returns search results for a manual search. // @desc Returns search results for a manual search. // @route /api/v1/manga/search [POST] // @returns []hibikemanga.SearchResult func (h *Handler) HandleMangaManualSearch(c echo.Context) error { type body struct { Provider string `json:"provider"` Query string `json:"query"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } ret, err := h.App.MangaRepository.ManualSearch(b.Provider, b.Query) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, ret) } // HandleMangaManualMapping // // @summary manually maps a manga entry to a manga ID from the provider. // @desc This is used to manually map a manga entry to a manga ID from the provider. // @desc The client should re-fetch the chapter container after this. // @route /api/v1/manga/manual-mapping [POST] // @returns bool func (h *Handler) HandleMangaManualMapping(c echo.Context) error { type body struct { Provider string `json:"provider"` MediaId int `json:"mediaId"` MangaId string `json:"mangaId"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } err := h.App.MangaRepository.ManualMapping(b.Provider, b.MediaId, b.MangaId) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, true) } // HandleGetMangaMapping // // @summary returns the mapping for a manga entry. // @desc This is used to get the mapping for a manga entry. // @desc An empty string is returned if there's no manual mapping. If there is, the manga ID will be returned. // @route /api/v1/manga/get-mapping [POST] // @returns manga.MappingResponse func (h *Handler) HandleGetMangaMapping(c echo.Context) error { type body struct { Provider string `json:"provider"` MediaId int `json:"mediaId"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } mapping := h.App.MangaRepository.GetMapping(b.Provider, b.MediaId) return h.RespondWithData(c, mapping) } // HandleRemoveMangaMapping // // @summary removes the mapping for a manga entry. // @desc This is used to remove the mapping for a manga entry. // @desc The client should re-fetch the chapter container after this. // @route /api/v1/manga/remove-mapping [POST] // @returns bool func (h *Handler) HandleRemoveMangaMapping(c echo.Context) error { type body struct { Provider string `json:"provider"` MediaId int `json:"mediaId"` } var b body if err := c.Bind(&b); err != nil { return h.RespondWithError(c, err) } err := h.App.MangaRepository.RemoveMapping(b.Provider, b.MediaId) if err != nil { return h.RespondWithError(c, err) } return h.RespondWithData(c, true) } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // HandleGetLocalMangaPage // // @summary returns a local manga page. // @route /api/v1/manga/local-page/{path} [GET] // @returns manga.PageContainer func (h *Handler) HandleGetLocalMangaPage(c echo.Context) error { path := c.Param("path") path, err := url.PathUnescape(path) if err != nil { return h.RespondWithError(c, err) } path = strings.TrimPrefix(path, manga_providers.LocalServePath) providerExtension, ok := extension.GetExtension[extension.MangaProviderExtension](h.App.ExtensionRepository.GetExtensionBank(), manga_providers.LocalProvider) if !ok { return h.RespondWithError(c, errors.New("manga: Local provider not found")) } localProvider, ok := providerExtension.GetProvider().(*manga_providers.Local) if !ok { return h.RespondWithError(c, errors.New("manga: Local provider not found")) } reader, err := localProvider.ReadPage(path) if err != nil { return h.RespondWithError(c, err) } return c.Stream(http.StatusOK, "image/jpeg", reader) }