node build fixed
This commit is contained in:
470
seanime-2.9.10/internal/handlers/anilist.go
Normal file
470
seanime-2.9.10/internal/handlers/anilist.go
Normal file
@@ -0,0 +1,470 @@
|
||||
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)
|
||||
}
|
||||
46
seanime-2.9.10/internal/handlers/anime.go
Normal file
46
seanime-2.9.10/internal/handlers/anime.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/library/anime"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetAnimeEpisodeCollection
|
||||
//
|
||||
// @summary gets list of main episodes
|
||||
// @desc This returns a list of main episodes for the given AniList anime media id.
|
||||
// @desc It also loads the episode list into the different modules.
|
||||
// @returns anime.EpisodeCollection
|
||||
// @param id - int - true - "AniList anime media ID"
|
||||
// @route /api/v1/anime/episode-collection/{id} [GET]
|
||||
func (h *Handler) HandleGetAnimeEpisodeCollection(c echo.Context) error {
|
||||
mId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.AddOnRefreshAnilistCollectionFunc("HandleGetAnimeEpisodeCollection", func() {
|
||||
anime.ClearEpisodeCollectionCache()
|
||||
})
|
||||
|
||||
completeAnime, animeMetadata, err := h.App.TorrentstreamRepository.GetMediaInfo(c.Request().Context(), mId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
ec, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{
|
||||
AnimeMetadata: animeMetadata,
|
||||
Media: completeAnime.ToBaseAnime(),
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
Logger: h.App.Logger,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.FillerManager.HydrateEpisodeFillerData(mId, ec.Episodes)
|
||||
|
||||
return h.RespondWithData(c, ec)
|
||||
}
|
||||
212
seanime-2.9.10/internal/handlers/anime_collection.go
Normal file
212
seanime-2.9.10/internal/handlers/anime_collection.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/torrentstream"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetLibraryCollection
|
||||
//
|
||||
// @summary returns the main local anime collection.
|
||||
// @desc This creates a new LibraryCollection struct and returns it.
|
||||
// @desc This is used to get the main anime collection of the user.
|
||||
// @desc It uses the cached Anilist anime collection for the GET method.
|
||||
// @desc It refreshes the AniList anime collection if the POST method is used.
|
||||
// @route /api/v1/library/collection [GET,POST]
|
||||
// @returns anime.LibraryCollection
|
||||
func (h *Handler) HandleGetLibraryCollection(c echo.Context) error {
|
||||
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if animeCollection == nil {
|
||||
return h.RespondWithData(c, &anime.LibraryCollection{})
|
||||
}
|
||||
|
||||
originalAnimeCollection := animeCollection
|
||||
|
||||
var lfs []*anime.LocalFile
|
||||
nakamaLibrary, fromNakama := h.App.NakamaManager.GetHostAnimeLibrary()
|
||||
if fromNakama {
|
||||
// Save the original anime collection to restore it later
|
||||
originalAnimeCollection = animeCollection.Copy()
|
||||
lfs = nakamaLibrary.LocalFiles
|
||||
// Merge missing media entries into the collection
|
||||
currentMediaIds := make(map[int]struct{})
|
||||
for _, list := range animeCollection.MediaListCollection.GetLists() {
|
||||
for _, entry := range list.GetEntries() {
|
||||
currentMediaIds[entry.GetMedia().GetID()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
nakamaMediaIds := make(map[int]struct{})
|
||||
for _, lf := range lfs {
|
||||
if lf.MediaId > 0 {
|
||||
nakamaMediaIds[lf.MediaId] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
missingMediaIds := make(map[int]struct{})
|
||||
for _, lf := range lfs {
|
||||
if lf.MediaId > 0 {
|
||||
if _, ok := currentMediaIds[lf.MediaId]; !ok {
|
||||
missingMediaIds[lf.MediaId] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, list := range nakamaLibrary.AnimeCollection.MediaListCollection.GetLists() {
|
||||
for _, entry := range list.GetEntries() {
|
||||
if _, ok := missingMediaIds[entry.GetMedia().GetID()]; ok {
|
||||
// create a new entry with blank list data
|
||||
newEntry := &anilist.AnimeListEntry{
|
||||
ID: entry.GetID(),
|
||||
Media: entry.GetMedia(),
|
||||
Status: &[]anilist.MediaListStatus{anilist.MediaListStatusPlanning}[0],
|
||||
}
|
||||
animeCollection.MediaListCollection.AddEntryToList(newEntry, anilist.MediaListStatusPlanning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
lfs, _, err = db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
LocalFiles: lfs,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Restore the original anime collection if it was modified
|
||||
if fromNakama {
|
||||
*animeCollection = *originalAnimeCollection
|
||||
}
|
||||
|
||||
if !fromNakama {
|
||||
if (h.App.SecondarySettings.Torrentstream != nil && h.App.SecondarySettings.Torrentstream.Enabled && h.App.SecondarySettings.Torrentstream.IncludeInLibrary) ||
|
||||
(h.App.Settings.GetLibrary() != nil && h.App.Settings.GetLibrary().EnableOnlinestream && h.App.Settings.GetLibrary().IncludeOnlineStreamingInLibrary) ||
|
||||
(h.App.SecondarySettings.Debrid != nil && h.App.SecondarySettings.Debrid.Enabled && h.App.SecondarySettings.Debrid.IncludeDebridStreamInLibrary) {
|
||||
h.App.TorrentstreamRepository.HydrateStreamCollection(&torrentstream.HydrateStreamCollectionOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
LibraryCollection: libraryCollection,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add and remove necessary metadata when hydrating from Nakama
|
||||
if fromNakama {
|
||||
for _, ep := range libraryCollection.ContinueWatchingList {
|
||||
ep.IsNakamaEpisode = true
|
||||
}
|
||||
for _, list := range libraryCollection.Lists {
|
||||
for _, entry := range list.Entries {
|
||||
if entry.EntryLibraryData == nil {
|
||||
continue
|
||||
}
|
||||
entry.NakamaEntryLibraryData = &anime.NakamaEntryLibraryData{
|
||||
UnwatchedCount: entry.EntryLibraryData.UnwatchedCount,
|
||||
MainFileCount: entry.EntryLibraryData.MainFileCount,
|
||||
}
|
||||
entry.EntryLibraryData = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate total library size
|
||||
if libraryCollection != nil && libraryCollection.Stats != nil {
|
||||
libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, libraryCollection)
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
var animeScheduleCache = result.NewCache[int, []*anime.ScheduleItem]()
|
||||
|
||||
// HandleGetAnimeCollectionSchedule
|
||||
//
|
||||
// @summary returns anime collection schedule
|
||||
// @desc This is used by the "Schedule" page to display the anime schedule.
|
||||
// @route /api/v1/library/schedule [GET]
|
||||
// @returns []anime.ScheduleItem
|
||||
func (h *Handler) HandleGetAnimeCollectionSchedule(c echo.Context) error {
|
||||
|
||||
// Invalidate the cache when the Anilist collection is refreshed
|
||||
h.App.AddOnRefreshAnilistCollectionFunc("HandleGetAnimeCollectionSchedule", func() {
|
||||
animeScheduleCache.Clear()
|
||||
})
|
||||
|
||||
if ret, ok := animeScheduleCache.Get(1); ok {
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
animeSchedule, err := h.App.AnilistPlatform.GetAnimeAiringSchedule(c.Request().Context())
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
ret := anime.GetScheduleItems(animeSchedule, animeCollection)
|
||||
|
||||
animeScheduleCache.SetT(1, ret, 1*time.Hour)
|
||||
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
// HandleAddUnknownMedia
|
||||
//
|
||||
// @summary adds the given media to the user's AniList planning collections
|
||||
// @desc Since media not found in the user's AniList collection are not displayed in the library, this route is used to add them.
|
||||
// @desc The response is ignored in the frontend, the client should just refetch the entire library collection.
|
||||
// @route /api/v1/library/unknown-media [POST]
|
||||
// @returns anilist.AnimeCollection
|
||||
func (h *Handler) HandleAddUnknownMedia(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaIds []int `json:"mediaIds"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Add non-added media entries to AniList collection
|
||||
if err := h.App.AnilistPlatform.AddMediaToCollection(c.Request().Context(), b.MediaIds); err != nil {
|
||||
return h.RespondWithError(c, errors.New("error: Anilist responded with an error, this is most likely a rate limit issue"))
|
||||
}
|
||||
|
||||
// Bypass the cache
|
||||
animeCollection, err := h.App.GetAnimeCollection(true)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, errors.New("error: Anilist responded with an error, wait one minute before refreshing"))
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, animeCollection)
|
||||
|
||||
}
|
||||
630
seanime-2.9.10/internal/handlers/anime_entries.go
Normal file
630
seanime-2.9.10/internal/handlers/anime_entries.go
Normal file
@@ -0,0 +1,630 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/library/scanner"
|
||||
"seanime/internal/library/summary"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/limiter"
|
||||
"seanime/internal/util/result"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/lo"
|
||||
lop "github.com/samber/lo/parallel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleGetAnimeEntry
|
||||
//
|
||||
// @summary return a media entry for the given AniList anime media id.
|
||||
// @desc This is used by the anime media entry pages to get all the data about the anime.
|
||||
// @desc This includes episodes and metadata (if any), AniList list data, download info...
|
||||
// @route /api/v1/library/anime-entry/{id} [GET]
|
||||
// @param id - int - true - "AniList anime media ID"
|
||||
// @returns anime.Entry
|
||||
func (h *Handler) HandleGetAnimeEntry(c echo.Context) error {
|
||||
|
||||
mId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get the host anime library files
|
||||
nakamaLfs, hydratedFromNakama := h.App.NakamaManager.GetHostAnimeLibraryFiles(mId)
|
||||
if hydratedFromNakama && nakamaLfs != nil {
|
||||
lfs = nakamaLfs
|
||||
}
|
||||
|
||||
// Get the user's anilist collection
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if animeCollection == nil {
|
||||
return h.RespondWithError(c, errors.New("anime collection not found"))
|
||||
}
|
||||
|
||||
// Create a new media entry
|
||||
entry, err := anime.NewEntry(c.Request().Context(), &anime.NewEntryOptions{
|
||||
MediaId: mId,
|
||||
LocalFiles: lfs,
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
IsSimulated: h.App.GetUser().IsSimulated,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
fillerEvent := new(anime.AnimeEntryFillerHydrationEvent)
|
||||
fillerEvent.Entry = entry
|
||||
err = hook.GlobalHookManager.OnAnimeEntryFillerHydration().Trigger(fillerEvent)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
entry = fillerEvent.Entry
|
||||
|
||||
if !fillerEvent.DefaultPrevented {
|
||||
h.App.FillerManager.HydrateFillerData(fillerEvent.Entry)
|
||||
}
|
||||
|
||||
if hydratedFromNakama {
|
||||
entry.IsNakamaEntry = true
|
||||
for _, ep := range entry.Episodes {
|
||||
ep.IsNakamaEpisode = true
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, entry)
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// HandleAnimeEntryBulkAction
|
||||
//
|
||||
// @summary perform given action on all the local files for the given media id.
|
||||
// @desc This is used to unmatch or toggle the lock status of all the local files for a specific media entry
|
||||
// @desc The response is not used in the frontend. The client should just refetch the entire media entry data.
|
||||
// @route /api/v1/library/anime-entry/bulk-action [PATCH]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleAnimeEntryBulkAction(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Action string `json:"action"` // "unmatch" or "toggle-lock"
|
||||
}
|
||||
|
||||
p := new(body)
|
||||
if err := c.Bind(p); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Group local files by media id
|
||||
groupedLfs := anime.GroupLocalFilesByMediaID(lfs)
|
||||
|
||||
selectLfs, ok := groupedLfs[p.MediaId]
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("no local files found for media id"))
|
||||
}
|
||||
|
||||
switch p.Action {
|
||||
case "unmatch":
|
||||
lfs = lop.Map(lfs, func(item *anime.LocalFile, _ int) *anime.LocalFile {
|
||||
if item.MediaId == p.MediaId && p.MediaId != 0 {
|
||||
item.MediaId = 0
|
||||
item.Locked = false
|
||||
item.Ignored = false
|
||||
}
|
||||
return item
|
||||
})
|
||||
case "toggle-lock":
|
||||
// Flip the locked status of all the local files for the given media
|
||||
allLocked := lo.EveryBy(selectLfs, func(item *anime.LocalFile) bool { return item.Locked })
|
||||
lfs = lop.Map(lfs, func(item *anime.LocalFile, _ int) *anime.LocalFile {
|
||||
if item.MediaId == p.MediaId && p.MediaId != 0 {
|
||||
item.Locked = !allLocked
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
// Save the local files
|
||||
retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, retLfs)
|
||||
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// HandleOpenAnimeEntryInExplorer
|
||||
//
|
||||
// @summary opens the directory of a media entry in the file explorer.
|
||||
// @desc This finds a common directory for all media entry local files and opens it in the file explorer.
|
||||
// @desc Returns 'true' whether the operation was successful or not, errors are ignored.
|
||||
// @route /api/v1/library/anime-entry/open-in-explorer [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleOpenAnimeEntryInExplorer(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
}
|
||||
|
||||
p := new(body)
|
||||
if err := c.Bind(p); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
lf, found := lo.Find(lfs, func(i *anime.LocalFile) bool {
|
||||
return i.MediaId == p.MediaId
|
||||
})
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("local file not found"))
|
||||
}
|
||||
|
||||
dir := filepath.Dir(lf.GetNormalizedPath())
|
||||
cmd := ""
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "explorer"
|
||||
wPath := strings.ReplaceAll(strings.ToLower(dir), "/", "\\")
|
||||
args = []string{wPath}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{dir}
|
||||
case "linux":
|
||||
cmd = "xdg-open"
|
||||
args = []string{dir}
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
cmdObj := util.NewCmd(cmd, args...)
|
||||
cmdObj.Stdout = os.Stdout
|
||||
cmdObj.Stderr = os.Stderr
|
||||
_ = cmdObj.Run()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
entriesSuggestionsCache = result.NewCache[string, []*anilist.BaseAnime]()
|
||||
)
|
||||
|
||||
// HandleFetchAnimeEntrySuggestions
|
||||
//
|
||||
// @summary returns a list of media suggestions for files in the given directory.
|
||||
// @desc This is used by the "Resolve unmatched media" feature to suggest media entries for the local files in the given directory.
|
||||
// @desc If some matches files are found in the directory, it will ignore them and base the suggestions on the remaining files.
|
||||
// @route /api/v1/library/anime-entry/suggestions [POST]
|
||||
// @returns []anilist.BaseAnime
|
||||
func (h *Handler) HandleFetchAnimeEntrySuggestions(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
b.Dir = strings.ToLower(b.Dir)
|
||||
|
||||
suggestions, found := entriesSuggestionsCache.Get(b.Dir)
|
||||
if found {
|
||||
return h.RespondWithData(c, suggestions)
|
||||
}
|
||||
|
||||
// Retrieve local files
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Group local files by dir
|
||||
groupedLfs := lop.GroupBy(lfs, func(item *anime.LocalFile) string {
|
||||
return filepath.Dir(item.GetNormalizedPath())
|
||||
})
|
||||
|
||||
selectedLfs, found := groupedLfs[b.Dir]
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("no local files found for selected directory"))
|
||||
}
|
||||
|
||||
// Filter out local files that are already matched
|
||||
selectedLfs = lo.Filter(selectedLfs, func(item *anime.LocalFile, _ int) bool {
|
||||
return item.MediaId == 0
|
||||
})
|
||||
|
||||
title := selectedLfs[0].GetParsedTitle()
|
||||
|
||||
h.App.Logger.Info().Str("title", title).Msg("handlers: Fetching anime suggestions")
|
||||
|
||||
res, err := anilist.ListAnimeM(
|
||||
lo.ToPtr(1),
|
||||
&title,
|
||||
lo.ToPtr(8),
|
||||
nil,
|
||||
[]*anilist.MediaStatus{lo.ToPtr(anilist.MediaStatusFinished), lo.ToPtr(anilist.MediaStatusReleasing), lo.ToPtr(anilist.MediaStatusCancelled), lo.ToPtr(anilist.MediaStatusHiatus)},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
h.App.Logger,
|
||||
h.App.GetUserAnilistToken(),
|
||||
)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
entriesSuggestionsCache.Set(b.Dir, res.GetPage().GetMedia())
|
||||
|
||||
return h.RespondWithData(c, res.GetPage().GetMedia())
|
||||
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// HandleAnimeEntryManualMatch
|
||||
//
|
||||
// @summary matches un-matched local files in the given directory to the given media.
|
||||
// @desc It is used by the "Resolve unmatched media" feature to manually match local files to a specific media entry.
|
||||
// @desc Matching involves the use of scanner.FileHydrator. It will also lock the files.
|
||||
// @desc The response is not used in the frontend. The client should just refetch the entire library collection.
|
||||
// @route /api/v1/library/anime-entry/manual-match [POST]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleAnimeEntryManualMatch(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Paths []string `json:"paths"`
|
||||
MediaId int `json:"mediaId"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
animeCollectionWithRelations, err := h.App.AnilistPlatform.GetAnimeCollectionWithRelations(c.Request().Context())
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Retrieve local files
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
compPaths := make(map[string]struct{})
|
||||
for _, p := range b.Paths {
|
||||
compPaths[util.NormalizePath(p)] = struct{}{}
|
||||
}
|
||||
|
||||
selectedLfs := lo.Filter(lfs, func(item *anime.LocalFile, _ int) bool {
|
||||
_, found := compPaths[item.GetNormalizedPath()]
|
||||
return found && item.MediaId == 0
|
||||
})
|
||||
|
||||
// Add the media id to the selected local files
|
||||
// Also, lock the files
|
||||
selectedLfs = lop.Map(selectedLfs, func(item *anime.LocalFile, _ int) *anime.LocalFile {
|
||||
item.MediaId = b.MediaId
|
||||
item.Locked = true
|
||||
item.Ignored = false
|
||||
return item
|
||||
})
|
||||
|
||||
// Get the media
|
||||
media, err := h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Create a slice of normalized media
|
||||
normalizedMedia := []*anime.NormalizedMedia{
|
||||
anime.NewNormalizedMedia(media),
|
||||
}
|
||||
|
||||
scanLogger, err := scanner.NewScanLogger(h.App.Config.Logs.Dir)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Create scan summary logger
|
||||
scanSummaryLogger := summary.NewScanSummaryLogger()
|
||||
|
||||
fh := scanner.FileHydrator{
|
||||
LocalFiles: selectedLfs,
|
||||
CompleteAnimeCache: anilist.NewCompleteAnimeCache(),
|
||||
Platform: h.App.AnilistPlatform,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
AnilistRateLimiter: limiter.NewAnilistLimiter(),
|
||||
Logger: h.App.Logger,
|
||||
ScanLogger: scanLogger,
|
||||
ScanSummaryLogger: scanSummaryLogger,
|
||||
AllMedia: normalizedMedia,
|
||||
ForceMediaId: media.GetID(),
|
||||
}
|
||||
|
||||
fh.HydrateMetadata()
|
||||
|
||||
// Hydrate the summary logger before merging files
|
||||
fh.ScanSummaryLogger.HydrateData(selectedLfs, normalizedMedia, animeCollectionWithRelations)
|
||||
|
||||
// Save the scan summary
|
||||
go func() {
|
||||
err = db_bridge.InsertScanSummary(h.App.Database, scanSummaryLogger.GenerateSummary())
|
||||
}()
|
||||
|
||||
// Remove select local files from the database slice, we will add them (hydrated) later
|
||||
selectedPaths := lop.Map(selectedLfs, func(item *anime.LocalFile, _ int) string { return item.GetNormalizedPath() })
|
||||
lfs = lo.Filter(lfs, func(item *anime.LocalFile, _ int) bool {
|
||||
if slices.Contains(selectedPaths, item.GetNormalizedPath()) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Event
|
||||
event := new(anime.AnimeEntryManualMatchBeforeSaveEvent)
|
||||
event.MediaId = b.MediaId
|
||||
event.Paths = b.Paths
|
||||
event.MatchedLocalFiles = selectedLfs
|
||||
err = hook.GlobalHookManager.OnAnimeEntryManualMatchBeforeSave().Trigger(event)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, fmt.Errorf("OnAnimeEntryManualMatchBeforeSave: %w", err))
|
||||
}
|
||||
|
||||
// Default prevented, do not save the local files
|
||||
if event.DefaultPrevented {
|
||||
return h.RespondWithData(c, lfs)
|
||||
}
|
||||
|
||||
// Add the hydrated local files to the slice
|
||||
lfs = append(lfs, event.MatchedLocalFiles...)
|
||||
|
||||
// Update the local files
|
||||
retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, retLfs)
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
var missingEpisodesCache *anime.MissingEpisodes
|
||||
|
||||
// HandleGetMissingEpisodes
|
||||
//
|
||||
// @summary returns a list of episodes missing from the user's library collection
|
||||
// @desc It detects missing episodes by comparing the user's AniList collection 'next airing' data with the local files.
|
||||
// @desc This route can be called multiple times, as it does not bypass the cache.
|
||||
// @route /api/v1/library/missing-episodes [GET]
|
||||
// @returns anime.MissingEpisodes
|
||||
func (h *Handler) HandleGetMissingEpisodes(c echo.Context) error {
|
||||
h.App.AddOnRefreshAnilistCollectionFunc("HandleGetMissingEpisodes", func() {
|
||||
missingEpisodesCache = nil
|
||||
})
|
||||
|
||||
if missingEpisodesCache != nil {
|
||||
return h.RespondWithData(c, missingEpisodesCache)
|
||||
}
|
||||
|
||||
// Get the user's anilist collection
|
||||
// Do not bypass the cache, since this handler might be called multiple times, and we don't want to spam the API
|
||||
// A cron job will refresh the cache every 10 minutes
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get the silenced media ids
|
||||
silencedMediaIds, _ := h.App.Database.GetSilencedMediaEntryIds()
|
||||
|
||||
missingEps := anime.NewMissingEpisodes(&anime.NewMissingEpisodesOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
LocalFiles: lfs,
|
||||
SilencedMediaIds: silencedMediaIds,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
})
|
||||
|
||||
event := new(anime.MissingEpisodesEvent)
|
||||
event.MissingEpisodes = missingEps
|
||||
err = hook.GlobalHookManager.OnMissingEpisodes().Trigger(event)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
missingEpisodesCache = event.MissingEpisodes
|
||||
|
||||
return h.RespondWithData(c, event.MissingEpisodes)
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// HandleGetAnimeEntrySilenceStatus
|
||||
//
|
||||
// @summary returns the silence status of a media entry.
|
||||
// @param id - int - true - "The ID of the media entry."
|
||||
// @route /api/v1/library/anime-entry/silence/{id} [GET]
|
||||
// @returns models.SilencedMediaEntry
|
||||
func (h *Handler) HandleGetAnimeEntrySilenceStatus(c echo.Context) error {
|
||||
mId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, errors.New("invalid id"))
|
||||
}
|
||||
|
||||
animeEntry, err := h.App.Database.GetSilencedMediaEntry(uint(mId))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return h.RespondWithData(c, false)
|
||||
} else {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, animeEntry)
|
||||
}
|
||||
|
||||
// HandleToggleAnimeEntrySilenceStatus
|
||||
//
|
||||
// @summary toggles the silence status of a media entry.
|
||||
// @desc The missing episodes should be re-fetched after this.
|
||||
// @route /api/v1/library/anime-entry/silence [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleToggleAnimeEntrySilenceStatus(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
animeEntry, err := h.App.Database.GetSilencedMediaEntry(uint(b.MediaId))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = h.App.Database.InsertSilencedMediaEntry(uint(b.MediaId))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
return h.RespondWithData(c, true)
|
||||
} else {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = h.App.Database.DeleteSilencedMediaEntry(animeEntry.ID)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// HandleUpdateAnimeEntryProgress
|
||||
//
|
||||
// @summary update the progress of the given anime media entry.
|
||||
// @desc This is used to update the progress of the given anime media entry on AniList.
|
||||
// @desc The response is not used in the frontend, the client should just refetch the entire media entry data.
|
||||
// @desc NOTE: This is currently only used by the 'Online streaming' feature since anime progress updates are handled by the Playback Manager.
|
||||
// @route /api/v1/library/anime-entry/update-progress [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUpdateAnimeEntryProgress(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
MalId int `json:"malId,omitempty"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
TotalEpisodes int `json:"totalEpisodes"`
|
||||
}
|
||||
|
||||
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.EpisodeNumber,
|
||||
&b.TotalEpisodes,
|
||||
)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
_, _ = h.App.RefreshAnimeCollection() // Refresh the AniList collection
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
// HandleUpdateAnimeEntryRepeat
|
||||
//
|
||||
// @summary update the repeat value of the given anime media entry.
|
||||
// @desc This is used to update the repeat value of the given anime media entry on AniList.
|
||||
// @desc The response is not used in the frontend, the client should just refetch the entire media entry data.
|
||||
// @route /api/v1/library/anime-entry/update-repeat [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUpdateAnimeEntryRepeat(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Repeat int `json:"repeat"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.AnilistPlatform.UpdateEntryRepeat(
|
||||
c.Request().Context(),
|
||||
b.MediaId,
|
||||
b.Repeat,
|
||||
)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
//_, _ = h.App.RefreshAnimeCollection() // Refresh the AniList collection
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
138
seanime-2.9.10/internal/handlers/auth.go
Normal file
138
seanime-2.9.10/internal/handlers/auth.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/platforms/simulated_platform"
|
||||
"seanime/internal/util"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleLogin
|
||||
//
|
||||
// @summary logs in the user by saving the JWT token in the database.
|
||||
// @desc This is called when the JWT token is obtained from AniList after logging in with redirection on the client.
|
||||
// @desc It also fetches the Viewer data from AniList and saves it in the database.
|
||||
// @desc It creates a new handlers.Status and refreshes App modules.
|
||||
// @route /api/v1/auth/login [POST]
|
||||
// @returns handlers.Status
|
||||
func (h *Handler) HandleLogin(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Set a new AniList client by passing to JWT token
|
||||
h.App.UpdateAnilistClientToken(b.Token)
|
||||
|
||||
// Get viewer data from AniList
|
||||
getViewer, err := h.App.AnilistClient.GetViewer(context.Background())
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Msg("Could not authenticate to AniList")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if len(getViewer.Viewer.Name) == 0 {
|
||||
return h.RespondWithError(c, errors.New("could not find user"))
|
||||
}
|
||||
|
||||
// Marshal viewer data
|
||||
bytes, err := json.Marshal(getViewer.Viewer)
|
||||
if err != nil {
|
||||
h.App.Logger.Err(err).Msg("scan: could not save local files")
|
||||
}
|
||||
|
||||
// Save account data in database
|
||||
_, err = h.App.Database.UpsertAccount(&models.Account{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: 1,
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Username: getViewer.Viewer.Name,
|
||||
Token: b.Token,
|
||||
Viewer: bytes,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.Logger.Info().Msg("app: Authenticated to AniList")
|
||||
|
||||
// Update the platform
|
||||
anilistPlatform := anilist_platform.NewAnilistPlatform(h.App.AnilistClient, h.App.Logger)
|
||||
h.App.UpdatePlatform(anilistPlatform)
|
||||
|
||||
// Create a new status
|
||||
status := h.NewStatus(c)
|
||||
|
||||
h.App.InitOrRefreshAnilistData()
|
||||
|
||||
h.App.InitOrRefreshModules()
|
||||
|
||||
go func() {
|
||||
defer util.HandlePanicThen(func() {})
|
||||
h.App.InitOrRefreshTorrentstreamSettings()
|
||||
h.App.InitOrRefreshMediastreamSettings()
|
||||
h.App.InitOrRefreshDebridSettings()
|
||||
}()
|
||||
|
||||
// Return new status
|
||||
return h.RespondWithData(c, status)
|
||||
|
||||
}
|
||||
|
||||
// HandleLogout
|
||||
//
|
||||
// @summary logs out the user by removing JWT token from the database.
|
||||
// @desc It removes JWT token and Viewer data from the database.
|
||||
// @desc It creates a new handlers.Status and refreshes App modules.
|
||||
// @route /api/v1/auth/logout [POST]
|
||||
// @returns handlers.Status
|
||||
func (h *Handler) HandleLogout(c echo.Context) error {
|
||||
|
||||
// Update the anilist client
|
||||
h.App.UpdateAnilistClientToken("")
|
||||
|
||||
// Update the platform
|
||||
simulatedPlatform, err := simulated_platform.NewSimulatedPlatform(h.App.LocalManager, h.App.AnilistClient, h.App.Logger)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
h.App.UpdatePlatform(simulatedPlatform)
|
||||
|
||||
_, err = h.App.Database.UpsertAccount(&models.Account{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: 1,
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Username: "",
|
||||
Token: "",
|
||||
Viewer: nil,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.Logger.Info().Msg("Logged out of AniList")
|
||||
|
||||
status := h.NewStatus(c)
|
||||
|
||||
h.App.InitOrRefreshModules()
|
||||
|
||||
h.App.InitOrRefreshAnilistData()
|
||||
|
||||
return h.RespondWithData(c, status)
|
||||
}
|
||||
233
seanime-2.9.10/internal/handlers/auto_downloader.go
Normal file
233
seanime-2.9.10/internal/handlers/auto_downloader.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/anime"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleRunAutoDownloader
|
||||
//
|
||||
// @summary tells the AutoDownloader to check for new episodes if enabled.
|
||||
// @desc This will run the AutoDownloader if it is enabled.
|
||||
// @desc It does nothing if the AutoDownloader is disabled.
|
||||
// @route /api/v1/auto-downloader/run [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleRunAutoDownloader(c echo.Context) error {
|
||||
|
||||
h.App.AutoDownloader.Run()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetAutoDownloaderRule
|
||||
//
|
||||
// @summary returns the rule with the given DB id.
|
||||
// @desc This is used to get a specific rule, useful for editing.
|
||||
// @route /api/v1/auto-downloader/rule/{id} [GET]
|
||||
// @param id - int - true - "The DB id of the rule"
|
||||
// @returns anime.AutoDownloaderRule
|
||||
func (h *Handler) HandleGetAutoDownloaderRule(c echo.Context) error {
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, errors.New("invalid id"))
|
||||
}
|
||||
|
||||
rule, err := db_bridge.GetAutoDownloaderRule(h.App.Database, uint(id))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, rule)
|
||||
}
|
||||
|
||||
// HandleGetAutoDownloaderRulesByAnime
|
||||
//
|
||||
// @summary returns the rules with the given media id.
|
||||
// @route /api/v1/auto-downloader/rule/anime/{id} [GET]
|
||||
// @param id - int - true - "The AniList anime id of the rules"
|
||||
// @returns []anime.AutoDownloaderRule
|
||||
func (h *Handler) HandleGetAutoDownloaderRulesByAnime(c echo.Context) error {
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, errors.New("invalid id"))
|
||||
}
|
||||
|
||||
rules := db_bridge.GetAutoDownloaderRulesByMediaId(h.App.Database, id)
|
||||
return h.RespondWithData(c, rules)
|
||||
}
|
||||
|
||||
// HandleGetAutoDownloaderRules
|
||||
//
|
||||
// @summary returns all rules.
|
||||
// @desc This is used to list all rules. It returns an empty slice if there are no rules.
|
||||
// @route /api/v1/auto-downloader/rules [GET]
|
||||
// @returns []anime.AutoDownloaderRule
|
||||
func (h *Handler) HandleGetAutoDownloaderRules(c echo.Context) error {
|
||||
rules, err := db_bridge.GetAutoDownloaderRules(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, rules)
|
||||
}
|
||||
|
||||
// HandleCreateAutoDownloaderRule
|
||||
//
|
||||
// @summary creates a new rule.
|
||||
// @desc The body should contain the same fields as entities.AutoDownloaderRule.
|
||||
// @desc It returns the created rule.
|
||||
// @route /api/v1/auto-downloader/rule [POST]
|
||||
// @returns anime.AutoDownloaderRule
|
||||
func (h *Handler) HandleCreateAutoDownloaderRule(c echo.Context) error {
|
||||
type body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MediaId int `json:"mediaId"`
|
||||
ReleaseGroups []string `json:"releaseGroups"`
|
||||
Resolutions []string `json:"resolutions"`
|
||||
AdditionalTerms []string `json:"additionalTerms"`
|
||||
ComparisonTitle string `json:"comparisonTitle"`
|
||||
TitleComparisonType anime.AutoDownloaderRuleTitleComparisonType `json:"titleComparisonType"`
|
||||
EpisodeType anime.AutoDownloaderRuleEpisodeType `json:"episodeType"`
|
||||
EpisodeNumbers []int `json:"episodeNumbers,omitempty"`
|
||||
Destination string `json:"destination"`
|
||||
}
|
||||
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.Destination == "" {
|
||||
return h.RespondWithError(c, errors.New("destination is required"))
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(b.Destination) {
|
||||
return h.RespondWithError(c, errors.New("destination must be an absolute path"))
|
||||
}
|
||||
|
||||
rule := &anime.AutoDownloaderRule{
|
||||
Enabled: b.Enabled,
|
||||
MediaId: b.MediaId,
|
||||
ReleaseGroups: b.ReleaseGroups,
|
||||
Resolutions: b.Resolutions,
|
||||
ComparisonTitle: b.ComparisonTitle,
|
||||
TitleComparisonType: b.TitleComparisonType,
|
||||
EpisodeType: b.EpisodeType,
|
||||
EpisodeNumbers: b.EpisodeNumbers,
|
||||
Destination: b.Destination,
|
||||
AdditionalTerms: b.AdditionalTerms,
|
||||
}
|
||||
|
||||
if err := db_bridge.InsertAutoDownloaderRule(h.App.Database, rule); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, rule)
|
||||
}
|
||||
|
||||
// HandleUpdateAutoDownloaderRule
|
||||
//
|
||||
// @summary updates a rule.
|
||||
// @desc The body should contain the same fields as entities.AutoDownloaderRule.
|
||||
// @desc It returns the updated rule.
|
||||
// @route /api/v1/auto-downloader/rule [PATCH]
|
||||
// @returns anime.AutoDownloaderRule
|
||||
func (h *Handler) HandleUpdateAutoDownloaderRule(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Rule *anime.AutoDownloaderRule `json:"rule"`
|
||||
}
|
||||
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.Rule == nil {
|
||||
return h.RespondWithError(c, errors.New("invalid rule"))
|
||||
}
|
||||
|
||||
if b.Rule.DbID == 0 {
|
||||
return h.RespondWithError(c, errors.New("invalid id"))
|
||||
}
|
||||
|
||||
// Update the rule based on its DbID (primary key)
|
||||
if err := db_bridge.UpdateAutoDownloaderRule(h.App.Database, b.Rule.DbID, b.Rule); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, b.Rule)
|
||||
}
|
||||
|
||||
// HandleDeleteAutoDownloaderRule
|
||||
//
|
||||
// @summary deletes a rule.
|
||||
// @desc It returns 'true' if the rule was deleted.
|
||||
// @route /api/v1/auto-downloader/rule/{id} [DELETE]
|
||||
// @param id - int - true - "The DB id of the rule"
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDeleteAutoDownloaderRule(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, errors.New("invalid id"))
|
||||
}
|
||||
|
||||
if err := db_bridge.DeleteAutoDownloaderRule(h.App.Database, uint(id)); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandleGetAutoDownloaderItems
|
||||
//
|
||||
// @summary returns all queued items.
|
||||
// @desc Queued items are episodes that are downloaded but not scanned or not yet downloaded.
|
||||
// @desc The AutoDownloader uses these items in order to not download the same episode twice.
|
||||
// @route /api/v1/auto-downloader/items [GET]
|
||||
// @returns []models.AutoDownloaderItem
|
||||
func (h *Handler) HandleGetAutoDownloaderItems(c echo.Context) error {
|
||||
rules, err := h.App.Database.GetAutoDownloaderItems()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, rules)
|
||||
}
|
||||
|
||||
// HandleDeleteAutoDownloaderItem
|
||||
//
|
||||
// @summary delete a queued item.
|
||||
// @desc This is used to remove a queued item from the list.
|
||||
// @desc Returns 'true' if the item was deleted.
|
||||
// @route /api/v1/auto-downloader/item [DELETE]
|
||||
// @param id - int - true - "The DB id of the item"
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDeleteAutoDownloaderItem(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if err := h.App.Database.DeleteAutoDownloaderItem(b.ID); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
74
seanime-2.9.10/internal/handlers/continuity.go
Normal file
74
seanime-2.9.10/internal/handlers/continuity.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/continuity"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleUpdateContinuityWatchHistoryItem
|
||||
//
|
||||
// @summary Updates watch history item.
|
||||
// @desc This endpoint is used to update a watch history item.
|
||||
// @desc Since this is low priority, we ignore any errors.
|
||||
// @route /api/v1/continuity/item [PATCH]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUpdateContinuityWatchHistoryItem(c echo.Context) error {
|
||||
type body struct {
|
||||
Options continuity.UpdateWatchHistoryItemOptions `json:"options"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.ContinuityManager.UpdateWatchHistoryItem(&b.Options)
|
||||
if err != nil {
|
||||
// Ignore the error
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetContinuityWatchHistoryItem
|
||||
//
|
||||
// @summary Returns a watch history item.
|
||||
// @desc This endpoint is used to retrieve a watch history item.
|
||||
// @route /api/v1/continuity/item/{id} [GET]
|
||||
// @param id - int - true - "AniList anime media ID"
|
||||
// @returns continuity.WatchHistoryItemResponse
|
||||
func (h *Handler) HandleGetContinuityWatchHistoryItem(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if !h.App.ContinuityManager.GetSettings().WatchContinuityEnabled {
|
||||
return h.RespondWithData(c, &continuity.WatchHistoryItemResponse{
|
||||
Item: nil,
|
||||
Found: false,
|
||||
})
|
||||
}
|
||||
|
||||
resp := h.App.ContinuityManager.GetWatchHistoryItem(id)
|
||||
return h.RespondWithData(c, resp)
|
||||
}
|
||||
|
||||
// HandleGetContinuityWatchHistory
|
||||
//
|
||||
// @summary Returns the continuity watch history
|
||||
// @desc This endpoint is used to retrieve all watch history items.
|
||||
// @route /api/v1/continuity/history [GET]
|
||||
// @returns continuity.WatchHistory
|
||||
func (h *Handler) HandleGetContinuityWatchHistory(c echo.Context) error {
|
||||
if !h.App.ContinuityManager.GetSettings().WatchContinuityEnabled {
|
||||
ret := make(map[int]*continuity.WatchHistoryItem)
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
resp := h.App.ContinuityManager.GetWatchHistory()
|
||||
return h.RespondWithData(c, resp)
|
||||
}
|
||||
404
seanime-2.9.10/internal/handlers/debrid.go
Normal file
404
seanime-2.9.10/internal/handlers/debrid.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/database/models"
|
||||
debrid_client "seanime/internal/debrid/client"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/events"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetDebridSettings
|
||||
//
|
||||
// @summary get debrid settings.
|
||||
// @desc This returns the debrid settings.
|
||||
// @returns models.DebridSettings
|
||||
// @route /api/v1/debrid/settings [GET]
|
||||
func (h *Handler) HandleGetDebridSettings(c echo.Context) error {
|
||||
debridSettings, found := h.App.Database.GetDebridSettings()
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("debrid settings not found"))
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, debridSettings)
|
||||
}
|
||||
|
||||
// HandleSaveDebridSettings
|
||||
//
|
||||
// @summary save debrid settings.
|
||||
// @desc This saves the debrid settings.
|
||||
// @desc The client should refetch the server status.
|
||||
// @returns models.DebridSettings
|
||||
// @route /api/v1/debrid/settings [PATCH]
|
||||
func (h *Handler) HandleSaveDebridSettings(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Settings models.DebridSettings `json:"settings"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
settings, err := h.App.Database.UpsertDebridSettings(&b.Settings)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.InitOrRefreshDebridSettings()
|
||||
|
||||
return h.RespondWithData(c, settings)
|
||||
}
|
||||
|
||||
// HandleDebridAddTorrents
|
||||
//
|
||||
// @summary add torrent to debrid.
|
||||
// @desc This adds a torrent to the debrid service.
|
||||
// @returns bool
|
||||
// @route /api/v1/debrid/torrents [POST]
|
||||
func (h *Handler) HandleDebridAddTorrents(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Torrents []hibiketorrent.AnimeTorrent `json:"torrents"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
Destination string `json:"destination"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if !h.App.DebridClientRepository.HasProvider() {
|
||||
return h.RespondWithError(c, errors.New("debrid provider not set"))
|
||||
}
|
||||
|
||||
for _, torrent := range b.Torrents {
|
||||
// Get the torrent's provider extension
|
||||
animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(torrent.Provider)
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("provider extension not found for torrent"))
|
||||
}
|
||||
|
||||
magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(&torrent)
|
||||
if err != nil {
|
||||
if len(b.Torrents) == 1 {
|
||||
return h.RespondWithError(c, err)
|
||||
} else {
|
||||
h.App.Logger.Err(err).Msg("debrid: Failed to get magnet link")
|
||||
h.App.WSEventManager.SendEvent(events.ErrorToast, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
torrent.MagnetLink = magnet
|
||||
|
||||
// Add the torrent to the debrid service
|
||||
_, err = h.App.DebridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: magnet,
|
||||
SelectFileId: "all",
|
||||
}, b.Destination, b.Media.ID)
|
||||
if err != nil {
|
||||
// If there is only one torrent, return the error
|
||||
if len(b.Torrents) == 1 {
|
||||
return h.RespondWithError(c, err)
|
||||
} else {
|
||||
// If there are multiple torrents, send an error toast and continue to the next torrent
|
||||
h.App.Logger.Err(err).Msg("debrid: Failed to add torrent to debrid")
|
||||
h.App.WSEventManager.SendEvent(events.ErrorToast, err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDebridDownloadTorrent
|
||||
//
|
||||
// @summary download torrent from debrid.
|
||||
// @desc Manually downloads a torrent from the debrid service locally.
|
||||
// @returns bool
|
||||
// @route /api/v1/debrid/torrents/download [POST]
|
||||
func (h *Handler) HandleDebridDownloadTorrent(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
TorrentItem debrid.TorrentItem `json:"torrentItem"`
|
||||
Destination string `json:"destination"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(b.Destination) {
|
||||
return h.RespondWithError(c, errors.New("destination must be an absolute path"))
|
||||
}
|
||||
|
||||
// Remove the torrent from the database
|
||||
// This is done so that the torrent is not downloaded automatically
|
||||
// We ignore the error here because the torrent might not be in the database
|
||||
_ = h.App.Database.DeleteDebridTorrentItemByTorrentItemId(b.TorrentItem.ID)
|
||||
|
||||
// Download the torrent locally
|
||||
err := h.App.DebridClientRepository.DownloadTorrent(b.TorrentItem, b.Destination)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDebridCancelDownload
|
||||
//
|
||||
// @summary cancel download from debrid.
|
||||
// @desc This cancels a download from the debrid service.
|
||||
// @returns bool
|
||||
// @route /api/v1/debrid/torrents/cancel [POST]
|
||||
func (h *Handler) HandleDebridCancelDownload(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
ItemID string `json:"itemID"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.DebridClientRepository.CancelDownload(b.ItemID)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDebridDeleteTorrent
|
||||
//
|
||||
// @summary remove torrent from debrid.
|
||||
// @desc This removes a torrent from the debrid service.
|
||||
// @returns bool
|
||||
// @route /api/v1/debrid/torrent [DELETE]
|
||||
func (h *Handler) HandleDebridDeleteTorrent(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
TorrentItem debrid.TorrentItem `json:"torrentItem"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
provider, err := h.App.DebridClientRepository.GetProvider()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err = provider.DeleteTorrent(b.TorrentItem.ID)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDebridGetTorrents
|
||||
//
|
||||
// @summary get torrents from debrid.
|
||||
// @desc This gets the torrents from the debrid service.
|
||||
// @returns []debrid.TorrentItem
|
||||
// @route /api/v1/debrid/torrents [GET]
|
||||
func (h *Handler) HandleDebridGetTorrents(c echo.Context) error {
|
||||
|
||||
provider, err := h.App.DebridClientRepository.GetProvider()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
torrents, err := provider.GetTorrents()
|
||||
if err != nil {
|
||||
h.App.Logger.Err(err).Msg("debrid: Failed to get torrents")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, torrents)
|
||||
}
|
||||
|
||||
// HandleDebridGetTorrentInfo
|
||||
//
|
||||
// @summary get torrent info from debrid.
|
||||
// @desc This gets the torrent info from the debrid service.
|
||||
// @returns debrid.TorrentInfo
|
||||
// @route /api/v1/debrid/torrents/info [POST]
|
||||
func (h *Handler) HandleDebridGetTorrentInfo(c echo.Context) error {
|
||||
type body struct {
|
||||
Torrent hibiketorrent.AnimeTorrent `json:"torrent"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(b.Torrent.Provider)
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("provider extension not found for torrent"))
|
||||
}
|
||||
|
||||
magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(&b.Torrent)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
b.Torrent.MagnetLink = magnet
|
||||
|
||||
torrentInfo, err := h.App.DebridClientRepository.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: b.Torrent.MagnetLink,
|
||||
InfoHash: b.Torrent.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, torrentInfo)
|
||||
}
|
||||
|
||||
// HandleDebridGetTorrentFilePreviews
|
||||
//
|
||||
// @summary get list of torrent files
|
||||
// @returns []debrid_client.FilePreview
|
||||
// @route /api/v1/debrid/torrents/file-previews [POST]
|
||||
func (h *Handler) HandleDebridGetTorrentFilePreviews(c echo.Context) error {
|
||||
type body struct {
|
||||
Torrent *hibiketorrent.AnimeTorrent `json:"torrent"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(b.Torrent.Provider)
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("provider extension not found for torrent"))
|
||||
}
|
||||
|
||||
magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(b.Torrent)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
b.Torrent.MagnetLink = magnet
|
||||
|
||||
// Get the media
|
||||
animeMetadata, _ := h.App.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, b.Media.ID)
|
||||
absoluteOffset := 0
|
||||
if animeMetadata != nil {
|
||||
absoluteOffset = animeMetadata.GetOffset()
|
||||
}
|
||||
|
||||
torrentInfo, err := h.App.DebridClientRepository.GetTorrentFilePreviewsFromManualSelection(&debrid_client.GetTorrentFilePreviewsOptions{
|
||||
Torrent: b.Torrent,
|
||||
Magnet: magnet,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
Media: b.Media,
|
||||
AbsoluteOffset: absoluteOffset,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, torrentInfo)
|
||||
}
|
||||
|
||||
// HandleDebridStartStream
|
||||
//
|
||||
// @summary start stream from debrid.
|
||||
// @desc This starts streaming a torrent from the debrid service.
|
||||
// @returns bool
|
||||
// @route /api/v1/debrid/stream/start [POST]
|
||||
func (h *Handler) HandleDebridStartStream(c echo.Context) error {
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
AniDBEpisode string `json:"aniDBEpisode"`
|
||||
AutoSelect bool `json:"autoSelect"`
|
||||
Torrent *hibiketorrent.AnimeTorrent `json:"torrent"`
|
||||
FileId string `json:"fileId"`
|
||||
FileIndex *int `json:"fileIndex"`
|
||||
PlaybackType debrid_client.StreamPlaybackType `json:"playbackType"` // "default" or "externalPlayerLink"
|
||||
ClientId string `json:"clientId"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
userAgent := c.Request().Header.Get("User-Agent")
|
||||
|
||||
if b.Torrent != nil {
|
||||
animeTorrentProviderExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(b.Torrent.Provider)
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("provider extension not found for torrent"))
|
||||
}
|
||||
|
||||
magnet, err := animeTorrentProviderExtension.GetProvider().GetTorrentMagnetLink(b.Torrent)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
b.Torrent.MagnetLink = magnet
|
||||
}
|
||||
|
||||
err := h.App.DebridClientRepository.StartStream(c.Request().Context(), &debrid_client.StartStreamOptions{
|
||||
MediaId: b.MediaId,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
AniDBEpisode: b.AniDBEpisode,
|
||||
Torrent: b.Torrent,
|
||||
FileId: b.FileId,
|
||||
FileIndex: b.FileIndex,
|
||||
UserAgent: userAgent,
|
||||
ClientId: b.ClientId,
|
||||
PlaybackType: b.PlaybackType,
|
||||
AutoSelect: b.AutoSelect,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDebridCancelStream
|
||||
//
|
||||
// @summary cancel stream from debrid.
|
||||
// @desc This cancels a stream from the debrid service.
|
||||
// @returns bool
|
||||
// @route /api/v1/debrid/stream/cancel [POST]
|
||||
func (h *Handler) HandleDebridCancelStream(c echo.Context) error {
|
||||
type body struct {
|
||||
Options *debrid_client.CancelStreamOptions `json:"options"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.DebridClientRepository.CancelStream(b.Options)
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
133
seanime-2.9.10/internal/handlers/directory_selector.go
Normal file
133
seanime-2.9.10/internal/handlers/directory_selector.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type DirectoryInfo struct {
|
||||
FullPath string `json:"fullPath"`
|
||||
FolderName string `json:"folderName"`
|
||||
}
|
||||
|
||||
type DirectorySelectorResponse struct {
|
||||
FullPath string `json:"fullPath"`
|
||||
Exists bool `json:"exists"`
|
||||
BasePath string `json:"basePath"`
|
||||
Suggestions []DirectoryInfo `json:"suggestions"`
|
||||
Content []DirectoryInfo `json:"content"`
|
||||
}
|
||||
|
||||
// HandleDirectorySelector
|
||||
//
|
||||
// @summary returns directory content based on the input path.
|
||||
// @desc This used by the directory selector component to get directory validation and suggestions.
|
||||
// @desc It returns subdirectories based on the input path.
|
||||
// @desc It returns 500 error if the directory does not exist (or cannot be accessed).
|
||||
// @route /api/v1/directory-selector [POST]
|
||||
// @returns handlers.DirectorySelectorResponse
|
||||
func (h *Handler) HandleDirectorySelector(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
var request body
|
||||
|
||||
if err := c.Bind(&request); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
input := filepath.ToSlash(filepath.Clean(request.Input))
|
||||
directoryExists, err := checkDirectoryExists(input)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if directoryExists {
|
||||
suggestions, err := getAutocompletionSuggestions(input)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
content, err := getDirectoryContent(input)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, DirectorySelectorResponse{
|
||||
FullPath: input,
|
||||
BasePath: filepath.ToSlash(filepath.Dir(input)),
|
||||
Exists: true,
|
||||
Suggestions: suggestions,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
suggestions, err := getAutocompletionSuggestions(input)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, DirectorySelectorResponse{
|
||||
FullPath: input,
|
||||
BasePath: filepath.ToSlash(filepath.Dir(input)),
|
||||
Exists: false,
|
||||
Suggestions: suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
func checkDirectoryExists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func getAutocompletionSuggestions(input string) ([]DirectoryInfo, error) {
|
||||
var suggestions []DirectoryInfo
|
||||
baseDir := filepath.Dir(input)
|
||||
prefix := filepath.Base(input)
|
||||
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(strings.ToLower(entry.Name()), strings.ToLower(prefix)) {
|
||||
suggestions = append(suggestions, DirectoryInfo{
|
||||
FullPath: filepath.Join(baseDir, entry.Name()),
|
||||
FolderName: entry.Name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func getDirectoryContent(path string) ([]DirectoryInfo, error) {
|
||||
var content []DirectoryInfo
|
||||
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
content = append(content, DirectoryInfo{
|
||||
FullPath: filepath.Join(path, entry.Name()),
|
||||
FolderName: entry.Name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
46
seanime-2.9.10/internal/handlers/directstream.go
Normal file
46
seanime-2.9.10/internal/handlers/directstream.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/directstream"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleDirectstreamPlayLocalFile
|
||||
//
|
||||
// @summary request local file stream.
|
||||
// @desc This requests a local file stream and returns the media container to start the playback.
|
||||
// @returns mediastream.MediaContainer
|
||||
// @route /api/v1/directstream/play/localfile [POST]
|
||||
func (h *Handler) HandleDirectstreamPlayLocalFile(c echo.Context) error {
|
||||
type body struct {
|
||||
Path string `json:"path"` // The path of the file.
|
||||
ClientId string `json:"clientId"` // The session id
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.App.DirectStreamManager.PlayLocalFile(c.Request().Context(), directstream.PlayLocalFileOptions{
|
||||
ClientId: b.ClientId,
|
||||
Path: b.Path,
|
||||
LocalFiles: lfs,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDirectstreamGetStream() http.Handler {
|
||||
return h.App.DirectStreamManager.ServeEchoStream()
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDirectstreamGetAttachments(c echo.Context) error {
|
||||
return h.App.DirectStreamManager.ServeEchoAttachments(c)
|
||||
}
|
||||
144
seanime-2.9.10/internal/handlers/discord.go
Normal file
144
seanime-2.9.10/internal/handlers/discord.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
discordrpc_presence "seanime/internal/discordrpc/presence"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleSetDiscordMangaActivity
|
||||
//
|
||||
// @summary sets manga activity for discord rich presence.
|
||||
// @route /api/v1/discord/presence/manga [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSetDiscordMangaActivity(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
Chapter string `json:"chapter"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body")
|
||||
return h.RespondWithData(c, false)
|
||||
}
|
||||
|
||||
h.App.DiscordPresence.SetMangaActivity(&discordrpc_presence.MangaActivity{
|
||||
ID: b.MediaId,
|
||||
Title: b.Title,
|
||||
Image: b.Image,
|
||||
Chapter: b.Chapter,
|
||||
})
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleSetDiscordLegacyAnimeActivity
|
||||
//
|
||||
// @summary sets anime activity for discord rich presence.
|
||||
// @route /api/v1/discord/presence/legacy-anime [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSetDiscordLegacyAnimeActivity(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
IsMovie bool `json:"isMovie"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body")
|
||||
return h.RespondWithData(c, false)
|
||||
}
|
||||
|
||||
h.App.DiscordPresence.LegacySetAnimeActivity(&discordrpc_presence.LegacyAnimeActivity{
|
||||
ID: b.MediaId,
|
||||
Title: b.Title,
|
||||
Image: b.Image,
|
||||
IsMovie: b.IsMovie,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
})
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleSetDiscordAnimeActivityWithProgress
|
||||
//
|
||||
// @summary sets anime activity for discord rich presence with progress.
|
||||
// @route /api/v1/discord/presence/anime [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSetDiscordAnimeActivityWithProgress(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Title string `json:"title"`
|
||||
Image string `json:"image"`
|
||||
IsMovie bool `json:"isMovie"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
TotalEpisodes *int `json:"totalEpisodes,omitempty"`
|
||||
CurrentEpisodeCount *int `json:"currentEpisodeCount,omitempty"`
|
||||
EpisodeTitle *string `json:"episodeTitle,omitempty"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body")
|
||||
return h.RespondWithData(c, false)
|
||||
}
|
||||
|
||||
h.App.DiscordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{
|
||||
ID: b.MediaId,
|
||||
Title: b.Title,
|
||||
Image: b.Image,
|
||||
IsMovie: b.IsMovie,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
Progress: b.Progress,
|
||||
Duration: b.Duration,
|
||||
TotalEpisodes: b.TotalEpisodes,
|
||||
CurrentEpisodeCount: b.CurrentEpisodeCount,
|
||||
EpisodeTitle: b.EpisodeTitle,
|
||||
})
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleUpdateDiscordAnimeActivityWithProgress
|
||||
//
|
||||
// @summary updates the anime activity for discord rich presence with progress.
|
||||
// @route /api/v1/discord/presence/anime-update [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUpdateDiscordAnimeActivityWithProgress(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("discord rpc handler: failed to parse request body")
|
||||
return h.RespondWithData(c, false)
|
||||
}
|
||||
|
||||
h.App.DiscordPresence.UpdateAnimeActivity(b.Progress, b.Duration, b.Paused)
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleCancelDiscordActivity
|
||||
//
|
||||
// @summary cancels the current discord rich presence activity.
|
||||
// @route /api/v1/discord/presence/cancel [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleCancelDiscordActivity(c echo.Context) error {
|
||||
h.App.DiscordPresence.Close()
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
98
seanime-2.9.10/internal/handlers/docs.go
Normal file
98
seanime-2.9.10/internal/handlers/docs.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type (
|
||||
ApiDocsGroup struct {
|
||||
Filename string `json:"filename"`
|
||||
Name string `json:"name"`
|
||||
Handlers []*RouteHandler `json:"handlers"`
|
||||
}
|
||||
|
||||
RouteHandler struct {
|
||||
Name string `json:"name"`
|
||||
TrimmedName string `json:"trimmedName"`
|
||||
Comments []string `json:"comments"`
|
||||
Filepath string `json:"filepath"`
|
||||
Filename string `json:"filename"`
|
||||
Api *RouteHandlerApi `json:"api"`
|
||||
}
|
||||
|
||||
RouteHandlerApi struct {
|
||||
Summary string `json:"summary"`
|
||||
Descriptions []string `json:"descriptions"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Methods []string `json:"methods"`
|
||||
Params []*RouteHandlerParam `json:"params"`
|
||||
BodyFields []*RouteHandlerParam `json:"bodyFields"`
|
||||
Returns string `json:"returns"`
|
||||
ReturnGoType string `json:"returnGoType"`
|
||||
ReturnTypescriptType string `json:"returnTypescriptType"`
|
||||
}
|
||||
|
||||
RouteHandlerParam struct {
|
||||
Name string `json:"name"`
|
||||
JsonName string `json:"jsonName"`
|
||||
GoType string `json:"goType"` // e.g., []models.User
|
||||
UsedStructType string `json:"usedStructType"` // e.g., models.User
|
||||
TypescriptType string `json:"typescriptType"` // e.g., Array<User>
|
||||
Required bool `json:"required"`
|
||||
Descriptions []string `json:"descriptions"`
|
||||
}
|
||||
)
|
||||
|
||||
var cachedDocs []*ApiDocsGroup
|
||||
|
||||
// HandleGetDocs
|
||||
//
|
||||
// @summary returns the API documentation
|
||||
// @route /api/v1/internal/docs [GET]
|
||||
// @returns []handlers.ApiDocsGroup
|
||||
func (h *Handler) HandleGetDocs(c echo.Context) error {
|
||||
|
||||
if len(cachedDocs) > 0 {
|
||||
return h.RespondWithData(c, cachedDocs)
|
||||
}
|
||||
|
||||
// Read the file
|
||||
wd, _ := os.Getwd()
|
||||
buf, err := os.ReadFile(filepath.Join(wd, "codegen/generated/handlers.json"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var data []*RouteHandler
|
||||
// Unmarshal the data
|
||||
err = json.Unmarshal(buf, &data)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Group the data
|
||||
groups := make(map[string]*ApiDocsGroup)
|
||||
for _, handler := range data {
|
||||
group, ok := groups[handler.Filename]
|
||||
if !ok {
|
||||
group = &ApiDocsGroup{
|
||||
Filename: handler.Filename,
|
||||
Name: strings.TrimPrefix(handler.Filename, ".go"),
|
||||
}
|
||||
groups[handler.Filename] = group
|
||||
}
|
||||
group.Handlers = append(group.Handlers, handler)
|
||||
}
|
||||
|
||||
cachedDocs = make([]*ApiDocsGroup, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
cachedDocs = append(cachedDocs, group)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, groups)
|
||||
}
|
||||
130
seanime-2.9.10/internal/handlers/download.go
Normal file
130
seanime-2.9.10/internal/handlers/download.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/updater"
|
||||
"seanime/internal/util"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleDownloadTorrentFile
|
||||
//
|
||||
// @summary downloads torrent files to the destination folder
|
||||
// @route /api/v1/download-torrent-file [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDownloadTorrentFile(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
DownloadUrls []string `json:"download_urls"`
|
||||
Destination string `json:"destination"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
errs := make([]error, 0)
|
||||
for _, url := range b.DownloadUrls {
|
||||
err := downloadTorrentFile(url, b.Destination)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) == 1 {
|
||||
return h.RespondWithError(c, errs[0])
|
||||
} else if len(errs) > 1 {
|
||||
return h.RespondWithError(c, errors.New("failed to download multiple files"))
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
func downloadTorrentFile(url string, dest string) (err error) {
|
||||
|
||||
defer util.HandlePanicInModuleWithError("handlers/download/downloadTorrentFile", &err)
|
||||
|
||||
// Get the file name from the URL
|
||||
fileName := filepath.Base(url)
|
||||
filePath := filepath.Join(dest, fileName)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check if the request was successful (status code 200)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download file, %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create the destination folder if it doesn't exist
|
||||
err = os.MkdirAll(dest, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type DownloadReleaseResponse struct {
|
||||
Destination string `json:"destination"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandleDownloadRelease
|
||||
//
|
||||
// @summary downloads selected release asset to the destination folder.
|
||||
// @desc Downloads the selected release asset to the destination folder and extracts it if possible.
|
||||
// @desc If the extraction fails, the error message will be returned in the successful response.
|
||||
// @desc The successful response will contain the destination path of the extracted files.
|
||||
// @desc It only returns an error if the download fails.
|
||||
// @route /api/v1/download-release [POST]
|
||||
// @returns handlers.DownloadReleaseResponse
|
||||
func (h *Handler) HandleDownloadRelease(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
DownloadUrl string `json:"download_url"`
|
||||
Destination string `json:"destination"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
path, err := h.App.Updater.DownloadLatestRelease(b.DownloadUrl, b.Destination)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, updater.ErrExtractionFailed) {
|
||||
return h.RespondWithData(c, DownloadReleaseResponse{Destination: path, Error: err.Error()})
|
||||
}
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, DownloadReleaseResponse{Destination: path})
|
||||
}
|
||||
12
seanime-2.9.10/internal/handlers/events_webview.go
Normal file
12
seanime-2.9.10/internal/handlers/events_webview.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import "seanime/internal/events"
|
||||
|
||||
func (h *Handler) HandleClientEvents(event *events.WebsocketClientEvent) {
|
||||
|
||||
//h.App.Logger.Debug().Msgf("ws: message received: %+v", event)
|
||||
|
||||
if h.App.WSEventManager != nil {
|
||||
h.App.WSEventManager.OnClientEvent(event)
|
||||
}
|
||||
}
|
||||
59
seanime-2.9.10/internal/handlers/explorer.go
Normal file
59
seanime-2.9.10/internal/handlers/explorer.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleOpenInExplorer
|
||||
//
|
||||
// @summary opens the given directory in the file explorer.
|
||||
// @desc It returns 'true' whether the operation was successful or not.
|
||||
// @route /api/v1/open-in-explorer [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleOpenInExplorer(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
p := new(body)
|
||||
if err := c.Bind(p); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
OpenDirInExplorer(p.Path)
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
func OpenDirInExplorer(dir string) {
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cmd := ""
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "explorer"
|
||||
args = []string{strings.ReplaceAll(strings.ToLower(dir), "/", "\\")}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{dir}
|
||||
case "linux":
|
||||
cmd = "xdg-open"
|
||||
args = []string{dir}
|
||||
default:
|
||||
return
|
||||
}
|
||||
cmdObj := util.NewCmd(cmd, args...)
|
||||
cmdObj.Stdout = os.Stdout
|
||||
cmdObj.Stderr = os.Stderr
|
||||
_ = cmdObj.Run()
|
||||
}
|
||||
362
seanime-2.9.10/internal/handlers/extensions.go
Normal file
362
seanime-2.9.10/internal/handlers/extensions.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/extension_playground"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleFetchExternalExtensionData
|
||||
//
|
||||
// @summary returns the extension data from the given manifest uri.
|
||||
// @route /api/v1/extensions/external/fetch [POST]
|
||||
// @returns extension.Extension
|
||||
func (h *Handler) HandleFetchExternalExtensionData(c echo.Context) error {
|
||||
type body struct {
|
||||
ManifestURI string `json:"manifestUri"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
extension, err := h.App.ExtensionRepository.FetchExternalExtensionData(b.ManifestURI)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, extension)
|
||||
}
|
||||
|
||||
// HandleInstallExternalExtension
|
||||
//
|
||||
// @summary installs the extension from the given manifest uri.
|
||||
// @route /api/v1/extensions/external/install [POST]
|
||||
// @returns extension_repo.ExtensionInstallResponse
|
||||
func (h *Handler) HandleInstallExternalExtension(c echo.Context) error {
|
||||
type body struct {
|
||||
ManifestURI string `json:"manifestUri"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
res, err := h.App.ExtensionRepository.InstallExternalExtension(b.ManifestURI)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, res)
|
||||
}
|
||||
|
||||
// HandleUninstallExternalExtension
|
||||
//
|
||||
// @summary uninstalls the extension with the given ID.
|
||||
// @route /api/v1/extensions/external/uninstall [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUninstallExternalExtension(c echo.Context) error {
|
||||
type body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.ExtensionRepository.UninstallExternalExtension(b.ID)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleUpdateExtensionCode
|
||||
//
|
||||
// @summary updates the extension code with the given ID and reloads the extensions.
|
||||
// @route /api/v1/extensions/external/edit-payload [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUpdateExtensionCode(c echo.Context) error {
|
||||
type body struct {
|
||||
ID string `json:"id"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.ExtensionRepository.UpdateExtensionCode(b.ID, b.Payload)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleReloadExternalExtensions
|
||||
//
|
||||
// @summary reloads the external extensions.
|
||||
// @route /api/v1/extensions/external/reload [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleReloadExternalExtensions(c echo.Context) error {
|
||||
h.App.ExtensionRepository.ReloadExternalExtensions()
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleReloadExternalExtension
|
||||
//
|
||||
// @summary reloads the external extension with the given ID.
|
||||
// @route /api/v1/extensions/external/reload [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleReloadExternalExtension(c echo.Context) error {
|
||||
type body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
h.App.ExtensionRepository.ReloadExternalExtension(b.ID)
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleListExtensionData
|
||||
//
|
||||
// @summary returns the loaded extensions
|
||||
// @route /api/v1/extensions/list [GET]
|
||||
// @returns []extension.Extension
|
||||
func (h *Handler) HandleListExtensionData(c echo.Context) error {
|
||||
extensions := h.App.ExtensionRepository.ListExtensionData()
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
|
||||
// HandleGetExtensionPayload
|
||||
//
|
||||
// @summary returns the payload of the extension with the given ID.
|
||||
// @route /api/v1/extensions/payload/{id} [GET]
|
||||
// @returns string
|
||||
func (h *Handler) HandleGetExtensionPayload(c echo.Context) error {
|
||||
payload := h.App.ExtensionRepository.GetExtensionPayload(c.Param("id"))
|
||||
return h.RespondWithData(c, payload)
|
||||
}
|
||||
|
||||
// HandleListDevelopmentModeExtensions
|
||||
//
|
||||
// @summary returns the development mode extensions
|
||||
// @route /api/v1/extensions/list/development [GET]
|
||||
// @returns []extension.Extension
|
||||
func (h *Handler) HandleListDevelopmentModeExtensions(c echo.Context) error {
|
||||
extensions := h.App.ExtensionRepository.ListDevelopmentModeExtensions()
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
|
||||
// HandleGetAllExtensions
|
||||
//
|
||||
// @summary returns all loaded and invalid extensions.
|
||||
// @route /api/v1/extensions/all [POST]
|
||||
// @returns extension_repo.AllExtensions
|
||||
func (h *Handler) HandleGetAllExtensions(c echo.Context) error {
|
||||
type body struct {
|
||||
WithUpdates bool `json:"withUpdates"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
extensions := h.App.ExtensionRepository.GetAllExtensions(b.WithUpdates)
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
|
||||
// HandleGetExtensionUpdateData
|
||||
//
|
||||
// @summary returns the update data that were found for the extensions.
|
||||
// @route /api/v1/extensions/updates [GET]
|
||||
// @returns []extension_repo.UpdateData
|
||||
func (h *Handler) HandleGetExtensionUpdateData(c echo.Context) error {
|
||||
return h.RespondWithData(c, h.App.ExtensionRepository.GetUpdateData())
|
||||
}
|
||||
|
||||
// HandleListMangaProviderExtensions
|
||||
//
|
||||
// @summary returns the installed manga providers.
|
||||
// @route /api/v1/extensions/list/manga-provider [GET]
|
||||
// @returns []extension_repo.MangaProviderExtensionItem
|
||||
func (h *Handler) HandleListMangaProviderExtensions(c echo.Context) error {
|
||||
extensions := h.App.ExtensionRepository.ListMangaProviderExtensions()
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
|
||||
// HandleListOnlinestreamProviderExtensions
|
||||
//
|
||||
// @summary returns the installed online streaming providers.
|
||||
// @route /api/v1/extensions/list/onlinestream-provider [GET]
|
||||
// @returns []extension_repo.OnlinestreamProviderExtensionItem
|
||||
func (h *Handler) HandleListOnlinestreamProviderExtensions(c echo.Context) error {
|
||||
extensions := h.App.ExtensionRepository.ListOnlinestreamProviderExtensions()
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
|
||||
// HandleListAnimeTorrentProviderExtensions
|
||||
//
|
||||
// @summary returns the installed torrent providers.
|
||||
// @route /api/v1/extensions/list/anime-torrent-provider [GET]
|
||||
// @returns []extension_repo.AnimeTorrentProviderExtensionItem
|
||||
func (h *Handler) HandleListAnimeTorrentProviderExtensions(c echo.Context) error {
|
||||
extensions := h.App.ExtensionRepository.ListAnimeTorrentProviderExtensions()
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
|
||||
// HandleGetPluginSettings
|
||||
//
|
||||
// @summary returns the plugin settings.
|
||||
// @route /api/v1/extensions/plugin-settings [GET]
|
||||
// @returns extension_repo.StoredPluginSettingsData
|
||||
func (h *Handler) HandleGetPluginSettings(c echo.Context) error {
|
||||
settings := h.App.ExtensionRepository.GetPluginSettings()
|
||||
return h.RespondWithData(c, settings)
|
||||
}
|
||||
|
||||
// HandleSetPluginSettingsPinnedTrays
|
||||
//
|
||||
// @summary sets the pinned trays in the plugin settings.
|
||||
// @route /api/v1/extensions/plugin-settings/pinned-trays [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSetPluginSettingsPinnedTrays(c echo.Context) error {
|
||||
type body struct {
|
||||
PinnedTrayPluginIds []string `json:"pinnedTrayPluginIds"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.ExtensionRepository.SetPluginSettingsPinnedTrays(b.PinnedTrayPluginIds)
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGrantPluginPermissions
|
||||
//
|
||||
// @summary grants the plugin permissions to the extension with the given ID.
|
||||
// @route /api/v1/extensions/plugin-permissions/grant [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleGrantPluginPermissions(c echo.Context) error {
|
||||
type body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.ExtensionRepository.GrantPluginPermissions(b.ID)
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandleRunExtensionPlaygroundCode
|
||||
//
|
||||
// @summary runs the code in the extension playground.
|
||||
// @desc Returns the logs
|
||||
// @route /api/v1/extensions/playground/run [POST]
|
||||
// @returns extension_playground.RunPlaygroundCodeResponse
|
||||
func (h *Handler) HandleRunExtensionPlaygroundCode(c echo.Context) error {
|
||||
type body struct {
|
||||
Params *extension_playground.RunPlaygroundCodeParams `json:"params"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
res, err := h.App.ExtensionPlaygroundRepository.RunPlaygroundCode(b.Params)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, res)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandleGetExtensionUserConfig
|
||||
//
|
||||
// @summary returns the user config definition and current values for the extension with the given ID.
|
||||
// @route /api/v1/extensions/user-config/{id} [GET]
|
||||
// @returns extension_repo.ExtensionUserConfig
|
||||
func (h *Handler) HandleGetExtensionUserConfig(c echo.Context) error {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
return h.RespondWithError(c, fmt.Errorf("id is required"))
|
||||
}
|
||||
config := h.App.ExtensionRepository.GetExtensionUserConfig(id)
|
||||
return h.RespondWithData(c, config)
|
||||
}
|
||||
|
||||
// HandleSaveExtensionUserConfig
|
||||
//
|
||||
// @summary saves the user config for the extension with the given ID and reloads it.
|
||||
// @route /api/v1/extensions/user-config [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSaveExtensionUserConfig(c echo.Context) error {
|
||||
type body struct {
|
||||
ID string `json:"id"` // The extension ID
|
||||
Version int `json:"version"` // The current extension user config definition version
|
||||
Values map[string]string `json:"values"` // The values
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
config := &extension.SavedUserConfig{
|
||||
Version: b.Version,
|
||||
Values: b.Values,
|
||||
}
|
||||
|
||||
err := h.App.ExtensionRepository.SaveExtensionUserConfig(b.ID, config)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandleGetMarketplaceExtensions
|
||||
//
|
||||
// @summary returns the marketplace extensions.
|
||||
// @route /api/v1/extensions/marketplace [GET]
|
||||
// @returns []extension.Extension
|
||||
func (h *Handler) HandleGetMarketplaceExtensions(c echo.Context) error {
|
||||
encodedMarketplaceUrl := c.QueryParam("marketplace")
|
||||
marketplaceUrl := ""
|
||||
|
||||
if encodedMarketplaceUrl != "" {
|
||||
marketplaceUrl, _ = url.PathUnescape(encodedMarketplaceUrl)
|
||||
}
|
||||
|
||||
extensions, err := h.App.ExtensionRepository.GetMarketplaceExtensions(marketplaceUrl)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, extensions)
|
||||
}
|
||||
100
seanime-2.9.10/internal/handlers/filecache.go
Normal file
100
seanime-2.9.10/internal/handlers/filecache.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetFileCacheTotalSize
|
||||
//
|
||||
// @summary returns the total size of cache files.
|
||||
// @desc The total size of the cache files is returned in human-readable format.
|
||||
// @route /api/v1/filecache/total-size [GET]
|
||||
// @returns string
|
||||
func (h *Handler) HandleGetFileCacheTotalSize(c echo.Context) error {
|
||||
// Get the cache size
|
||||
size, err := h.App.FileCacher.GetTotalSize()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Return the cache size
|
||||
return h.RespondWithData(c, util.Bytes(uint64(size)))
|
||||
}
|
||||
|
||||
// HandleRemoveFileCacheBucket
|
||||
//
|
||||
// @summary deletes all buckets with the given prefix.
|
||||
// @desc The bucket value is the prefix of the cache files that should be deleted.
|
||||
// @desc Returns 'true' if the operation was successful.
|
||||
// @route /api/v1/filecache/bucket [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleRemoveFileCacheBucket(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Bucket string `json:"bucket"` // e.g. "onlinestream_"
|
||||
}
|
||||
|
||||
// Parse the request body
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Remove all files in the cache directory that match the given filter
|
||||
err := h.App.FileCacher.RemoveAllBy(func(filename string) bool {
|
||||
return strings.HasPrefix(filename, b.Bucket)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Return a success response
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetFileCacheMediastreamVideoFilesTotalSize
|
||||
//
|
||||
// @summary returns the total size of cached video file data.
|
||||
// @desc The total size of the cache video file data is returned in human-readable format.
|
||||
// @route /api/v1/filecache/mediastream/videofiles/total-size [GET]
|
||||
// @returns string
|
||||
func (h *Handler) HandleGetFileCacheMediastreamVideoFilesTotalSize(c echo.Context) error {
|
||||
// Get the cache size
|
||||
size, err := h.App.FileCacher.GetMediastreamVideoFilesTotalSize()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Return the cache size
|
||||
return h.RespondWithData(c, util.Bytes(uint64(size)))
|
||||
}
|
||||
|
||||
// HandleClearFileCacheMediastreamVideoFiles
|
||||
//
|
||||
// @summary deletes the contents of the mediastream video file cache directory.
|
||||
// @desc Returns 'true' if the operation was successful.
|
||||
// @route /api/v1/filecache/mediastream/videofiles [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleClearFileCacheMediastreamVideoFiles(c echo.Context) error {
|
||||
|
||||
// Clear the attachments
|
||||
err := h.App.FileCacher.ClearMediastreamVideoFiles()
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Clear the transcode dir
|
||||
h.App.MediastreamRepository.ClearTranscodeDir()
|
||||
|
||||
if h.App.MediastreamRepository != nil {
|
||||
go h.App.MediastreamRepository.CacheWasCleared()
|
||||
}
|
||||
|
||||
// Return a success response
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
226
seanime-2.9.10/internal/handlers/local.go
Normal file
226
seanime-2.9.10/internal/handlers/local.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/util"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleSetOfflineMode
|
||||
//
|
||||
// @summary sets the offline mode.
|
||||
// @desc Returns true if the offline mode is active, false otherwise.
|
||||
// @route /api/v1/local/offline [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSetOfflineMode(c echo.Context) error {
|
||||
type body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.SetOfflineMode(b.Enabled)
|
||||
return h.RespondWithData(c, b.Enabled)
|
||||
}
|
||||
|
||||
// HandleLocalGetTrackedMediaItems
|
||||
//
|
||||
// @summary gets all tracked media.
|
||||
// @route /api/v1/local/track [GET]
|
||||
// @returns []local.TrackedMediaItem
|
||||
func (h *Handler) HandleLocalGetTrackedMediaItems(c echo.Context) error {
|
||||
tracked := h.App.LocalManager.GetTrackedMediaItems()
|
||||
return h.RespondWithData(c, tracked)
|
||||
}
|
||||
|
||||
// HandleLocalAddTrackedMedia
|
||||
//
|
||||
// @summary adds one or multiple media to be tracked for offline sync.
|
||||
// @route /api/v1/local/track [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalAddTrackedMedia(c echo.Context) error {
|
||||
type body struct {
|
||||
Media []struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Type string `json:"type"`
|
||||
} `json:"media"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, m := range b.Media {
|
||||
switch m.Type {
|
||||
case "anime":
|
||||
err = h.App.LocalManager.TrackAnime(m.MediaId)
|
||||
case "manga":
|
||||
err = h.App.LocalManager.TrackManga(m.MediaId)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleLocalRemoveTrackedMedia
|
||||
//
|
||||
// @summary remove media from being tracked for offline sync.
|
||||
// @desc This will remove anime from being tracked for offline sync and delete any associated data.
|
||||
// @route /api/v1/local/track [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalRemoveTrackedMedia(c echo.Context) error {
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var err error
|
||||
switch b.Type {
|
||||
case "anime":
|
||||
err = h.App.LocalManager.UntrackAnime(b.MediaId)
|
||||
case "manga":
|
||||
err = h.App.LocalManager.UntrackManga(b.MediaId)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleLocalGetIsMediaTracked
|
||||
//
|
||||
// @summary checks if media is being tracked for offline sync.
|
||||
// @route /api/v1/local/track/{id}/{type} [GET]
|
||||
// @param id - int - true - "AniList anime media ID"
|
||||
// @param type - string - true - "Type of media (anime/manga)"
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalGetIsMediaTracked(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
kind := c.Param("type")
|
||||
tracked := h.App.LocalManager.IsMediaTracked(id, kind)
|
||||
|
||||
return h.RespondWithData(c, tracked)
|
||||
}
|
||||
|
||||
// HandleLocalSyncData
|
||||
//
|
||||
// @summary syncs local data with AniList.
|
||||
// @route /api/v1/local/local [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalSyncData(c echo.Context) error {
|
||||
// Do not allow syncing if the user is simulated
|
||||
if h.App.GetUser().IsSimulated {
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
err := h.App.LocalManager.SynchronizeLocal()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if h.App.Settings.GetLibrary().AutoSaveCurrentMediaOffline {
|
||||
go func() {
|
||||
added, _ := h.App.LocalManager.AutoTrackCurrentMedia()
|
||||
if added {
|
||||
_ = h.App.LocalManager.SynchronizeLocal()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleLocalGetSyncQueueState
|
||||
//
|
||||
// @summary gets the current sync queue state.
|
||||
// @desc This will return the list of media that are currently queued for syncing.
|
||||
// @route /api/v1/local/queue [GET]
|
||||
// @returns local.QueueState
|
||||
func (h *Handler) HandleLocalGetSyncQueueState(c echo.Context) error {
|
||||
return h.RespondWithData(c, h.App.LocalManager.GetSyncer().GetQueueState())
|
||||
}
|
||||
|
||||
// HandleLocalSyncAnilistData
|
||||
//
|
||||
// @summary syncs AniList data with local.
|
||||
// @route /api/v1/local/anilist [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalSyncAnilistData(c echo.Context) error {
|
||||
err := h.App.LocalManager.SynchronizeAnilist()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleLocalSetHasLocalChanges
|
||||
//
|
||||
// @summary sets the flag to determine if there are local changes that need to be synced with AniList.
|
||||
// @route /api/v1/local/updated [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalSetHasLocalChanges(c echo.Context) error {
|
||||
type body struct {
|
||||
Updated bool `json:"updated"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.LocalManager.SetHasLocalChanges(b.Updated)
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleLocalGetHasLocalChanges
|
||||
//
|
||||
// @summary gets the flag to determine if there are local changes that need to be synced with AniList.
|
||||
// @route /api/v1/local/updated [GET]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalGetHasLocalChanges(c echo.Context) error {
|
||||
updated := h.App.LocalManager.HasLocalChanges()
|
||||
return h.RespondWithData(c, updated)
|
||||
}
|
||||
|
||||
// HandleLocalGetLocalStorageSize
|
||||
//
|
||||
// @summary gets the size of the local storage in a human-readable format.
|
||||
// @route /api/v1/local/storage/size [GET]
|
||||
// @returns string
|
||||
func (h *Handler) HandleLocalGetLocalStorageSize(c echo.Context) error {
|
||||
size := h.App.LocalManager.GetLocalStorageSize()
|
||||
return h.RespondWithData(c, util.Bytes(uint64(size)))
|
||||
}
|
||||
|
||||
// HandleLocalSyncSimulatedDataToAnilist
|
||||
//
|
||||
// @summary syncs the simulated data to AniList.
|
||||
// @route /api/v1/local/sync-simulated-to-anilist [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleLocalSyncSimulatedDataToAnilist(c echo.Context) error {
|
||||
err := h.App.LocalManager.SynchronizeSimulatedCollectionToAnilist()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
327
seanime-2.9.10/internal/handlers/localfiles.go
Normal file
327
seanime-2.9.10/internal/handlers/localfiles.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/library/filesystem"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sourcegraph/conc/pool"
|
||||
)
|
||||
|
||||
// HandleGetLocalFiles
|
||||
//
|
||||
// @summary returns all local files.
|
||||
// @desc Reminder that local files are scanned from the library path.
|
||||
// @route /api/v1/library/local-files [GET]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleGetLocalFiles(c echo.Context) error {
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, lfs)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDumpLocalFilesToFile(c echo.Context) error {
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("seanime-localfiles-%s.json", time.Now().Format("2006-01-02_15-04-05"))
|
||||
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Response().Header().Set("Content-Type", "application/json")
|
||||
|
||||
jsonData, err := json.MarshalIndent(lfs, "", " ")
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return c.Blob(200, "application/json", jsonData)
|
||||
}
|
||||
|
||||
// HandleImportLocalFiles
|
||||
//
|
||||
// @summary imports local files from the given path.
|
||||
// @desc This will import local files from the given path.
|
||||
// @desc The response is ignored, the client should refetch the entire library collection and media entry.
|
||||
// @route /api/v1/library/local-files/import [POST]
|
||||
func (h *Handler) HandleImportLocalFiles(c echo.Context) error {
|
||||
type body struct {
|
||||
DataFilePath string `json:"dataFilePath"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
contentB, err := os.ReadFile(b.DataFilePath)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var lfs []*anime.LocalFile
|
||||
if err := json.Unmarshal(contentB, &lfs); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if len(lfs) == 0 {
|
||||
return h.RespondWithError(c, errors.New("no local files found"))
|
||||
}
|
||||
|
||||
_, err = db_bridge.InsertLocalFiles(h.App.Database, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.Database.TrimLocalFileEntries()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleLocalFileBulkAction
|
||||
//
|
||||
// @summary performs an action on all local files.
|
||||
// @desc This will perform the given action on all local files.
|
||||
// @desc The response is ignored, the client should refetch the entire library collection and media entry.
|
||||
// @route /api/v1/library/local-files [POST]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleLocalFileBulkAction(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
switch b.Action {
|
||||
case "lock":
|
||||
for _, lf := range lfs {
|
||||
// Note: Don't lock local files that are not associated with a media.
|
||||
// Else refreshing the library will ignore them.
|
||||
if lf.MediaId != 0 {
|
||||
lf.Locked = true
|
||||
}
|
||||
}
|
||||
case "unlock":
|
||||
for _, lf := range lfs {
|
||||
lf.Locked = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save the local files
|
||||
retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, retLfs)
|
||||
}
|
||||
|
||||
// HandleUpdateLocalFileData
|
||||
//
|
||||
// @summary updates the local file with the given path.
|
||||
// @desc This will update the local file with the given path.
|
||||
// @desc The response is ignored, the client should refetch the entire library collection and media entry.
|
||||
// @route /api/v1/library/local-file [PATCH]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleUpdateLocalFileData(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Path string `json:"path"`
|
||||
Metadata *anime.LocalFileMetadata `json:"metadata"`
|
||||
Locked bool `json:"locked"`
|
||||
Ignored bool `json:"ignored"`
|
||||
MediaId int `json:"mediaId"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
lf, found := lo.Find(lfs, func(i *anime.LocalFile) bool {
|
||||
return i.HasSamePath(b.Path)
|
||||
})
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("local file not found"))
|
||||
}
|
||||
lf.Metadata = b.Metadata
|
||||
lf.Locked = b.Locked
|
||||
lf.Ignored = b.Ignored
|
||||
lf.MediaId = b.MediaId
|
||||
|
||||
// Save the local files
|
||||
retLfs, err := db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, retLfs)
|
||||
}
|
||||
|
||||
// HandleUpdateLocalFiles
|
||||
//
|
||||
// @summary updates local files with the given paths.
|
||||
// @desc The client should refetch the entire library collection and media entry.
|
||||
// @route /api/v1/library/local-files [PATCH]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleUpdateLocalFiles(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Paths []string `json:"paths"`
|
||||
Action string `json:"action"`
|
||||
MediaId int `json:"mediaId,omitempty"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Update the files
|
||||
for _, path := range b.Paths {
|
||||
lf, found := lo.Find(lfs, func(i *anime.LocalFile) bool {
|
||||
return i.HasSamePath(path)
|
||||
})
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch b.Action {
|
||||
case "lock":
|
||||
lf.Locked = true
|
||||
case "unlock":
|
||||
lf.Locked = false
|
||||
case "ignore":
|
||||
lf.MediaId = 0
|
||||
lf.Ignored = true
|
||||
lf.Locked = false
|
||||
case "unignore":
|
||||
lf.Ignored = false
|
||||
lf.Locked = false
|
||||
case "unmatch":
|
||||
lf.MediaId = 0
|
||||
lf.Locked = false
|
||||
lf.Ignored = false
|
||||
case "match":
|
||||
lf.MediaId = b.MediaId
|
||||
lf.Locked = true
|
||||
lf.Ignored = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save the local files
|
||||
_, err = db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDeleteLocalFiles
|
||||
//
|
||||
// @summary deletes local files with the given paths.
|
||||
// @desc This will delete the local files with the given paths.
|
||||
// @desc The client should refetch the entire library collection and media entry.
|
||||
// @route /api/v1/library/local-files [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDeleteLocalFiles(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Delete the files
|
||||
p := pool.New().WithErrors()
|
||||
for _, path := range b.Paths {
|
||||
path := path
|
||||
p.Go(func() error {
|
||||
err := os.Remove(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := p.Wait(); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Remove the files from the list
|
||||
lfs = lo.Filter(lfs, func(i *anime.LocalFile, _ int) bool {
|
||||
return !lo.Contains(b.Paths, i.Path)
|
||||
})
|
||||
|
||||
// Save the local files
|
||||
_, err = db_bridge.SaveLocalFiles(h.App.Database, lfsId, lfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleRemoveEmptyDirectories
|
||||
//
|
||||
// @summary removes empty directories.
|
||||
// @desc This will remove empty directories in the library path.
|
||||
// @route /api/v1/library/empty-directories [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleRemoveEmptyDirectories(c echo.Context) error {
|
||||
|
||||
libraryPaths, err := h.App.Database.GetAllLibraryPathsFromSettings()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
for _, path := range libraryPaths {
|
||||
filesystem.RemoveEmptyDirectories(path, h.App.Logger)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
159
seanime-2.9.10/internal/handlers/mal.go
Normal file
159
seanime-2.9.10/internal/handlers/mal.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"seanime/internal/api/mal"
|
||||
"seanime/internal/constants"
|
||||
"seanime/internal/database/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type MalAuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int32 `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// HandleMALAuth
|
||||
//
|
||||
// @summary fetches the access and refresh tokens for the given code.
|
||||
// @desc This is used to authenticate the user with MyAnimeList.
|
||||
// @desc It will save the info in the database, effectively logging the user in.
|
||||
// @desc The client should re-fetch the server status after this.
|
||||
// @route /api/v1/mal/auth [POST]
|
||||
// @returns handlers.MalAuthResponse
|
||||
func (h *Handler) HandleMALAuth(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Code string `json:"code"`
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
// Build URL
|
||||
urlData := url.Values{}
|
||||
urlData.Set("client_id", constants.MalClientId)
|
||||
urlData.Set("grant_type", "authorization_code")
|
||||
urlData.Set("code", b.Code)
|
||||
urlData.Set("code_verifier", b.CodeVerifier)
|
||||
encodedData := urlData.Encode()
|
||||
|
||||
req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(encodedData))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Add("Content-Length", strconv.Itoa(len(urlData.Encode())))
|
||||
|
||||
// Response
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
ret := MalAuthResponse{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&ret); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Save
|
||||
malInfo := 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 = h.App.Database.UpsertMalInfo(&malInfo)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
// HandleEditMALListEntryProgress
|
||||
//
|
||||
// @summary updates the progress of a MAL list entry.
|
||||
// @route /api/v1/mal/list-entry/progress [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleEditMALListEntryProgress(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId *int `json:"mediaId"`
|
||||
Progress *int `json:"progress"`
|
||||
}
|
||||
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.MediaId == nil || b.Progress == nil {
|
||||
return h.RespondWithError(c, errors.New("mediaId and progress is required"))
|
||||
}
|
||||
|
||||
// Get MAL info
|
||||
_malInfo, err := h.App.Database.GetMalInfo()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Verify MAL auth
|
||||
malInfo, err := mal.VerifyMALAuth(_malInfo, h.App.Database, h.App.Logger)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get MAL Wrapper
|
||||
malWrapper := mal.NewWrapper(malInfo.AccessToken, h.App.Logger)
|
||||
|
||||
// Update MAL list entry
|
||||
err = malWrapper.UpdateAnimeProgress(&mal.AnimeListProgressParams{
|
||||
NumEpisodesWatched: b.Progress,
|
||||
}, *b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.Logger.Debug().Msgf("mal: Updated MAL list entry for mediaId %d", *b.MediaId)
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleMALLogout
|
||||
//
|
||||
// @summary logs the user out of MyAnimeList.
|
||||
// @desc This will delete the MAL info from the database, effectively logging the user out.
|
||||
// @desc The client should re-fetch the server status after this.
|
||||
// @route /api/v1/mal/logout [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleMALLogout(c echo.Context) error {
|
||||
|
||||
err := h.App.Database.DeleteMalInfo()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
596
seanime-2.9.10/internal/handlers/manga.go
Normal file
596
seanime-2.9.10/internal/handlers/manga.go
Normal file
@@ -0,0 +1,596 @@
|
||||
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)
|
||||
}
|
||||
210
seanime-2.9.10/internal/handlers/manga_download.go
Normal file
210
seanime-2.9.10/internal/handlers/manga_download.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/manga"
|
||||
chapter_downloader "seanime/internal/manga/downloader"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleDownloadMangaChapters
|
||||
//
|
||||
// @summary adds chapters to the download queue.
|
||||
// @route /api/v1/manga/download-chapters [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDownloadMangaChapters(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Provider string `json:"provider"`
|
||||
ChapterIds []string `json:"chapterIds"`
|
||||
StartNow bool `json:"startNow"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.WSEventManager.SendEvent(events.InfoToast, "Adding chapters to download queue...")
|
||||
|
||||
// Add chapters to the download queue
|
||||
for _, chapterId := range b.ChapterIds {
|
||||
err := h.App.MangaDownloader.DownloadChapter(manga.DownloadChapterOptions{
|
||||
Provider: b.Provider,
|
||||
MediaId: b.MediaId,
|
||||
ChapterId: chapterId,
|
||||
StartNow: b.StartNow,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond) // Sleep to avoid rate limiting
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetMangaDownloadData
|
||||
//
|
||||
// @summary returns the download data for a specific media.
|
||||
// @desc This is used to display information about the downloaded and queued chapters in the UI.
|
||||
// @desc If the 'cached' parameter is false, it will refresh the data by rescanning the download folder.
|
||||
// @route /api/v1/manga/download-data [POST]
|
||||
// @returns manga.MediaDownloadData
|
||||
func (h *Handler) HandleGetMangaDownloadData(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Cached bool `json:"cached"` // If false, it will refresh the data
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
data, err := h.App.MangaDownloader.GetMediaDownloads(b.MediaId, b.Cached)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, data)
|
||||
}
|
||||
|
||||
// HandleGetMangaDownloadQueue
|
||||
//
|
||||
// @summary returns the items in the download queue.
|
||||
// @route /api/v1/manga/download-queue [GET]
|
||||
// @returns []models.ChapterDownloadQueueItem
|
||||
func (h *Handler) HandleGetMangaDownloadQueue(c echo.Context) error {
|
||||
|
||||
data, err := h.App.Database.GetChapterDownloadQueue()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, data)
|
||||
}
|
||||
|
||||
// HandleStartMangaDownloadQueue
|
||||
//
|
||||
// @summary starts the download queue if it's not already running.
|
||||
// @desc This will start the download queue if it's not already running.
|
||||
// @desc Returns 'true' whether the queue was started or not.
|
||||
// @route /api/v1/manga/download-queue/start [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleStartMangaDownloadQueue(c echo.Context) error {
|
||||
|
||||
h.App.MangaDownloader.RunChapterDownloadQueue()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleStopMangaDownloadQueue
|
||||
//
|
||||
// @summary stops the manga download queue.
|
||||
// @desc This will stop the manga download queue.
|
||||
// @desc Returns 'true' whether the queue was stopped or not.
|
||||
// @route /api/v1/manga/download-queue/stop [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleStopMangaDownloadQueue(c echo.Context) error {
|
||||
|
||||
h.App.MangaDownloader.StopChapterDownloadQueue()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
|
||||
}
|
||||
|
||||
// HandleClearAllChapterDownloadQueue
|
||||
//
|
||||
// @summary clears all chapters from the download queue.
|
||||
// @desc This will clear all chapters from the download queue.
|
||||
// @desc Returns 'true' whether the queue was cleared or not.
|
||||
// @desc This will also send a websocket event telling the client to refetch the download queue.
|
||||
// @route /api/v1/manga/download-queue [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleClearAllChapterDownloadQueue(c echo.Context) error {
|
||||
|
||||
err := h.App.Database.ClearAllChapterDownloadQueueItems()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.WSEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil)
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleResetErroredChapterDownloadQueue
|
||||
//
|
||||
// @summary resets the errored chapters in the download queue.
|
||||
// @desc This will reset the errored chapters in the download queue, so they can be re-downloaded.
|
||||
// @desc Returns 'true' whether the queue was reset or not.
|
||||
// @desc This will also send a websocket event telling the client to refetch the download queue.
|
||||
// @route /api/v1/manga/download-queue/reset-errored [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleResetErroredChapterDownloadQueue(c echo.Context) error {
|
||||
|
||||
err := h.App.Database.ResetErroredChapterDownloadQueueItems()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.WSEventManager.SendEvent(events.ChapterDownloadQueueUpdated, nil)
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDeleteMangaDownloadedChapters
|
||||
//
|
||||
// @summary deletes downloaded chapters.
|
||||
// @desc This will delete downloaded chapters from the filesystem.
|
||||
// @desc Returns 'true' whether the chapters were deleted or not.
|
||||
// @desc The client should refetch the download data after this.
|
||||
// @route /api/v1/manga/download-chapter [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDeleteMangaDownloadedChapters(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
DownloadIds []chapter_downloader.DownloadID `json:"downloadIds"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.MangaDownloader.DeleteChapters(b.DownloadIds)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetMangaDownloadsList
|
||||
//
|
||||
// @summary displays the list of downloaded manga.
|
||||
// @desc This analyzes the download folder and returns a well-formatted structure for displaying downloaded manga.
|
||||
// @desc It returns a list of manga.DownloadListItem where the media data might be nil if it's not in the AniList collection.
|
||||
// @route /api/v1/manga/downloads [GET]
|
||||
// @returns []manga.DownloadListItem
|
||||
func (h *Handler) HandleGetMangaDownloadsList(c echo.Context) error {
|
||||
|
||||
mangaCollection, err := h.App.GetMangaCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
res, err := h.App.MangaDownloader.NewDownloadList(&manga.NewDownloadListOptions{
|
||||
MangaCollection: mangaCollection,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, res)
|
||||
}
|
||||
53
seanime-2.9.10/internal/handlers/manual_dump.go
Normal file
53
seanime-2.9.10/internal/handlers/manual_dump.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/library/scanner"
|
||||
"seanime/internal/util/limiter"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// DUMMY HANDLER
|
||||
|
||||
type RequestBody struct {
|
||||
Dir string `json:"dir"`
|
||||
Username string `json:"userName"`
|
||||
}
|
||||
|
||||
// HandleTestDump
|
||||
//
|
||||
// @summary this is a dummy handler for testing purposes.
|
||||
// @route /api/v1/test-dump [POST]
|
||||
func (h *Handler) HandleTestDump(c echo.Context) error {
|
||||
|
||||
body := new(RequestBody)
|
||||
if err := c.Bind(body); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
localFiles, err := scanner.GetLocalFilesFromDir(body.Dir, h.App.Logger)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
completeAnimeCache := anilist.NewCompleteAnimeCache()
|
||||
|
||||
mc, err := scanner.NewMediaFetcher(c.Request().Context(), &scanner.MediaFetcherOptions{
|
||||
Enhanced: false,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
LocalFiles: localFiles,
|
||||
CompleteAnimeCache: completeAnimeCache,
|
||||
Logger: h.App.Logger,
|
||||
AnilistRateLimiter: limiter.NewAnilistLimiter(),
|
||||
DisableAnimeCollection: false,
|
||||
ScanLogger: nil,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, mc.AllMedia)
|
||||
}
|
||||
34
seanime-2.9.10/internal/handlers/mediaplayer.go
Normal file
34
seanime-2.9.10/internal/handlers/mediaplayer.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleStartDefaultMediaPlayer
|
||||
//
|
||||
// @summary launches the default media player (vlc or mpc-hc).
|
||||
// @route /api/v1/media-player/start [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleStartDefaultMediaPlayer(c echo.Context) error {
|
||||
|
||||
// Retrieve settings
|
||||
settings, err := h.App.Database.GetSettings()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
switch settings.MediaPlayer.Default {
|
||||
case "vlc":
|
||||
err = h.App.MediaPlayer.VLC.Start()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
case "mpc-hc":
|
||||
err = h.App.MediaPlayer.MpcHc.Start()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
179
seanime-2.9.10/internal/handlers/mediastream.go
Normal file
179
seanime-2.9.10/internal/handlers/mediastream.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/mediastream"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetMediastreamSettings
|
||||
//
|
||||
// @summary get mediastream settings.
|
||||
// @desc This returns the mediastream settings.
|
||||
// @returns models.MediastreamSettings
|
||||
// @route /api/v1/mediastream/settings [GET]
|
||||
func (h *Handler) HandleGetMediastreamSettings(c echo.Context) error {
|
||||
mediastreamSettings, found := h.App.Database.GetMediastreamSettings()
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("media streaming settings not found"))
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, mediastreamSettings)
|
||||
}
|
||||
|
||||
// HandleSaveMediastreamSettings
|
||||
//
|
||||
// @summary save mediastream settings.
|
||||
// @desc This saves the mediastream settings.
|
||||
// @returns models.MediastreamSettings
|
||||
// @route /api/v1/mediastream/settings [PATCH]
|
||||
func (h *Handler) HandleSaveMediastreamSettings(c echo.Context) error {
|
||||
type body struct {
|
||||
Settings models.MediastreamSettings `json:"settings"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
settings, err := h.App.Database.UpsertMediastreamSettings(&b.Settings)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.InitOrRefreshMediastreamSettings()
|
||||
|
||||
return h.RespondWithData(c, settings)
|
||||
}
|
||||
|
||||
// HandleRequestMediastreamMediaContainer
|
||||
//
|
||||
// @summary request media stream.
|
||||
// @desc This requests a media stream and returns the media container to start the playback.
|
||||
// @returns mediastream.MediaContainer
|
||||
// @route /api/v1/mediastream/request [POST]
|
||||
func (h *Handler) HandleRequestMediastreamMediaContainer(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Path string `json:"path"` // The path of the file.
|
||||
StreamType mediastream.StreamType `json:"streamType"` // The type of stream to request.
|
||||
AudioStreamIndex int `json:"audioStreamIndex"` // The audio stream index to use. (unused)
|
||||
ClientId string `json:"clientId"` // The session id
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var mediaContainer *mediastream.MediaContainer
|
||||
var err error
|
||||
|
||||
switch b.StreamType {
|
||||
case mediastream.StreamTypeDirect:
|
||||
mediaContainer, err = h.App.MediastreamRepository.RequestDirectPlay(b.Path, b.ClientId)
|
||||
case mediastream.StreamTypeTranscode:
|
||||
mediaContainer, err = h.App.MediastreamRepository.RequestTranscodeStream(b.Path, b.ClientId)
|
||||
case mediastream.StreamTypeOptimized:
|
||||
err = fmt.Errorf("stream type %s not implemented", b.StreamType)
|
||||
//mediaContainer, err = h.App.MediastreamRepository.RequestOptimizedStream(b.Path)
|
||||
default:
|
||||
err = fmt.Errorf("stream type %s not implemented", b.StreamType)
|
||||
}
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, mediaContainer)
|
||||
}
|
||||
|
||||
// HandlePreloadMediastreamMediaContainer
|
||||
//
|
||||
// @summary preloads media stream for playback.
|
||||
// @desc This preloads a media stream by extracting the media information and attachments.
|
||||
// @returns bool
|
||||
// @route /api/v1/mediastream/preload [POST]
|
||||
func (h *Handler) HandlePreloadMediastreamMediaContainer(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Path string `json:"path"` // The path of the file.
|
||||
StreamType mediastream.StreamType `json:"streamType"` // The type of stream to request.
|
||||
AudioStreamIndex int `json:"audioStreamIndex"` // The audio stream index to use.
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
switch b.StreamType {
|
||||
case mediastream.StreamTypeTranscode:
|
||||
err = h.App.MediastreamRepository.RequestPreloadTranscodeStream(b.Path)
|
||||
case mediastream.StreamTypeDirect:
|
||||
err = h.App.MediastreamRepository.RequestPreloadDirectPlay(b.Path)
|
||||
default:
|
||||
err = fmt.Errorf("stream type %s not implemented", b.StreamType)
|
||||
}
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleMediastreamGetSubtitles(c echo.Context) error {
|
||||
return h.App.MediastreamRepository.ServeEchoExtractedSubtitles(c)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleMediastreamGetAttachments(c echo.Context) error {
|
||||
return h.App.MediastreamRepository.ServeEchoExtractedAttachments(c)
|
||||
}
|
||||
|
||||
//
|
||||
// Direct
|
||||
//
|
||||
|
||||
func (h *Handler) HandleMediastreamDirectPlay(c echo.Context) error {
|
||||
client := "1"
|
||||
return h.App.MediastreamRepository.ServeEchoDirectPlay(c, client)
|
||||
}
|
||||
|
||||
//
|
||||
// Transcode
|
||||
//
|
||||
|
||||
func (h *Handler) HandleMediastreamTranscode(c echo.Context) error {
|
||||
client := "1"
|
||||
return h.App.MediastreamRepository.ServeEchoTranscodeStream(c, client)
|
||||
}
|
||||
|
||||
// HandleMediastreamShutdownTranscodeStream
|
||||
//
|
||||
// @summary shuts down the transcode stream
|
||||
// @desc This requests the transcoder to shut down. It should be called when unmounting the player (playback is no longer needed).
|
||||
// @desc This will also send an events.MediastreamShutdownStream event.
|
||||
// @desc It will not return any error and is safe to call multiple times.
|
||||
// @returns bool
|
||||
// @route /api/v1/mediastream/shutdown-transcode [POST]
|
||||
func (h *Handler) HandleMediastreamShutdownTranscodeStream(c echo.Context) error {
|
||||
client := "1"
|
||||
h.App.MediastreamRepository.ShutdownTranscodeStream(client)
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//
|
||||
// Serve file
|
||||
//
|
||||
|
||||
func (h *Handler) HandleMediastreamFile(c echo.Context) error {
|
||||
client := "1"
|
||||
fp := c.QueryParam("path")
|
||||
libraryPaths := h.App.Settings.GetLibrary().GetLibraryPaths()
|
||||
return h.App.MediastreamRepository.ServeEchoFile(c, fp, client, libraryPaths)
|
||||
}
|
||||
68
seanime-2.9.10/internal/handlers/metadata.go
Normal file
68
seanime-2.9.10/internal/handlers/metadata.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandlePopulateFillerData
|
||||
//
|
||||
// @summary fetches and caches filler data for the given media.
|
||||
// @desc This will fetch and cache filler data for the given media.
|
||||
// @returns true
|
||||
// @route /api/v1/metadata-provider/filler [POST]
|
||||
func (h *Handler) HandlePopulateFillerData(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)
|
||||
}
|
||||
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
media, found := animeCollection.FindAnime(b.MediaId)
|
||||
if !found {
|
||||
// Fetch media
|
||||
media, err = h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch filler data
|
||||
err = h.App.FillerManager.FetchAndStoreFillerData(b.MediaId, media.GetAllTitlesDeref())
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleRemoveFillerData
|
||||
//
|
||||
// @summary removes filler data cache.
|
||||
// @desc This will remove the filler data cache for the given media.
|
||||
// @returns bool
|
||||
// @route /api/v1/metadata-provider/filler [DELETE]
|
||||
func (h *Handler) HandleRemoveFillerData(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.FillerManager.RemoveFillerData(b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
783
seanime-2.9.10/internal/handlers/nakama.go
Normal file
783
seanime-2.9.10/internal/handlers/nakama.go
Normal file
@@ -0,0 +1,783 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/nakama"
|
||||
"seanime/internal/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// HandleNakamaWebSocket handles WebSocket connections for Nakama peers
|
||||
//
|
||||
// @summary handles WebSocket connections for Nakama peers.
|
||||
// @desc This endpoint handles WebSocket connections from Nakama peers when this instance is acting as a host.
|
||||
// @route /api/v1/nakama/ws [GET]
|
||||
func (h *Handler) HandleNakamaWebSocket(c echo.Context) error {
|
||||
// Use the standard library HTTP ResponseWriter and Request
|
||||
w := c.Response().Writer
|
||||
r := c.Request()
|
||||
|
||||
// Let the Nakama manager handle the WebSocket connection
|
||||
h.App.NakamaManager.HandlePeerConnection(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleSendNakamaMessage
|
||||
//
|
||||
// @summary sends a custom message through Nakama.
|
||||
// @desc This allows sending custom messages to connected peers or the host.
|
||||
// @route /api/v1/nakama/message [POST]
|
||||
// @returns nakama.MessageResponse
|
||||
func (h *Handler) HandleSendNakamaMessage(c echo.Context) error {
|
||||
type body struct {
|
||||
MessageType string `json:"messageType"`
|
||||
Payload interface{} `json:"payload"`
|
||||
PeerID string `json:"peerId,omitempty"` // If specified, send to specific peer (host only)
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var err error
|
||||
if b.PeerID != "" && h.App.Settings.GetNakama().IsHost {
|
||||
// Send to specific peer
|
||||
err = h.App.NakamaManager.SendMessageToPeer(b.PeerID, nakama.MessageType(b.MessageType), b.Payload)
|
||||
} else if h.App.Settings.GetNakama().IsHost {
|
||||
// Send to all peers
|
||||
err = h.App.NakamaManager.SendMessage(nakama.MessageType(b.MessageType), b.Payload)
|
||||
} else {
|
||||
// Send to host
|
||||
err = h.App.NakamaManager.SendMessageToHost(nakama.MessageType(b.MessageType), b.Payload)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
response := &nakama.MessageResponse{
|
||||
Success: true,
|
||||
Message: "Message sent successfully",
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, response)
|
||||
}
|
||||
|
||||
// HandleGetNakamaAnimeLibrary
|
||||
//
|
||||
// @summary shares the local anime collection with Nakama clients.
|
||||
// @desc This creates a new LibraryCollection struct and returns it.
|
||||
// @desc This is used to share the local anime collection with Nakama clients.
|
||||
// @route /api/v1/nakama/host/anime/library/collection [GET]
|
||||
// @returns nakama.NakamaAnimeLibrary
|
||||
func (h *Handler) HandleGetNakamaAnimeLibrary(c echo.Context) error {
|
||||
if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary {
|
||||
return h.RespondWithError(c, errors.New("host is not sharing its anime library"))
|
||||
}
|
||||
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if animeCollection == nil {
|
||||
return h.RespondWithData(c, &anime.LibraryCollection{})
|
||||
}
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds
|
||||
unsharedAnimeIdsMap := make(map[int]struct{})
|
||||
unsharedAnimeIdsMap[0] = struct{}{} // Do not share unmatched files
|
||||
for _, id := range unsharedAnimeIds {
|
||||
unsharedAnimeIdsMap[id] = struct{}{}
|
||||
}
|
||||
lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool {
|
||||
_, ok := unsharedAnimeIdsMap[lf.MediaId]
|
||||
return !ok
|
||||
})
|
||||
|
||||
libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
LocalFiles: lfs,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Hydrate total library size
|
||||
if libraryCollection != nil && libraryCollection.Stats != nil {
|
||||
libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, &nakama.NakamaAnimeLibrary{
|
||||
LocalFiles: lfs,
|
||||
AnimeCollection: animeCollection,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetNakamaAnimeLibraryCollection
|
||||
//
|
||||
// @summary shares the local anime collection with Nakama clients.
|
||||
// @desc This creates a new LibraryCollection struct and returns it.
|
||||
// @desc This is used to share the local anime collection with Nakama clients.
|
||||
// @route /api/v1/nakama/host/anime/library/collection [GET]
|
||||
// @returns anime.LibraryCollection
|
||||
func (h *Handler) HandleGetNakamaAnimeLibraryCollection(c echo.Context) error {
|
||||
if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary {
|
||||
return h.RespondWithError(c, errors.New("host is not sharing its anime library"))
|
||||
}
|
||||
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if animeCollection == nil {
|
||||
return h.RespondWithData(c, &anime.LibraryCollection{})
|
||||
}
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds
|
||||
unsharedAnimeIdsMap := make(map[int]struct{})
|
||||
unsharedAnimeIdsMap[0] = struct{}{}
|
||||
for _, id := range unsharedAnimeIds {
|
||||
unsharedAnimeIdsMap[id] = struct{}{}
|
||||
}
|
||||
lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool {
|
||||
_, ok := unsharedAnimeIdsMap[lf.MediaId]
|
||||
return !ok
|
||||
})
|
||||
|
||||
libraryCollection, err := anime.NewLibraryCollection(c.Request().Context(), &anime.NewLibraryCollectionOptions{
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
LocalFiles: lfs,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Hydrate total library size
|
||||
if libraryCollection != nil && libraryCollection.Stats != nil {
|
||||
libraryCollection.Stats.TotalSize = util.Bytes(h.App.TotalLibrarySize)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, libraryCollection)
|
||||
}
|
||||
|
||||
// HandleGetNakamaAnimeLibraryFiles
|
||||
//
|
||||
// @summary return the local files for the given AniList anime media id.
|
||||
// @desc This is used by the anime media entry pages to get all the data about the anime.
|
||||
// @route /api/v1/nakama/host/anime/library/files/{id} [POST]
|
||||
// @param id - int - true - "AniList anime media ID"
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleGetNakamaAnimeLibraryFiles(c echo.Context) error {
|
||||
if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary {
|
||||
return h.RespondWithError(c, errors.New("host is not sharing its anime library"))
|
||||
}
|
||||
|
||||
mId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds
|
||||
unsharedAnimeIdsMap := make(map[int]struct{})
|
||||
unsharedAnimeIdsMap[0] = struct{}{}
|
||||
for _, id := range unsharedAnimeIds {
|
||||
unsharedAnimeIdsMap[id] = struct{}{}
|
||||
}
|
||||
|
||||
retLfs := lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool {
|
||||
if _, ok := unsharedAnimeIdsMap[lf.MediaId]; ok {
|
||||
return false
|
||||
}
|
||||
return lf.MediaId == mId
|
||||
})
|
||||
|
||||
return h.RespondWithData(c, retLfs)
|
||||
}
|
||||
|
||||
// HandleGetNakamaAnimeAllLibraryFiles
|
||||
//
|
||||
// @summary return all the local files for the host.
|
||||
// @desc This is used to share the local anime collection with Nakama clients.
|
||||
// @route /api/v1/nakama/host/anime/library/files [POST]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleGetNakamaAnimeAllLibraryFiles(c echo.Context) error {
|
||||
if !h.App.Settings.GetNakama().HostShareLocalAnimeLibrary {
|
||||
return h.RespondWithError(c, errors.New("host is not sharing its anime library"))
|
||||
}
|
||||
|
||||
// Get all the local files
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
unsharedAnimeIds := h.App.Settings.GetNakama().HostUnsharedAnimeIds
|
||||
unsharedAnimeIdsMap := make(map[int]struct{})
|
||||
unsharedAnimeIdsMap[0] = struct{}{}
|
||||
for _, id := range unsharedAnimeIds {
|
||||
unsharedAnimeIdsMap[id] = struct{}{}
|
||||
}
|
||||
lfs = lo.Filter(lfs, func(lf *anime.LocalFile, _ int) bool {
|
||||
_, ok := unsharedAnimeIdsMap[lf.MediaId]
|
||||
return !ok
|
||||
})
|
||||
|
||||
return h.RespondWithData(c, lfs)
|
||||
}
|
||||
|
||||
// HandleNakamaPlayVideo
|
||||
//
|
||||
// @summary plays the media from the host.
|
||||
// @route /api/v1/nakama/play [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleNakamaPlayVideo(c echo.Context) error {
|
||||
type body struct {
|
||||
Path string `json:"path"`
|
||||
MediaId int `json:"mediaId"`
|
||||
AniDBEpisode string `json:"anidbEpisode"`
|
||||
}
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if !h.App.NakamaManager.IsConnectedToHost() {
|
||||
return h.RespondWithError(c, errors.New("not connected to host"))
|
||||
}
|
||||
|
||||
media, err := h.App.AnilistPlatform.GetAnime(c.Request().Context(), b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err = h.App.NakamaManager.PlayHostAnimeLibraryFile(b.Path, c.Request().Header.Get("User-Agent"), media, b.AniDBEpisode)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// Note: This is not used anymore. Each peer will independently stream the torrent.
|
||||
// route /api/v1/nakama/host/torrentstream/stream
|
||||
// Allows peers to stream the currently playing torrent.
|
||||
func (h *Handler) HandleNakamaHostTorrentstreamServeStream(c echo.Context) error {
|
||||
h.App.TorrentstreamRepository.HTTPStreamHandler().ServeHTTP(c.Response().Writer, c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
var videoProxyClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: false, // Fixes issues on Linux
|
||||
},
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// route /api/v1/nakama/host/debridstream/stream
|
||||
// Allows peers to stream the currently playing torrent.
|
||||
func (h *Handler) HandleNakamaHostDebridstreamServeStream(c echo.Context) error {
|
||||
streamUrl, ok := h.App.DebridClientRepository.GetStreamURL()
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "no stream url")
|
||||
}
|
||||
|
||||
// Proxy the stream to the peer
|
||||
// The debrid stream URL directly comes from the debrid service
|
||||
req, err := http.NewRequest(c.Request().Method, streamUrl, c.Request().Body)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request")
|
||||
}
|
||||
|
||||
// Copy original request headers to the proxied request
|
||||
for key, values := range c.Request().Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := videoProxyClient.Do(req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Response().Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the status code
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
|
||||
// Stream the response body
|
||||
_, err = io.Copy(c.Response().Writer, resp.Body)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to stream response body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// route /api/v1/nakama/host/debridstream/url
|
||||
// Returns the debrid stream URL for direct access by peers to avoid host bandwidth usage
|
||||
func (h *Handler) HandleNakamaHostGetDebridstreamURL(c echo.Context) error {
|
||||
streamUrl, ok := h.App.DebridClientRepository.GetStreamURL()
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "no stream url")
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, map[string]string{
|
||||
"streamUrl": streamUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// route /api/v1/nakama/host/anime/library/stream?path={base64_encoded_path}
|
||||
func (h *Handler) HandleNakamaHostAnimeLibraryServeStream(c echo.Context) error {
|
||||
filepath := c.QueryParam("path")
|
||||
decodedPath, err := base64.StdEncoding.DecodeString(filepath)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid path")
|
||||
}
|
||||
|
||||
h.App.Logger.Info().Msgf("nakama: Serving anime library file: %s", string(decodedPath))
|
||||
|
||||
// Make sure file is in library
|
||||
isInLibrary := false
|
||||
libraryPaths := h.App.Settings.GetLibrary().GetLibraryPaths()
|
||||
for _, libraryPath := range libraryPaths {
|
||||
if util.IsFileUnderDir(string(decodedPath), libraryPath) {
|
||||
isInLibrary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isInLibrary {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "file not in library")
|
||||
}
|
||||
|
||||
return c.File(string(decodedPath))
|
||||
}
|
||||
|
||||
// route /api/v1/nakama/stream
|
||||
// Proxies stream requests to the host. It inserts the Nakama password in the headers.
|
||||
// It checks if the password is valid.
|
||||
// For debrid streams, it redirects directly to the debrid service to avoid host bandwidth usage.
|
||||
func (h *Handler) HandleNakamaProxyStream(c echo.Context) error {
|
||||
|
||||
streamType := c.QueryParam("type") // "file", "torrent", "debrid"
|
||||
if streamType == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "type is required")
|
||||
}
|
||||
|
||||
hostServerUrl := h.App.Settings.GetNakama().RemoteServerURL
|
||||
hostServerUrl = strings.TrimSuffix(hostServerUrl, "/")
|
||||
|
||||
if streamType == "debrid" {
|
||||
// Get the debrid stream URL from the host
|
||||
urlEndpoint := hostServerUrl + "/api/v1/nakama/host/debridstream/url"
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, urlEndpoint, nil)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Str("url", urlEndpoint).Msg("nakama: Failed to create debrid URL request")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request")
|
||||
}
|
||||
|
||||
// Add Nakama password for authentication
|
||||
req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Str("url", urlEndpoint).Msg("nakama: Failed to get debrid stream URL")
|
||||
return echo.NewHTTPError(http.StatusBadGateway, "failed to get stream URL")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", urlEndpoint).Msg("nakama: Failed to get debrid stream URL")
|
||||
return echo.NewHTTPError(resp.StatusCode, "failed to get stream URL")
|
||||
}
|
||||
|
||||
// Parse the response to get the stream URL
|
||||
type urlResponse struct {
|
||||
Data struct {
|
||||
StreamUrl string `json:"streamUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
var urlResp urlResponse
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("nakama: Failed to read debrid URL response")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to read response")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &urlResp); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("nakama: Failed to parse debrid URL response")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to parse response")
|
||||
}
|
||||
|
||||
if urlResp.Data.StreamUrl == "" {
|
||||
h.App.Logger.Error().Msg("nakama: Empty debrid stream URL")
|
||||
return echo.NewHTTPError(http.StatusNotFound, "no stream URL available")
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(c.Request().Method, urlResp.Data.StreamUrl, c.Request().Body)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request")
|
||||
}
|
||||
|
||||
// Copy original request headers to the proxied request
|
||||
for key, values := range c.Request().Header {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err = videoProxyClient.Do(req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Response().Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the status code
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
|
||||
// Stream the response body
|
||||
_, err = io.Copy(c.Response().Writer, resp.Body)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to stream response body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
requestUrl := ""
|
||||
switch streamType {
|
||||
case "file":
|
||||
// Path should be base64 encoded
|
||||
filepath := c.QueryParam("path")
|
||||
if filepath == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "path is required")
|
||||
}
|
||||
requestUrl = hostServerUrl + "/api/v1/nakama/host/anime/library/stream?path=" + filepath
|
||||
case "torrent":
|
||||
requestUrl = hostServerUrl + "/api/v1/nakama/host/torrentstream/stream"
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid type")
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: true, // Disable keep-alive to prevent connection reuse issues
|
||||
ForceAttemptHTTP2: false,
|
||||
},
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
if c.Request().Method == http.MethodHead {
|
||||
req, err := http.NewRequest(http.MethodHead, requestUrl, nil)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to create HEAD request")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request")
|
||||
}
|
||||
|
||||
// Add Nakama password for authentication
|
||||
req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword)
|
||||
|
||||
// Add User-Agent from original request
|
||||
if userAgent := c.Request().Header.Get("User-Agent"); userAgent != "" {
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to proxy HEAD request")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to proxy request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Log authentication failures
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Msg("nakama: Authentication failed - check password configuration")
|
||||
}
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Response().Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return c.NoContent(resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create request with timeout context
|
||||
ctx := c.Request().Context()
|
||||
req, err := http.NewRequestWithContext(ctx, c.Request().Method, requestUrl, c.Request().Body)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: Failed to create request")
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create request")
|
||||
}
|
||||
|
||||
// Copy request headers but skip problematic ones
|
||||
for key, values := range c.Request().Header {
|
||||
// Skip headers that should not be forwarded or might cause errors
|
||||
if key == "Host" || key == "Content-Length" || key == "Connection" ||
|
||||
key == "Transfer-Encoding" || key == "Accept-Encoding" ||
|
||||
key == "Upgrade" || key == "Proxy-Connection" ||
|
||||
strings.HasPrefix(key, "Sec-") { // Skip WebSocket and security headers
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "*/*")
|
||||
// req.Header.Set("Accept-Encoding", "identity") // Disable compression to avoid issues
|
||||
|
||||
// Add Nakama password for authentication
|
||||
req.Header.Set("X-Seanime-Nakama-Token", h.App.Settings.GetNakama().RemoteServerPassword)
|
||||
|
||||
h.App.Logger.Debug().Str("url", requestUrl).Str("method", c.Request().Method).Msg("nakama: Proxying request")
|
||||
|
||||
// Add retry mechanism for intermittent network issues
|
||||
var resp *http.Response
|
||||
maxRetries := 3
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
resp, err = client.Do(req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
h.App.Logger.Warn().Err(err).Int("attempt", attempt+1).Str("url", requestUrl).Msg("nakama: request failed, retrying")
|
||||
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) // Exponential backoff
|
||||
continue
|
||||
}
|
||||
|
||||
h.App.Logger.Error().Err(err).Str("url", requestUrl).Msg("nakama: failed to proxy request after retries")
|
||||
return echo.NewHTTPError(http.StatusBadGateway, "failed to proxy request after retries")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Log authentication failures with more detail
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Msg("nakama: authentication failed - verify RemoteServerPassword matches host's HostPassword")
|
||||
}
|
||||
|
||||
// Log and handle 406 Not Acceptable errors
|
||||
if resp.StatusCode == http.StatusNotAcceptable {
|
||||
h.App.Logger.Error().Int("status", resp.StatusCode).Str("url", requestUrl).Str("content-type", resp.Header.Get("Content-Type")).Msg("nakama: 406 Not Acceptable - content negotiation failed")
|
||||
}
|
||||
|
||||
// Handle range request errors
|
||||
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
|
||||
h.App.Logger.Warn().Int("status", resp.StatusCode).Str("url", requestUrl).Str("range", c.Request().Header.Get("Range")).Msg("nakama: range request not satisfiable")
|
||||
}
|
||||
|
||||
// Copy response headers
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
c.Response().Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the status code
|
||||
c.Response().WriteHeader(resp.StatusCode)
|
||||
|
||||
// Stream the response body with better error handling
|
||||
bytesWritten, err := io.Copy(c.Response().Writer, resp.Body)
|
||||
if err != nil {
|
||||
// Check if it's a network-related error
|
||||
if strings.Contains(err.Error(), "connection") || strings.Contains(err.Error(), "broken pipe") ||
|
||||
strings.Contains(err.Error(), "wsasend") || strings.Contains(err.Error(), "reset by peer") {
|
||||
h.App.Logger.Warn().Err(err).Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: network connection error during streaming")
|
||||
} else {
|
||||
h.App.Logger.Error().Err(err).Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: error streaming response body")
|
||||
}
|
||||
// Don't return error here as response has already started
|
||||
} else {
|
||||
h.App.Logger.Debug().Int64("bytes_written", bytesWritten).Str("url", requestUrl).Msg("nakama: successfully streamed response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleNakamaReconnectToHost
|
||||
//
|
||||
// @summary reconnects to the Nakama host.
|
||||
// @desc This attempts to reconnect to the configured Nakama host if the connection was lost.
|
||||
// @route /api/v1/nakama/reconnect [POST]
|
||||
// @returns nakama.MessageResponse
|
||||
func (h *Handler) HandleNakamaReconnectToHost(c echo.Context) error {
|
||||
err := h.App.NakamaManager.ReconnectToHost()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
response := &nakama.MessageResponse{
|
||||
Success: true,
|
||||
Message: "Reconnection initiated",
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, response)
|
||||
}
|
||||
|
||||
// HandleNakamaRemoveStaleConnections
|
||||
//
|
||||
// @summary removes stale peer connections.
|
||||
// @desc This removes peer connections that haven't responded to ping messages for a while.
|
||||
// @route /api/v1/nakama/cleanup [POST]
|
||||
// @returns nakama.MessageResponse
|
||||
func (h *Handler) HandleNakamaRemoveStaleConnections(c echo.Context) error {
|
||||
if !h.App.Settings.GetNakama().IsHost {
|
||||
return h.RespondWithError(c, errors.New("not acting as host"))
|
||||
}
|
||||
|
||||
h.App.NakamaManager.RemoveStaleConnections()
|
||||
|
||||
response := &nakama.MessageResponse{
|
||||
Success: true,
|
||||
Message: "Stale connections cleaned up",
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, response)
|
||||
}
|
||||
|
||||
// HandleNakamaCreateWatchParty
|
||||
//
|
||||
// @summary creates a new watch party session.
|
||||
// @desc This creates a new watch party that peers can join to watch content together in sync.
|
||||
// @route /api/v1/nakama/watch-party/create [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleNakamaCreateWatchParty(c echo.Context) error {
|
||||
type body struct {
|
||||
Settings *nakama.WatchPartySessionSettings `json:"settings"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if !h.App.Settings.GetNakama().IsHost {
|
||||
return h.RespondWithError(c, errors.New("only hosts can create watch parties"))
|
||||
}
|
||||
|
||||
// Set default settings if not provided
|
||||
if b.Settings == nil {
|
||||
b.Settings = &nakama.WatchPartySessionSettings{
|
||||
SyncThreshold: 2.0,
|
||||
MaxBufferWaitTime: 10,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := h.App.NakamaManager.GetWatchPartyManager().CreateWatchParty(&nakama.CreateWatchOptions{
|
||||
Settings: b.Settings,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleNakamaJoinWatchParty
|
||||
//
|
||||
// @summary joins an existing watch party.
|
||||
// @desc This allows a peer to join an active watch party session.
|
||||
// @route /api/v1/nakama/watch-party/join [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleNakamaJoinWatchParty(c echo.Context) error {
|
||||
if h.App.Settings.GetNakama().IsHost {
|
||||
return h.RespondWithError(c, errors.New("hosts cannot join watch parties"))
|
||||
}
|
||||
|
||||
if !h.App.NakamaManager.IsConnectedToHost() {
|
||||
return h.RespondWithError(c, errors.New("not connected to host"))
|
||||
}
|
||||
|
||||
// Send join request to host
|
||||
err := h.App.NakamaManager.GetWatchPartyManager().JoinWatchParty()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleNakamaLeaveWatchParty
|
||||
//
|
||||
// @summary leaves the current watch party.
|
||||
// @desc This removes the user from the active watch party session.
|
||||
// @route /api/v1/nakama/watch-party/leave [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleNakamaLeaveWatchParty(c echo.Context) error {
|
||||
if h.App.Settings.GetNakama().IsHost {
|
||||
// Host stopping the watch party
|
||||
h.App.NakamaManager.GetWatchPartyManager().StopWatchParty()
|
||||
} else {
|
||||
// Peer leaving the watch party
|
||||
if !h.App.NakamaManager.IsConnectedToHost() {
|
||||
return h.RespondWithError(c, errors.New("not connected to host"))
|
||||
}
|
||||
|
||||
err := h.App.NakamaManager.GetWatchPartyManager().LeaveWatchParty()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
228
seanime-2.9.10/internal/handlers/onlinestream.go
Normal file
228
seanime-2.9.10/internal/handlers/onlinestream.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/onlinestream"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetOnlineStreamEpisodeList
|
||||
//
|
||||
// @summary returns the episode list for the given media and provider.
|
||||
// @desc It returns the episode list for the given media and provider.
|
||||
// @desc The episodes are cached using a file cache.
|
||||
// @desc The episode list is just a list of episodes with no video sources, it's what the client uses to display the episodes and subsequently fetch the sources.
|
||||
// @desc The episode list might be nil or empty if nothing could be found, but the media will always be returned.
|
||||
// @route /api/v1/onlinestream/episode-list [POST]
|
||||
// @returns onlinestream.EpisodeListResponse
|
||||
func (h *Handler) HandleGetOnlineStreamEpisodeList(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Dubbed bool `json:"dubbed"`
|
||||
Provider string `json:"provider,omitempty"` // Can be empty since we still have the media id
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if h.App.Settings == nil || !h.App.Settings.GetLibrary().EnableOnlinestream {
|
||||
return h.RespondWithError(c, errors.New("enable online streaming in the settings"))
|
||||
}
|
||||
|
||||
// Get media
|
||||
// This is cached
|
||||
media, err := h.App.OnlinestreamRepository.GetMedia(c.Request().Context(), b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if media.Status == nil || *media.Status == anilist.MediaStatusNotYetReleased {
|
||||
return h.RespondWithError(c, errors.New("unavailable"))
|
||||
}
|
||||
|
||||
// Get episode list
|
||||
// This is cached using file cache
|
||||
episodes, err := h.App.OnlinestreamRepository.GetMediaEpisodes(b.Provider, media, b.Dubbed)
|
||||
//if err != nil {
|
||||
// return h.RespondWithError(c, err)
|
||||
//}
|
||||
|
||||
ret := onlinestream.EpisodeListResponse{
|
||||
Episodes: episodes,
|
||||
Media: media,
|
||||
}
|
||||
|
||||
h.App.FillerManager.HydrateOnlinestreamFillerData(b.MediaId, ret.Episodes)
|
||||
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
// HandleGetOnlineStreamEpisodeSource
|
||||
//
|
||||
// @summary returns the video sources for the given media, episode number and provider.
|
||||
// @route /api/v1/onlinestream/episode-source [POST]
|
||||
// @returns onlinestream.EpisodeSource
|
||||
func (h *Handler) HandleGetOnlineStreamEpisodeSource(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
MediaId int `json:"mediaId"`
|
||||
Provider string `json:"provider"`
|
||||
Dubbed bool `json:"dubbed"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get media
|
||||
// This is cached
|
||||
media, err := h.App.OnlinestreamRepository.GetMedia(c.Request().Context(), b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
sources, err := h.App.OnlinestreamRepository.GetEpisodeSources(c.Request().Context(), b.Provider, b.MediaId, b.EpisodeNumber, b.Dubbed, media.GetStartYearSafe())
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, sources)
|
||||
}
|
||||
|
||||
// HandleOnlineStreamEmptyCache
|
||||
//
|
||||
// @summary empties the cache for the given media.
|
||||
// @route /api/v1/onlinestream/cache [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleOnlineStreamEmptyCache(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.OnlinestreamRepository.EmptyCache(b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandleOnlinestreamManualSearch
|
||||
//
|
||||
// @summary returns search results for a manual search.
|
||||
// @desc Returns search results for a manual search.
|
||||
// @route /api/v1/onlinestream/search [POST]
|
||||
// @returns []hibikeonlinestream.SearchResult
|
||||
func (h *Handler) HandleOnlinestreamManualSearch(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Provider string `json:"provider"`
|
||||
Query string `json:"query"`
|
||||
Dubbed bool `json:"dubbed"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
ret, err := h.App.OnlinestreamRepository.ManualSearch(b.Provider, b.Query, b.Dubbed)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
// HandleOnlinestreamManualMapping
|
||||
//
|
||||
// @summary manually maps an anime entry to an anime ID from the provider.
|
||||
// @desc This is used to manually map an anime entry to an anime ID from the provider.
|
||||
// @desc The client should re-fetch the chapter container after this.
|
||||
// @route /api/v1/onlinestream/manual-mapping [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleOnlinestreamManualMapping(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Provider string `json:"provider"`
|
||||
MediaId int `json:"mediaId"`
|
||||
AnimeId string `json:"animeId"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.OnlinestreamRepository.ManualMapping(b.Provider, b.MediaId, b.AnimeId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetOnlinestreamMapping
|
||||
//
|
||||
// @summary returns the mapping for an anime entry.
|
||||
// @desc This is used to get the mapping for an anime entry.
|
||||
// @desc An empty string is returned if there's no manual mapping. If there is, the anime ID will be returned.
|
||||
// @route /api/v1/onlinestream/get-mapping [POST]
|
||||
// @returns onlinestream.MappingResponse
|
||||
func (h *Handler) HandleGetOnlinestreamMapping(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.OnlinestreamRepository.GetMapping(b.Provider, b.MediaId)
|
||||
return h.RespondWithData(c, mapping)
|
||||
}
|
||||
|
||||
// HandleRemoveOnlinestreamMapping
|
||||
//
|
||||
// @summary removes the mapping for an anime entry.
|
||||
// @desc This is used to remove the mapping for an anime entry.
|
||||
// @desc The client should re-fetch the chapter container after this.
|
||||
// @route /api/v1/onlinestream/remove-mapping [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleRemoveOnlinestreamMapping(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.OnlinestreamRepository.RemoveMapping(b.Provider, b.MediaId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
232
seanime-2.9.10/internal/handlers/playback_manager.go
Normal file
232
seanime-2.9.10/internal/handlers/playback_manager.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandlePlaybackPlayVideo
|
||||
//
|
||||
// @summary plays the video with the given path using the default media player.
|
||||
// @desc This tells the Playback Manager to play the video using the default media player and start tracking progress.
|
||||
// @desc This returns 'true' if the video was successfully played.
|
||||
// @route /api/v1/playback-manager/play [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackPlayVideo(c echo.Context) error {
|
||||
type body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.PlaybackManager.StartPlayingUsingMediaPlayer(&playbackmanager.StartPlayingOptions{
|
||||
Payload: b.Path,
|
||||
UserAgent: c.Request().Header.Get("User-Agent"),
|
||||
ClientId: "",
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandlePlaybackPlayRandomVideo
|
||||
//
|
||||
// @summary plays a random, unwatched video using the default media player.
|
||||
// @desc This tells the Playback Manager to play a random, unwatched video using the media player and start tracking progress.
|
||||
// @desc It respects the user's progress data and will prioritize "current" and "repeating" media if they are many of them.
|
||||
// @desc This returns 'true' if the video was successfully played.
|
||||
// @route /api/v1/playback-manager/play-random [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackPlayRandomVideo(c echo.Context) error {
|
||||
|
||||
err := h.App.PlaybackManager.StartRandomVideo(&playbackmanager.StartRandomVideoOptions{
|
||||
UserAgent: c.Request().Header.Get("User-Agent"),
|
||||
ClientId: "",
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandlePlaybackSyncCurrentProgress
|
||||
//
|
||||
// @summary updates the AniList progress of the currently playing media.
|
||||
// @desc This is called after 'Update progress' is clicked when watching a media.
|
||||
// @desc This route returns the media ID of the currently playing media, so the client can refetch the media entry data.
|
||||
// @route /api/v1/playback-manager/sync-current-progress [POST]
|
||||
// @returns int
|
||||
func (h *Handler) HandlePlaybackSyncCurrentProgress(c echo.Context) error {
|
||||
|
||||
err := h.App.PlaybackManager.SyncCurrentProgress()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
mId, _ := h.App.PlaybackManager.GetCurrentMediaID()
|
||||
|
||||
return h.RespondWithData(c, mId)
|
||||
}
|
||||
|
||||
// HandlePlaybackPlayNextEpisode
|
||||
//
|
||||
// @summary plays the next episode of the currently playing media.
|
||||
// @desc This will play the next episode of the currently playing media.
|
||||
// @desc This is non-blocking so the client should prevent multiple calls until the next status is received.
|
||||
// @route /api/v1/playback-manager/next-episode [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackPlayNextEpisode(c echo.Context) error {
|
||||
|
||||
err := h.App.PlaybackManager.PlayNextEpisode()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandlePlaybackGetNextEpisode
|
||||
//
|
||||
// @summary gets the next episode of the currently playing media.
|
||||
// @desc This is used by the client's autoplay feature
|
||||
// @route /api/v1/playback-manager/next-episode [GET]
|
||||
// @returns *anime.LocalFile
|
||||
func (h *Handler) HandlePlaybackGetNextEpisode(c echo.Context) error {
|
||||
|
||||
lf := h.App.PlaybackManager.GetNextEpisode()
|
||||
return h.RespondWithData(c, lf)
|
||||
}
|
||||
|
||||
// HandlePlaybackAutoPlayNextEpisode
|
||||
//
|
||||
// @summary plays the next episode of the currently playing media.
|
||||
// @desc This will play the next episode of the currently playing media.
|
||||
// @route /api/v1/playback-manager/autoplay-next-episode [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackAutoPlayNextEpisode(c echo.Context) error {
|
||||
|
||||
err := h.App.PlaybackManager.AutoPlayNextEpisode()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandlePlaybackStartPlaylist
|
||||
//
|
||||
// @summary starts playing a playlist.
|
||||
// @desc The client should refetch playlists.
|
||||
// @route /api/v1/playback-manager/start-playlist [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackStartPlaylist(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
DbId uint `json:"dbId"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get playlist
|
||||
playlist, err := db_bridge.GetPlaylist(h.App.Database, b.DbId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err = h.App.PlaybackManager.StartPlaylist(playlist)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandlePlaybackCancelCurrentPlaylist
|
||||
//
|
||||
// @summary ends the current playlist.
|
||||
// @desc This will stop the current playlist. This is non-blocking.
|
||||
// @route /api/v1/playback-manager/cancel-playlist [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackCancelCurrentPlaylist(c echo.Context) error {
|
||||
|
||||
err := h.App.PlaybackManager.CancelCurrentPlaylist()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandlePlaybackPlaylistNext
|
||||
//
|
||||
// @summary moves to the next item in the current playlist.
|
||||
// @desc This is non-blocking so the client should prevent multiple calls until the next status is received.
|
||||
// @route /api/v1/playback-manager/playlist-next [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackPlaylistNext(c echo.Context) error {
|
||||
|
||||
err := h.App.PlaybackManager.RequestNextPlaylistFile()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// HandlePlaybackStartManualTracking
|
||||
//
|
||||
// @summary starts manual tracking of a media.
|
||||
// @desc Used for tracking progress of media that is not played through any integrated media player.
|
||||
// @desc This should only be used for trackable episodes (episodes that count towards progress).
|
||||
// @desc This returns 'true' if the tracking was successfully started.
|
||||
// @route /api/v1/playback-manager/manual-tracking/start [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackStartManualTracking(c echo.Context) error {
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
ClientId string `json:"clientId"`
|
||||
}
|
||||
b := new(body)
|
||||
if err := c.Bind(b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
err := h.App.PlaybackManager.StartManualProgressTracking(&playbackmanager.StartManualProgressTrackingOptions{
|
||||
ClientId: b.ClientId,
|
||||
MediaId: b.MediaId,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandlePlaybackCancelManualTracking
|
||||
//
|
||||
// @summary cancels manual tracking of a media.
|
||||
// @desc This will stop the server from expecting progress updates for the media.
|
||||
// @route /api/v1/playback-manager/manual-tracking/cancel [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandlePlaybackCancelManualTracking(c echo.Context) error {
|
||||
|
||||
h.App.PlaybackManager.CancelManualProgressTracking()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
185
seanime-2.9.10/internal/handlers/playlist.go
Normal file
185
seanime-2.9.10/internal/handlers/playlist.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/labstack/echo/v4"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/util"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// HandleCreatePlaylist
|
||||
//
|
||||
// @summary creates a new playlist.
|
||||
// @desc This will create a new playlist with the given name and local file paths.
|
||||
// @desc The response is ignored, the client should re-fetch the playlists after this.
|
||||
// @route /api/v1/playlist [POST]
|
||||
// @returns anime.Playlist
|
||||
func (h *Handler) HandleCreatePlaylist(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Name string `json:"name"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get the local files
|
||||
dbLfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Filter the local files
|
||||
lfs := make([]*anime.LocalFile, 0)
|
||||
for _, path := range b.Paths {
|
||||
for _, lf := range dbLfs {
|
||||
if lf.GetNormalizedPath() == util.NormalizePath(path) {
|
||||
lfs = append(lfs, lf)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the playlist
|
||||
playlist := anime.NewPlaylist(b.Name)
|
||||
playlist.SetLocalFiles(lfs)
|
||||
|
||||
// Save the playlist
|
||||
if err := db_bridge.SavePlaylist(h.App.Database, playlist); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, playlist)
|
||||
}
|
||||
|
||||
// HandleGetPlaylists
|
||||
//
|
||||
// @summary returns all playlists.
|
||||
// @route /api/v1/playlists [GET]
|
||||
// @returns []anime.Playlist
|
||||
func (h *Handler) HandleGetPlaylists(c echo.Context) error {
|
||||
|
||||
playlists, err := db_bridge.GetPlaylists(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, playlists)
|
||||
}
|
||||
|
||||
// HandleUpdatePlaylist
|
||||
//
|
||||
// @summary updates a playlist.
|
||||
// @returns the updated playlist
|
||||
// @desc The response is ignored, the client should re-fetch the playlists after this.
|
||||
// @route /api/v1/playlist [PATCH]
|
||||
// @param id - int - true - "The ID of the playlist to update."
|
||||
// @returns anime.Playlist
|
||||
func (h *Handler) HandleUpdatePlaylist(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
DbId uint `json:"dbId"`
|
||||
Name string `json:"name"`
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get the local files
|
||||
dbLfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Filter the local files
|
||||
lfs := make([]*anime.LocalFile, 0)
|
||||
for _, path := range b.Paths {
|
||||
for _, lf := range dbLfs {
|
||||
if lf.GetNormalizedPath() == util.NormalizePath(path) {
|
||||
lfs = append(lfs, lf)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate playlist
|
||||
playlist := anime.NewPlaylist(b.Name)
|
||||
playlist.DbId = b.DbId
|
||||
playlist.Name = b.Name
|
||||
playlist.SetLocalFiles(lfs)
|
||||
|
||||
// Save the playlist
|
||||
if err := db_bridge.UpdatePlaylist(h.App.Database, playlist); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, playlist)
|
||||
}
|
||||
|
||||
// HandleDeletePlaylist
|
||||
//
|
||||
// @summary deletes a playlist.
|
||||
// @route /api/v1/playlist [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDeletePlaylist(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
DbId uint `json:"dbId"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
|
||||
}
|
||||
|
||||
if err := db_bridge.DeletePlaylist(h.App.Database, b.DbId); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetPlaylistEpisodes
|
||||
//
|
||||
// @summary returns all the local files of a playlist media entry that have not been watched.
|
||||
// @route /api/v1/playlist/episodes/{id}/{progress} [GET]
|
||||
// @param id - int - true - "The ID of the media entry."
|
||||
// @param progress - int - true - "The progress of the media entry."
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleGetPlaylistEpisodes(c echo.Context) error {
|
||||
|
||||
lfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
lfw := anime.NewLocalFileWrapper(lfs)
|
||||
|
||||
// Params
|
||||
mId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
progress, err := strconv.Atoi(c.Param("progress"))
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
group, found := lfw.GetLocalEntryById(mId)
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("media entry not found"))
|
||||
}
|
||||
|
||||
toWatch := group.GetUnwatchedLocalFiles(progress)
|
||||
|
||||
return h.RespondWithData(c, toWatch)
|
||||
}
|
||||
167
seanime-2.9.10/internal/handlers/releases.go
Normal file
167
seanime-2.9.10/internal/handlers/releases.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"seanime/internal/updater"
|
||||
"seanime/internal/util/result"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// HandleInstallLatestUpdate
|
||||
//
|
||||
// @summary installs the latest update.
|
||||
// @desc This will install the latest update and launch the new version.
|
||||
// @route /api/v1/install-update [POST]
|
||||
// @returns handlers.Status
|
||||
func (h *Handler) HandleInstallLatestUpdate(c echo.Context) error {
|
||||
type body struct {
|
||||
FallbackDestination string `json:"fallback_destination"`
|
||||
}
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
h.App.SelfUpdater.StartSelfUpdate(b.FallbackDestination)
|
||||
}()
|
||||
|
||||
status := h.NewStatus(c)
|
||||
status.Updating = true
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
return h.RespondWithData(c, status)
|
||||
}
|
||||
|
||||
// HandleGetLatestUpdate
|
||||
//
|
||||
// @summary returns the latest update.
|
||||
// @desc This will return the latest update.
|
||||
// @desc If an error occurs, it will return an empty update.
|
||||
// @route /api/v1/latest-update [GET]
|
||||
// @returns updater.Update
|
||||
func (h *Handler) HandleGetLatestUpdate(c echo.Context) error {
|
||||
update, err := h.App.Updater.GetLatestUpdate()
|
||||
if err != nil {
|
||||
return h.RespondWithData(c, &updater.Update{})
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, update)
|
||||
}
|
||||
|
||||
type changelogItem struct {
|
||||
Version string `json:"version"`
|
||||
Lines []string `json:"lines"`
|
||||
}
|
||||
|
||||
var changelogCache = result.NewCache[string, []*changelogItem]()
|
||||
|
||||
// HandleGetChangelog
|
||||
//
|
||||
// @summary returns the changelog for versions greater than or equal to the given version.
|
||||
// @route /api/v1/changelog [GET]
|
||||
// @param before query string true "The version to get the changelog for."
|
||||
// @returns string
|
||||
func (h *Handler) HandleGetChangelog(c echo.Context) error {
|
||||
before := c.QueryParam("before")
|
||||
after := c.QueryParam("after")
|
||||
|
||||
key := fmt.Sprintf("%s-%s", before, after)
|
||||
|
||||
cached, ok := changelogCache.Get(key)
|
||||
if ok {
|
||||
return h.RespondWithData(c, cached)
|
||||
}
|
||||
|
||||
changelogBody, err := http.Get("https://raw.githubusercontent.com/5rahim/seanime/main/CHANGELOG.md")
|
||||
if err != nil {
|
||||
return h.RespondWithData(c, []*changelogItem{})
|
||||
}
|
||||
defer changelogBody.Body.Close()
|
||||
|
||||
changelog := []*changelogItem{}
|
||||
|
||||
scanner := bufio.NewScanner(changelogBody.Body)
|
||||
|
||||
var version string
|
||||
var body []string
|
||||
var blockOpen bool
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
if blockOpen {
|
||||
changelog = append(changelog, &changelogItem{
|
||||
Version: version,
|
||||
Lines: body,
|
||||
})
|
||||
}
|
||||
|
||||
version = strings.TrimPrefix(line, "## ")
|
||||
version = strings.TrimLeft(version, "v")
|
||||
body = []string{}
|
||||
blockOpen = true
|
||||
} else if blockOpen {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
|
||||
if blockOpen {
|
||||
changelog = append(changelog, &changelogItem{
|
||||
Version: version,
|
||||
Lines: body,
|
||||
})
|
||||
}
|
||||
|
||||
// e.g. get changelog after 2.7.0
|
||||
if after != "" {
|
||||
changelog = lo.Filter(changelog, func(item *changelogItem, index int) bool {
|
||||
afterVersion, err := semver.NewVersion(after)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
version, err := semver.NewVersion(item.Version)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return version.GreaterThan(afterVersion)
|
||||
})
|
||||
}
|
||||
|
||||
// e.g. get changelog before 2.7.0
|
||||
if before != "" {
|
||||
changelog = lo.Filter(changelog, func(item *changelogItem, index int) bool {
|
||||
beforeVersion, err := semver.NewVersion(before)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
version, err := semver.NewVersion(item.Version)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return version.LessThan(beforeVersion)
|
||||
})
|
||||
}
|
||||
|
||||
changelogCache.Set(key, changelog)
|
||||
|
||||
return h.RespondWithData(c, changelog)
|
||||
}
|
||||
93
seanime-2.9.10/internal/handlers/report.go
Normal file
93
seanime-2.9.10/internal/handlers/report.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/report"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleSaveIssueReport
|
||||
//
|
||||
// @summary saves the issue report in memory.
|
||||
// @route /api/v1/report/issue [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSaveIssueReport(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
ClickLogs []*report.ClickLog `json:"clickLogs"`
|
||||
NetworkLogs []*report.NetworkLog `json:"networkLogs"`
|
||||
ReactQueryLogs []*report.ReactQueryLog `json:"reactQueryLogs"`
|
||||
ConsoleLogs []*report.ConsoleLog `json:"consoleLogs"`
|
||||
IsAnimeLibraryIssue bool `json:"isAnimeLibraryIssue"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var localFiles []*anime.LocalFile
|
||||
if b.IsAnimeLibraryIssue {
|
||||
// Get local files
|
||||
var err error
|
||||
localFiles, _, err = db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
status := h.NewStatus(c)
|
||||
|
||||
if err := h.App.ReportRepository.SaveIssueReport(report.SaveIssueReportOptions{
|
||||
LogsDir: h.App.Config.Logs.Dir,
|
||||
UserAgent: c.Request().Header.Get("User-Agent"),
|
||||
ClickLogs: b.ClickLogs,
|
||||
NetworkLogs: b.NetworkLogs,
|
||||
ReactQueryLogs: b.ReactQueryLogs,
|
||||
ConsoleLogs: b.ConsoleLogs,
|
||||
Settings: h.App.Settings,
|
||||
DebridSettings: h.App.SecondarySettings.Debrid,
|
||||
IsAnimeLibraryIssue: b.IsAnimeLibraryIssue,
|
||||
LocalFiles: localFiles,
|
||||
ServerStatus: status,
|
||||
}); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleDownloadIssueReport
|
||||
//
|
||||
// @summary generates and downloads the issue report file.
|
||||
// @route /api/v1/report/issue/download [GET]
|
||||
// @returns report.IssueReport
|
||||
func (h *Handler) HandleDownloadIssueReport(c echo.Context) error {
|
||||
|
||||
issueReport, ok := h.App.ReportRepository.GetSavedIssueReport()
|
||||
if !ok {
|
||||
return h.RespondWithError(c, fmt.Errorf("no issue report found"))
|
||||
}
|
||||
|
||||
marshaledIssueReport, err := json.Marshal(issueReport)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, fmt.Errorf("failed to marshal issue report: %w", err))
|
||||
}
|
||||
|
||||
buffer := bytes.Buffer{}
|
||||
buffer.Write(marshaledIssueReport)
|
||||
|
||||
// Generate filename with current timestamp
|
||||
filename := fmt.Sprintf("issue_report_%s.json", time.Now().Format("2006-01-02_15-04-05"))
|
||||
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Response().Header().Set("Content-Type", "application/json")
|
||||
|
||||
return c.Stream(200, "application/json", &buffer)
|
||||
}
|
||||
27
seanime-2.9.10/internal/handlers/response.go
Normal file
27
seanime-2.9.10/internal/handlers/response.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package handlers
|
||||
|
||||
// SeaResponse is a generic response type for the API.
|
||||
// It is used to return data or errors.
|
||||
type SeaResponse[R any] struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Data R `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NewDataResponse[R any](data R) SeaResponse[R] {
|
||||
res := SeaResponse[R]{
|
||||
Data: data,
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func NewErrorResponse(err error) SeaResponse[any] {
|
||||
if err == nil {
|
||||
return SeaResponse[any]{
|
||||
Error: "Unknown error",
|
||||
}
|
||||
}
|
||||
res := SeaResponse[any]{
|
||||
Error: err.Error(),
|
||||
}
|
||||
return res
|
||||
}
|
||||
560
seanime-2.9.10/internal/handlers/routes.go
Normal file
560
seanime-2.9.10/internal/handlers/routes.go
Normal file
@@ -0,0 +1,560 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"seanime/internal/core"
|
||||
util "seanime/internal/util/proxies"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/ziflex/lecho/v3"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
App *core.App
|
||||
}
|
||||
|
||||
func InitRoutes(app *core.App, e *echo.Echo) {
|
||||
// CORS middleware
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Cookie", "Authorization",
|
||||
"X-Seanime-Token", "X-Seanime-Nakama-Token", "X-Seanime-Nakama-Username", "X-Seanime-Nakama-Server-Version", "X-Seanime-Nakama-Peer-Id"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
lechoLogger := lecho.From(*app.Logger)
|
||||
|
||||
urisToSkip := []string{
|
||||
"/internal/metrics",
|
||||
"/_next",
|
||||
"/icons",
|
||||
"/events",
|
||||
"/api/v1/image-proxy",
|
||||
"/api/v1/mediastream/transcode/",
|
||||
"/api/v1/torrent-client/list",
|
||||
"/api/v1/proxy",
|
||||
}
|
||||
|
||||
// Logging middleware
|
||||
e.Use(lecho.Middleware(lecho.Config{
|
||||
Logger: lechoLogger,
|
||||
Skipper: func(c echo.Context) bool {
|
||||
path := c.Request().URL.RequestURI()
|
||||
if filepath.Ext(c.Request().URL.Path) == ".txt" ||
|
||||
filepath.Ext(c.Request().URL.Path) == ".png" ||
|
||||
filepath.Ext(c.Request().URL.Path) == ".ico" {
|
||||
return true
|
||||
}
|
||||
for _, uri := range urisToSkip {
|
||||
if uri == path || strings.HasPrefix(path, uri) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
Enricher: func(c echo.Context, logger zerolog.Context) zerolog.Context {
|
||||
// Add which file the request came from
|
||||
return logger.Str("file", c.Path())
|
||||
},
|
||||
}))
|
||||
|
||||
// Recovery middleware
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
// Client ID middleware
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Check if the client has a UUID cookie
|
||||
cookie, err := c.Cookie("Seanime-Client-Id")
|
||||
|
||||
if err != nil || cookie.Value == "" {
|
||||
// Generate a new UUID for the client
|
||||
u := uuid.New().String()
|
||||
|
||||
// Create a cookie with the UUID
|
||||
newCookie := new(http.Cookie)
|
||||
newCookie.Name = "Seanime-Client-Id"
|
||||
newCookie.Value = u
|
||||
newCookie.HttpOnly = false // Make the cookie accessible via JS
|
||||
newCookie.Expires = time.Now().Add(24 * time.Hour)
|
||||
newCookie.Path = "/"
|
||||
newCookie.Domain = ""
|
||||
newCookie.SameSite = http.SameSiteDefaultMode
|
||||
newCookie.Secure = false
|
||||
|
||||
// Set the cookie
|
||||
c.SetCookie(newCookie)
|
||||
|
||||
// Store the UUID in the context for use in the request
|
||||
c.Set("Seanime-Client-Id", u)
|
||||
} else {
|
||||
// Store the existing UUID in the context for use in the request
|
||||
c.Set("Seanime-Client-Id", cookie.Value)
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
e.Use(headMethodMiddleware)
|
||||
|
||||
h := &Handler{App: app}
|
||||
|
||||
e.GET("/events", h.webSocketEventHandler)
|
||||
|
||||
v1 := e.Group("/api").Group("/v1") // Commented out for now, will be used later
|
||||
|
||||
//
|
||||
// Auth middleware
|
||||
//
|
||||
v1.Use(h.OptionalAuthMiddleware)
|
||||
|
||||
imageProxy := &util.ImageProxy{}
|
||||
v1.GET("/image-proxy", imageProxy.ProxyImage)
|
||||
|
||||
v1.GET("/proxy", util.VideoProxy)
|
||||
v1.HEAD("/proxy", util.VideoProxy)
|
||||
|
||||
v1.GET("/status", h.HandleGetStatus)
|
||||
v1.GET("/log/*", h.HandleGetLogContent)
|
||||
v1.GET("/logs/filenames", h.HandleGetLogFilenames)
|
||||
v1.DELETE("/logs", h.HandleDeleteLogs)
|
||||
v1.GET("/logs/latest", h.HandleGetLatestLogContent)
|
||||
|
||||
v1.GET("/memory/stats", h.HandleGetMemoryStats)
|
||||
v1.GET("/memory/profile", h.HandleGetMemoryProfile)
|
||||
v1.GET("/memory/goroutine", h.HandleGetGoRoutineProfile)
|
||||
v1.GET("/memory/cpu", h.HandleGetCPUProfile)
|
||||
v1.POST("/memory/gc", h.HandleForceGC)
|
||||
|
||||
v1.POST("/announcements", h.HandleGetAnnouncements)
|
||||
|
||||
// Auth
|
||||
v1.POST("/auth/login", h.HandleLogin)
|
||||
v1.POST("/auth/logout", h.HandleLogout)
|
||||
|
||||
// Settings
|
||||
v1.GET("/settings", h.HandleGetSettings)
|
||||
v1.PATCH("/settings", h.HandleSaveSettings)
|
||||
v1.POST("/start", h.HandleGettingStarted)
|
||||
v1.PATCH("/settings/auto-downloader", h.HandleSaveAutoDownloaderSettings)
|
||||
|
||||
// Auto Downloader
|
||||
v1.POST("/auto-downloader/run", h.HandleRunAutoDownloader)
|
||||
v1.GET("/auto-downloader/rule/:id", h.HandleGetAutoDownloaderRule)
|
||||
v1.GET("/auto-downloader/rule/anime/:id", h.HandleGetAutoDownloaderRulesByAnime)
|
||||
v1.GET("/auto-downloader/rules", h.HandleGetAutoDownloaderRules)
|
||||
v1.POST("/auto-downloader/rule", h.HandleCreateAutoDownloaderRule)
|
||||
v1.PATCH("/auto-downloader/rule", h.HandleUpdateAutoDownloaderRule)
|
||||
v1.DELETE("/auto-downloader/rule/:id", h.HandleDeleteAutoDownloaderRule)
|
||||
|
||||
v1.GET("/auto-downloader/items", h.HandleGetAutoDownloaderItems)
|
||||
v1.DELETE("/auto-downloader/item", h.HandleDeleteAutoDownloaderItem)
|
||||
|
||||
// Other
|
||||
v1.POST("/test-dump", h.HandleTestDump)
|
||||
|
||||
v1.POST("/directory-selector", h.HandleDirectorySelector)
|
||||
|
||||
v1.POST("/open-in-explorer", h.HandleOpenInExplorer)
|
||||
|
||||
v1.POST("/media-player/start", h.HandleStartDefaultMediaPlayer)
|
||||
|
||||
//
|
||||
// AniList
|
||||
//
|
||||
|
||||
v1Anilist := v1.Group("/anilist")
|
||||
|
||||
v1Anilist.GET("/collection", h.HandleGetAnimeCollection)
|
||||
v1Anilist.POST("/collection", h.HandleGetAnimeCollection)
|
||||
|
||||
v1Anilist.GET("/collection/raw", h.HandleGetRawAnimeCollection)
|
||||
v1Anilist.POST("/collection/raw", h.HandleGetRawAnimeCollection)
|
||||
|
||||
v1Anilist.GET("/media-details/:id", h.HandleGetAnilistAnimeDetails)
|
||||
|
||||
v1Anilist.GET("/studio-details/:id", h.HandleGetAnilistStudioDetails)
|
||||
|
||||
v1Anilist.POST("/list-entry", h.HandleEditAnilistListEntry)
|
||||
|
||||
v1Anilist.DELETE("/list-entry", h.HandleDeleteAnilistListEntry)
|
||||
|
||||
v1Anilist.POST("/list-anime", h.HandleAnilistListAnime)
|
||||
|
||||
v1Anilist.POST("/list-recent-anime", h.HandleAnilistListRecentAiringAnime)
|
||||
|
||||
v1Anilist.GET("/list-missed-sequels", h.HandleAnilistListMissedSequels)
|
||||
|
||||
v1Anilist.GET("/stats", h.HandleGetAniListStats)
|
||||
|
||||
//
|
||||
// MAL
|
||||
//
|
||||
|
||||
v1.POST("/mal/auth", h.HandleMALAuth)
|
||||
|
||||
v1.POST("/mal/logout", h.HandleMALLogout)
|
||||
|
||||
//
|
||||
// Library
|
||||
//
|
||||
|
||||
v1Library := v1.Group("/library")
|
||||
|
||||
v1Library.POST("/scan", h.HandleScanLocalFiles)
|
||||
|
||||
v1Library.DELETE("/empty-directories", h.HandleRemoveEmptyDirectories)
|
||||
|
||||
v1Library.GET("/local-files", h.HandleGetLocalFiles)
|
||||
v1Library.POST("/local-files", h.HandleLocalFileBulkAction)
|
||||
v1Library.PATCH("/local-files", h.HandleUpdateLocalFiles)
|
||||
v1Library.DELETE("/local-files", h.HandleDeleteLocalFiles)
|
||||
v1Library.GET("/local-files/dump", h.HandleDumpLocalFilesToFile)
|
||||
v1Library.POST("/local-files/import", h.HandleImportLocalFiles)
|
||||
v1Library.PATCH("/local-file", h.HandleUpdateLocalFileData)
|
||||
|
||||
v1Library.GET("/collection", h.HandleGetLibraryCollection)
|
||||
v1Library.GET("/schedule", h.HandleGetAnimeCollectionSchedule)
|
||||
|
||||
v1Library.GET("/scan-summaries", h.HandleGetScanSummaries)
|
||||
|
||||
v1Library.GET("/missing-episodes", h.HandleGetMissingEpisodes)
|
||||
|
||||
v1Library.GET("/anime-entry/:id", h.HandleGetAnimeEntry)
|
||||
v1Library.POST("/anime-entry/suggestions", h.HandleFetchAnimeEntrySuggestions)
|
||||
v1Library.POST("/anime-entry/manual-match", h.HandleAnimeEntryManualMatch)
|
||||
v1Library.PATCH("/anime-entry/bulk-action", h.HandleAnimeEntryBulkAction)
|
||||
v1Library.POST("/anime-entry/open-in-explorer", h.HandleOpenAnimeEntryInExplorer)
|
||||
v1Library.POST("/anime-entry/update-progress", h.HandleUpdateAnimeEntryProgress)
|
||||
v1Library.POST("/anime-entry/update-repeat", h.HandleUpdateAnimeEntryRepeat)
|
||||
v1Library.GET("/anime-entry/silence/:id", h.HandleGetAnimeEntrySilenceStatus)
|
||||
v1Library.POST("/anime-entry/silence", h.HandleToggleAnimeEntrySilenceStatus)
|
||||
|
||||
v1Library.POST("/unknown-media", h.HandleAddUnknownMedia)
|
||||
|
||||
//
|
||||
// Anime
|
||||
//
|
||||
v1.GET("/anime/episode-collection/:id", h.HandleGetAnimeEpisodeCollection)
|
||||
|
||||
//
|
||||
// Torrent / Torrent Client
|
||||
//
|
||||
|
||||
v1.POST("/torrent/search", h.HandleSearchTorrent)
|
||||
v1.POST("/torrent-client/download", h.HandleTorrentClientDownload)
|
||||
v1.GET("/torrent-client/list", h.HandleGetActiveTorrentList)
|
||||
v1.POST("/torrent-client/action", h.HandleTorrentClientAction)
|
||||
v1.POST("/torrent-client/rule-magnet", h.HandleTorrentClientAddMagnetFromRule)
|
||||
|
||||
//
|
||||
// Download
|
||||
//
|
||||
|
||||
v1.POST("/download-torrent-file", h.HandleDownloadTorrentFile)
|
||||
|
||||
//
|
||||
// Updates
|
||||
//
|
||||
|
||||
v1.GET("/latest-update", h.HandleGetLatestUpdate)
|
||||
v1.GET("/changelog", h.HandleGetChangelog)
|
||||
v1.POST("/install-update", h.HandleInstallLatestUpdate)
|
||||
v1.POST("/download-release", h.HandleDownloadRelease)
|
||||
|
||||
//
|
||||
// Theme
|
||||
//
|
||||
|
||||
v1.GET("/theme", h.HandleGetTheme)
|
||||
v1.PATCH("/theme", h.HandleUpdateTheme)
|
||||
|
||||
//
|
||||
// Playback Manager
|
||||
//
|
||||
|
||||
v1.POST("/playback-manager/sync-current-progress", h.HandlePlaybackSyncCurrentProgress)
|
||||
v1.POST("/playback-manager/start-playlist", h.HandlePlaybackStartPlaylist)
|
||||
v1.POST("/playback-manager/playlist-next", h.HandlePlaybackPlaylistNext)
|
||||
v1.POST("/playback-manager/cancel-playlist", h.HandlePlaybackCancelCurrentPlaylist)
|
||||
v1.POST("/playback-manager/next-episode", h.HandlePlaybackPlayNextEpisode)
|
||||
v1.GET("/playback-manager/next-episode", h.HandlePlaybackGetNextEpisode)
|
||||
v1.POST("/playback-manager/autoplay-next-episode", h.HandlePlaybackAutoPlayNextEpisode)
|
||||
v1.POST("/playback-manager/play", h.HandlePlaybackPlayVideo)
|
||||
v1.POST("/playback-manager/play-random", h.HandlePlaybackPlayRandomVideo)
|
||||
//------------
|
||||
v1.POST("/playback-manager/manual-tracking/start", h.HandlePlaybackStartManualTracking)
|
||||
v1.POST("/playback-manager/manual-tracking/cancel", h.HandlePlaybackCancelManualTracking)
|
||||
|
||||
//
|
||||
// Playlists
|
||||
//
|
||||
|
||||
v1.GET("/playlists", h.HandleGetPlaylists)
|
||||
v1.POST("/playlist", h.HandleCreatePlaylist)
|
||||
v1.PATCH("/playlist", h.HandleUpdatePlaylist)
|
||||
v1.DELETE("/playlist", h.HandleDeletePlaylist)
|
||||
v1.GET("/playlist/episodes/:id/:progress", h.HandleGetPlaylistEpisodes)
|
||||
|
||||
//
|
||||
// Onlinestream
|
||||
//
|
||||
|
||||
v1.POST("/onlinestream/episode-source", h.HandleGetOnlineStreamEpisodeSource)
|
||||
v1.POST("/onlinestream/episode-list", h.HandleGetOnlineStreamEpisodeList)
|
||||
v1.DELETE("/onlinestream/cache", h.HandleOnlineStreamEmptyCache)
|
||||
|
||||
v1.POST("/onlinestream/search", h.HandleOnlinestreamManualSearch)
|
||||
v1.POST("/onlinestream/manual-mapping", h.HandleOnlinestreamManualMapping)
|
||||
v1.POST("/onlinestream/get-mapping", h.HandleGetOnlinestreamMapping)
|
||||
v1.POST("/onlinestream/remove-mapping", h.HandleRemoveOnlinestreamMapping)
|
||||
|
||||
//
|
||||
// Metadata Provider
|
||||
//
|
||||
|
||||
v1.POST("/metadata-provider/filler", h.HandlePopulateFillerData)
|
||||
v1.DELETE("/metadata-provider/filler", h.HandleRemoveFillerData)
|
||||
|
||||
//
|
||||
// Manga
|
||||
//
|
||||
|
||||
v1Manga := v1.Group("/manga")
|
||||
v1Manga.POST("/anilist/collection", h.HandleGetAnilistMangaCollection)
|
||||
v1Manga.GET("/anilist/collection/raw", h.HandleGetRawAnilistMangaCollection)
|
||||
v1Manga.POST("/anilist/collection/raw", h.HandleGetRawAnilistMangaCollection)
|
||||
v1Manga.POST("/anilist/list", h.HandleAnilistListManga)
|
||||
v1Manga.GET("/collection", h.HandleGetMangaCollection)
|
||||
v1Manga.GET("/latest-chapter-numbers", h.HandleGetMangaLatestChapterNumbersMap)
|
||||
v1Manga.POST("/refetch-chapter-containers", h.HandleRefetchMangaChapterContainers)
|
||||
v1Manga.GET("/entry/:id", h.HandleGetMangaEntry)
|
||||
v1Manga.GET("/entry/:id/details", h.HandleGetMangaEntryDetails)
|
||||
v1Manga.DELETE("/entry/cache", h.HandleEmptyMangaEntryCache)
|
||||
v1Manga.POST("/chapters", h.HandleGetMangaEntryChapters)
|
||||
v1Manga.POST("/pages", h.HandleGetMangaEntryPages)
|
||||
v1Manga.POST("/update-progress", h.HandleUpdateMangaProgress)
|
||||
|
||||
v1Manga.GET("/downloaded-chapters/:id", h.HandleGetMangaEntryDownloadedChapters)
|
||||
v1Manga.GET("/downloads", h.HandleGetMangaDownloadsList)
|
||||
v1Manga.POST("/download-chapters", h.HandleDownloadMangaChapters)
|
||||
v1Manga.POST("/download-data", h.HandleGetMangaDownloadData)
|
||||
v1Manga.DELETE("/download-chapter", h.HandleDeleteMangaDownloadedChapters)
|
||||
v1Manga.GET("/download-queue", h.HandleGetMangaDownloadQueue)
|
||||
v1Manga.POST("/download-queue/start", h.HandleStartMangaDownloadQueue)
|
||||
v1Manga.POST("/download-queue/stop", h.HandleStopMangaDownloadQueue)
|
||||
v1Manga.DELETE("/download-queue", h.HandleClearAllChapterDownloadQueue)
|
||||
v1Manga.POST("/download-queue/reset-errored", h.HandleResetErroredChapterDownloadQueue)
|
||||
|
||||
v1Manga.POST("/search", h.HandleMangaManualSearch)
|
||||
v1Manga.POST("/manual-mapping", h.HandleMangaManualMapping)
|
||||
v1Manga.POST("/get-mapping", h.HandleGetMangaMapping)
|
||||
v1Manga.POST("/remove-mapping", h.HandleRemoveMangaMapping)
|
||||
|
||||
v1Manga.GET("/local-page/:path", h.HandleGetLocalMangaPage)
|
||||
|
||||
//
|
||||
// File Cache
|
||||
//
|
||||
|
||||
v1FileCache := v1.Group("/filecache")
|
||||
v1FileCache.GET("/total-size", h.HandleGetFileCacheTotalSize)
|
||||
v1FileCache.DELETE("/bucket", h.HandleRemoveFileCacheBucket)
|
||||
v1FileCache.GET("/mediastream/videofiles/total-size", h.HandleGetFileCacheMediastreamVideoFilesTotalSize)
|
||||
v1FileCache.DELETE("/mediastream/videofiles", h.HandleClearFileCacheMediastreamVideoFiles)
|
||||
|
||||
//
|
||||
// Discord
|
||||
//
|
||||
|
||||
v1Discord := v1.Group("/discord")
|
||||
v1Discord.POST("/presence/manga", h.HandleSetDiscordMangaActivity)
|
||||
v1Discord.POST("/presence/legacy-anime", h.HandleSetDiscordLegacyAnimeActivity)
|
||||
v1Discord.POST("/presence/anime", h.HandleSetDiscordAnimeActivityWithProgress)
|
||||
v1Discord.POST("/presence/anime-update", h.HandleUpdateDiscordAnimeActivityWithProgress)
|
||||
v1Discord.POST("/presence/cancel", h.HandleCancelDiscordActivity)
|
||||
|
||||
//
|
||||
// Media Stream
|
||||
//
|
||||
v1.GET("/mediastream/settings", h.HandleGetMediastreamSettings)
|
||||
v1.PATCH("/mediastream/settings", h.HandleSaveMediastreamSettings)
|
||||
v1.POST("/mediastream/request", h.HandleRequestMediastreamMediaContainer)
|
||||
v1.POST("/mediastream/preload", h.HandlePreloadMediastreamMediaContainer)
|
||||
// Transcode
|
||||
v1.POST("/mediastream/shutdown-transcode", h.HandleMediastreamShutdownTranscodeStream)
|
||||
v1.GET("/mediastream/transcode/*", h.HandleMediastreamTranscode)
|
||||
v1.GET("/mediastream/subs/*", h.HandleMediastreamGetSubtitles)
|
||||
v1.GET("/mediastream/att/*", h.HandleMediastreamGetAttachments)
|
||||
v1.GET("/mediastream/direct", h.HandleMediastreamDirectPlay)
|
||||
v1.HEAD("/mediastream/direct", h.HandleMediastreamDirectPlay)
|
||||
v1.GET("/mediastream/file", h.HandleMediastreamFile)
|
||||
|
||||
//
|
||||
// Direct Stream
|
||||
//
|
||||
v1.POST("/directstream/play/localfile", h.HandleDirectstreamPlayLocalFile)
|
||||
v1.GET("/directstream/stream", echo.WrapHandler(h.HandleDirectstreamGetStream()))
|
||||
v1.HEAD("/directstream/stream", echo.WrapHandler(h.HandleDirectstreamGetStream()))
|
||||
v1.GET("/directstream/att/*", h.HandleDirectstreamGetAttachments)
|
||||
|
||||
//
|
||||
// Torrent stream
|
||||
//
|
||||
v1.GET("/torrentstream/settings", h.HandleGetTorrentstreamSettings)
|
||||
v1.PATCH("/torrentstream/settings", h.HandleSaveTorrentstreamSettings)
|
||||
v1.POST("/torrentstream/start", h.HandleTorrentstreamStartStream)
|
||||
v1.POST("/torrentstream/stop", h.HandleTorrentstreamStopStream)
|
||||
v1.POST("/torrentstream/drop", h.HandleTorrentstreamDropTorrent)
|
||||
v1.POST("/torrentstream/torrent-file-previews", h.HandleGetTorrentstreamTorrentFilePreviews)
|
||||
v1.POST("/torrentstream/batch-history", h.HandleGetTorrentstreamBatchHistory)
|
||||
v1.GET("/torrentstream/stream/*", h.HandleTorrentstreamServeStream)
|
||||
|
||||
//
|
||||
// Extensions
|
||||
//
|
||||
|
||||
v1Extensions := v1.Group("/extensions")
|
||||
v1Extensions.POST("/playground/run", h.HandleRunExtensionPlaygroundCode)
|
||||
v1Extensions.POST("/external/fetch", h.HandleFetchExternalExtensionData)
|
||||
v1Extensions.POST("/external/install", h.HandleInstallExternalExtension)
|
||||
v1Extensions.POST("/external/uninstall", h.HandleUninstallExternalExtension)
|
||||
v1Extensions.POST("/external/edit-payload", h.HandleUpdateExtensionCode)
|
||||
v1Extensions.POST("/external/reload", h.HandleReloadExternalExtensions)
|
||||
v1Extensions.POST("/external/reload", h.HandleReloadExternalExtension)
|
||||
v1Extensions.POST("/all", h.HandleGetAllExtensions)
|
||||
v1Extensions.GET("/updates", h.HandleGetExtensionUpdateData)
|
||||
v1Extensions.GET("/list", h.HandleListExtensionData)
|
||||
v1Extensions.GET("/payload/:id", h.HandleGetExtensionPayload)
|
||||
v1Extensions.GET("/list/development", h.HandleListDevelopmentModeExtensions)
|
||||
v1Extensions.GET("/list/manga-provider", h.HandleListMangaProviderExtensions)
|
||||
v1Extensions.GET("/list/onlinestream-provider", h.HandleListOnlinestreamProviderExtensions)
|
||||
v1Extensions.GET("/list/anime-torrent-provider", h.HandleListAnimeTorrentProviderExtensions)
|
||||
v1Extensions.GET("/user-config/:id", h.HandleGetExtensionUserConfig)
|
||||
v1Extensions.POST("/user-config", h.HandleSaveExtensionUserConfig)
|
||||
v1Extensions.GET("/marketplace", h.HandleGetMarketplaceExtensions)
|
||||
v1Extensions.GET("/plugin-settings", h.HandleGetPluginSettings)
|
||||
v1Extensions.POST("/plugin-settings/pinned-trays", h.HandleSetPluginSettingsPinnedTrays)
|
||||
v1Extensions.POST("/plugin-permissions/grant", h.HandleGrantPluginPermissions)
|
||||
|
||||
//
|
||||
// Continuity
|
||||
//
|
||||
v1Continuity := v1.Group("/continuity")
|
||||
v1Continuity.PATCH("/item", h.HandleUpdateContinuityWatchHistoryItem)
|
||||
v1Continuity.GET("/item/:id", h.HandleGetContinuityWatchHistoryItem)
|
||||
v1Continuity.GET("/history", h.HandleGetContinuityWatchHistory)
|
||||
|
||||
//
|
||||
// Sync
|
||||
//
|
||||
v1Local := v1.Group("/local")
|
||||
v1Local.GET("/track", h.HandleLocalGetTrackedMediaItems)
|
||||
v1Local.POST("/track", h.HandleLocalAddTrackedMedia)
|
||||
v1Local.DELETE("/track", h.HandleLocalRemoveTrackedMedia)
|
||||
v1Local.GET("/track/:id/:type", h.HandleLocalGetIsMediaTracked)
|
||||
v1Local.POST("/local", h.HandleLocalSyncData)
|
||||
v1Local.GET("/queue", h.HandleLocalGetSyncQueueState)
|
||||
v1Local.POST("/anilist", h.HandleLocalSyncAnilistData)
|
||||
v1Local.POST("/updated", h.HandleLocalSetHasLocalChanges)
|
||||
v1Local.GET("/updated", h.HandleLocalGetHasLocalChanges)
|
||||
v1Local.GET("/storage/size", h.HandleLocalGetLocalStorageSize)
|
||||
v1Local.POST("/sync-simulated-to-anilist", h.HandleLocalSyncSimulatedDataToAnilist)
|
||||
|
||||
v1Local.POST("/offline", h.HandleSetOfflineMode)
|
||||
|
||||
//
|
||||
// Debrid
|
||||
//
|
||||
|
||||
v1.GET("/debrid/settings", h.HandleGetDebridSettings)
|
||||
v1.PATCH("/debrid/settings", h.HandleSaveDebridSettings)
|
||||
v1.POST("/debrid/torrents", h.HandleDebridAddTorrents)
|
||||
v1.POST("/debrid/torrents/download", h.HandleDebridDownloadTorrent)
|
||||
v1.POST("/debrid/torrents/cancel", h.HandleDebridCancelDownload)
|
||||
v1.DELETE("/debrid/torrent", h.HandleDebridDeleteTorrent)
|
||||
v1.GET("/debrid/torrents", h.HandleDebridGetTorrents)
|
||||
v1.POST("/debrid/torrents/info", h.HandleDebridGetTorrentInfo)
|
||||
v1.POST("/debrid/torrents/file-previews", h.HandleDebridGetTorrentFilePreviews)
|
||||
v1.POST("/debrid/stream/start", h.HandleDebridStartStream)
|
||||
v1.POST("/debrid/stream/cancel", h.HandleDebridCancelStream)
|
||||
|
||||
//
|
||||
// Report
|
||||
//
|
||||
|
||||
v1.POST("/report/issue", h.HandleSaveIssueReport)
|
||||
v1.GET("/report/issue/download", h.HandleDownloadIssueReport)
|
||||
|
||||
//
|
||||
// Nakama
|
||||
//
|
||||
|
||||
v1Nakama := v1.Group("/nakama")
|
||||
v1Nakama.GET("/ws", h.HandleNakamaWebSocket)
|
||||
v1Nakama.POST("/message", h.HandleSendNakamaMessage)
|
||||
v1Nakama.POST("/reconnect", h.HandleNakamaReconnectToHost)
|
||||
v1Nakama.POST("/cleanup", h.HandleNakamaRemoveStaleConnections)
|
||||
v1Nakama.GET("/host/anime/library", h.HandleGetNakamaAnimeLibrary)
|
||||
v1Nakama.GET("/host/anime/library/collection", h.HandleGetNakamaAnimeLibraryCollection)
|
||||
v1Nakama.GET("/host/anime/library/files/:id", h.HandleGetNakamaAnimeLibraryFiles)
|
||||
v1Nakama.GET("/host/anime/library/files", h.HandleGetNakamaAnimeAllLibraryFiles)
|
||||
v1Nakama.POST("/play", h.HandleNakamaPlayVideo)
|
||||
v1Nakama.GET("/host/torrentstream/stream", h.HandleNakamaHostTorrentstreamServeStream)
|
||||
v1Nakama.GET("/host/anime/library/stream", h.HandleNakamaHostAnimeLibraryServeStream)
|
||||
v1Nakama.GET("/host/debridstream/stream", h.HandleNakamaHostDebridstreamServeStream)
|
||||
v1Nakama.GET("/host/debridstream/url", h.HandleNakamaHostGetDebridstreamURL)
|
||||
v1Nakama.GET("/stream", h.HandleNakamaProxyStream)
|
||||
v1Nakama.POST("/watch-party/create", h.HandleNakamaCreateWatchParty)
|
||||
v1Nakama.POST("/watch-party/join", h.HandleNakamaJoinWatchParty)
|
||||
v1Nakama.POST("/watch-party/leave", h.HandleNakamaLeaveWatchParty)
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) JSON(c echo.Context, code int, i interface{}) error {
|
||||
return c.JSON(code, i)
|
||||
}
|
||||
|
||||
func (h *Handler) RespondWithData(c echo.Context, data interface{}) error {
|
||||
return c.JSON(200, NewDataResponse(data))
|
||||
}
|
||||
|
||||
func (h *Handler) RespondWithError(c echo.Context, err error) error {
|
||||
return c.JSON(500, NewErrorResponse(err))
|
||||
}
|
||||
|
||||
func headMethodMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Skip directstream route
|
||||
if strings.Contains(c.Request().URL.Path, "/directstream/stream") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if c.Request().Method == http.MethodHead {
|
||||
// Set the method to GET temporarily to reuse the handler
|
||||
c.Request().Method = http.MethodGet
|
||||
|
||||
defer func() {
|
||||
c.Request().Method = http.MethodHead
|
||||
}() // Restore method after
|
||||
|
||||
// Call the next handler and then clear the response body
|
||||
if err := next(c); err != nil {
|
||||
if err.Error() == echo.ErrMethodNotAllowed.Error() {
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
103
seanime-2.9.10/internal/handlers/scan.go
Normal file
103
seanime-2.9.10/internal/handlers/scan.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/library/scanner"
|
||||
"seanime/internal/library/summary"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleScanLocalFiles
|
||||
//
|
||||
// @summary scans the user's library.
|
||||
// @desc This will scan the user's library.
|
||||
// @desc The response is ignored, the client should re-fetch the library after this.
|
||||
// @route /api/v1/library/scan [POST]
|
||||
// @returns []anime.LocalFile
|
||||
func (h *Handler) HandleScanLocalFiles(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Enhanced bool `json:"enhanced"`
|
||||
SkipLockedFiles bool `json:"skipLockedFiles"`
|
||||
SkipIgnoredFiles bool `json:"skipIgnoredFiles"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Retrieve the user's library path
|
||||
libraryPath, err := h.App.Database.GetLibraryPathFromSettings()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
additionalLibraryPaths, err := h.App.Database.GetAdditionalLibraryPathsFromSettings()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get the latest local files
|
||||
existingLfs, _, err := db_bridge.GetLocalFiles(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// +---------------------+
|
||||
// | Scanner |
|
||||
// +---------------------+
|
||||
|
||||
// Create scan summary logger
|
||||
scanSummaryLogger := summary.NewScanSummaryLogger()
|
||||
|
||||
// Create a new scan logger
|
||||
scanLogger, err := scanner.NewScanLogger(h.App.Config.Logs.Dir)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
defer scanLogger.Done()
|
||||
|
||||
// Create a new scanner
|
||||
sc := scanner.Scanner{
|
||||
DirPath: libraryPath,
|
||||
OtherDirPaths: additionalLibraryPaths,
|
||||
Enhanced: b.Enhanced,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
Logger: h.App.Logger,
|
||||
WSEventManager: h.App.WSEventManager,
|
||||
ExistingLocalFiles: existingLfs,
|
||||
SkipLockedFiles: b.SkipLockedFiles,
|
||||
SkipIgnoredFiles: b.SkipIgnoredFiles,
|
||||
ScanSummaryLogger: scanSummaryLogger,
|
||||
ScanLogger: scanLogger,
|
||||
MetadataProvider: h.App.MetadataProvider,
|
||||
MatchingAlgorithm: h.App.Settings.GetLibrary().ScannerMatchingAlgorithm,
|
||||
MatchingThreshold: h.App.Settings.GetLibrary().ScannerMatchingThreshold,
|
||||
}
|
||||
|
||||
// Scan the library
|
||||
allLfs, err := sc.Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, scanner.ErrNoLocalFiles) {
|
||||
return h.RespondWithData(c, []interface{}{})
|
||||
} else {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the local files
|
||||
lfs, err := db_bridge.InsertLocalFiles(h.App.Database, allLfs)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Save the scan summary
|
||||
_ = db_bridge.InsertScanSummary(h.App.Database, scanSummaryLogger.GenerateSummary())
|
||||
|
||||
go h.App.AutoDownloader.CleanUpDownloadedItems()
|
||||
|
||||
return h.RespondWithData(c, lfs)
|
||||
|
||||
}
|
||||
22
seanime-2.9.10/internal/handlers/scan_summary.go
Normal file
22
seanime-2.9.10/internal/handlers/scan_summary.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/database/db_bridge"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetScanSummaries
|
||||
//
|
||||
// @summary returns the latest scan summaries.
|
||||
// @route /api/v1/library/scan-summaries [GET]
|
||||
// @returns []summary.ScanSummaryItem
|
||||
func (h *Handler) HandleGetScanSummaries(c echo.Context) error {
|
||||
|
||||
sm, err := db_bridge.GetScanSummaries(h.App.Database)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, sm)
|
||||
}
|
||||
87
seanime-2.9.10/internal/handlers/server_auth.go
Normal file
87
seanime-2.9.10/internal/handlers/server_auth.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func (h *Handler) OptionalAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if h.App.Config.Server.Password == "" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
path := c.Request().URL.Path
|
||||
passwordHash := c.Request().Header.Get("X-Seanime-Token")
|
||||
|
||||
// Allow the following paths to be accessed by anyone
|
||||
if path == "/api/v1/auth/login" || // for auth
|
||||
path == "/api/v1/auth/logout" || // for auth
|
||||
path == "/api/v1/status" || // for interface
|
||||
path == "/events" || // for server events
|
||||
strings.HasPrefix(path, "/api/v1/directstream") || // used by media players
|
||||
// strings.HasPrefix(path, "/api/v1/mediastream") || // used by media players // NODE: DO NOT
|
||||
strings.HasPrefix(path, "/api/v1/mediastream/att/") || // used by media players
|
||||
strings.HasPrefix(path, "/api/v1/mediastream/direct") || // used by media players
|
||||
strings.HasPrefix(path, "/api/v1/mediastream/transcode/") || // used by media players
|
||||
strings.HasPrefix(path, "/api/v1/mediastream/subs/") || // used by media players
|
||||
strings.HasPrefix(path, "/api/v1/image-proxy") || // used by img tag
|
||||
strings.HasPrefix(path, "/api/v1/proxy") || // used by video players
|
||||
strings.HasPrefix(path, "/api/v1/manga/local-page") || // used by img tag
|
||||
strings.HasPrefix(path, "/api/v1/torrentstream/stream/") || // accessible by media players
|
||||
strings.HasPrefix(path, "/api/v1/nakama/stream") { // accessible by media players
|
||||
|
||||
if path == "/api/v1/status" {
|
||||
// allow status requests by anyone but mark as unauthenticated
|
||||
// so we can filter out critical info like settings
|
||||
if passwordHash != h.App.ServerPasswordHash {
|
||||
c.Set("unauthenticated", true)
|
||||
}
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if passwordHash == h.App.ServerPasswordHash {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check HMAC token in query parameter
|
||||
token := c.Request().URL.Query().Get("token")
|
||||
if token != "" {
|
||||
hmacAuth := h.App.GetServerPasswordHMACAuth()
|
||||
_, err := hmacAuth.ValidateToken(token, path)
|
||||
if err == nil {
|
||||
return next(c)
|
||||
} else {
|
||||
h.App.Logger.Debug().Err(err).Str("path", path).Msg("server auth: HMAC token validation failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Nakama client connections
|
||||
if h.App.Settings.GetNakama().Enabled && h.App.Settings.GetNakama().IsHost {
|
||||
// Verify the Nakama host password in the client request
|
||||
nakamaPasswordHeader := c.Request().Header.Get("X-Seanime-Nakama-Token")
|
||||
|
||||
// Allow WebSocket connections for peer-to-host communication
|
||||
if path == "/api/v1/nakama/ws" {
|
||||
if nakamaPasswordHeader == h.App.Settings.GetNakama().HostPassword {
|
||||
c.Response().Header().Set("X-Seanime-Nakama-Is-Client", "true")
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow the following paths to be accessed by Nakama clients
|
||||
if strings.HasPrefix(path, "/api/v1/nakama/host/") {
|
||||
if nakamaPasswordHeader == h.App.Settings.GetNakama().HostPassword {
|
||||
c.Response().Header().Set("X-Seanime-Nakama-Is-Client", "true")
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithError(c, errors.New("UNAUTHENTICATED"))
|
||||
}
|
||||
}
|
||||
301
seanime-2.9.10/internal/handlers/settings.go
Normal file
301
seanime-2.9.10/internal/handlers/settings.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// HandleGetSettings
|
||||
//
|
||||
// @summary returns the app settings.
|
||||
// @route /api/v1/settings [GET]
|
||||
// @returns models.Settings
|
||||
func (h *Handler) HandleGetSettings(c echo.Context) error {
|
||||
|
||||
settings, err := h.App.Database.GetSettings()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
if settings.ID == 0 {
|
||||
return h.RespondWithError(c, errors.New(runtime.GOOS))
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, settings)
|
||||
}
|
||||
|
||||
// HandleGettingStarted
|
||||
//
|
||||
// @summary updates the app settings.
|
||||
// @desc This will update the app settings.
|
||||
// @desc The client should re-fetch the server status after this.
|
||||
// @route /api/v1/start [POST]
|
||||
// @returns handlers.Status
|
||||
func (h *Handler) HandleGettingStarted(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Library models.LibrarySettings `json:"library"`
|
||||
MediaPlayer models.MediaPlayerSettings `json:"mediaPlayer"`
|
||||
Torrent models.TorrentSettings `json:"torrent"`
|
||||
Anilist models.AnilistSettings `json:"anilist"`
|
||||
Discord models.DiscordSettings `json:"discord"`
|
||||
Manga models.MangaSettings `json:"manga"`
|
||||
Notifications models.NotificationSettings `json:"notifications"`
|
||||
Nakama models.NakamaSettings `json:"nakama"`
|
||||
EnableTranscode bool `json:"enableTranscode"`
|
||||
EnableTorrentStreaming bool `json:"enableTorrentStreaming"`
|
||||
DebridProvider string `json:"debridProvider"`
|
||||
DebridApiKey string `json:"debridApiKey"`
|
||||
}
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Check settings
|
||||
if b.Library.LibraryPaths == nil {
|
||||
b.Library.LibraryPaths = []string{}
|
||||
}
|
||||
b.Library.LibraryPath = filepath.ToSlash(b.Library.LibraryPath)
|
||||
|
||||
settings, err := h.App.Database.UpsertSettings(&models.Settings{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: 1,
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Library: &b.Library,
|
||||
MediaPlayer: &b.MediaPlayer,
|
||||
Torrent: &b.Torrent,
|
||||
Anilist: &b.Anilist,
|
||||
Discord: &b.Discord,
|
||||
Manga: &b.Manga,
|
||||
Notifications: &b.Notifications,
|
||||
Nakama: &b.Nakama,
|
||||
AutoDownloader: &models.AutoDownloaderSettings{
|
||||
Provider: b.Library.TorrentProvider,
|
||||
Interval: 20,
|
||||
Enabled: false,
|
||||
DownloadAutomatically: true,
|
||||
EnableEnhancedQueries: true,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.EnableTorrentStreaming {
|
||||
go func() {
|
||||
defer util.HandlePanicThen(func() {})
|
||||
prev, found := h.App.Database.GetTorrentstreamSettings()
|
||||
if found {
|
||||
prev.Enabled = true
|
||||
//prev.IncludeInLibrary = true
|
||||
_, _ = h.App.Database.UpsertTorrentstreamSettings(prev)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if b.EnableTranscode {
|
||||
go func() {
|
||||
defer util.HandlePanicThen(func() {})
|
||||
prev, found := h.App.Database.GetMediastreamSettings()
|
||||
if found {
|
||||
prev.TranscodeEnabled = true
|
||||
_, _ = h.App.Database.UpsertMediastreamSettings(prev)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if b.DebridProvider != "" && b.DebridProvider != "none" {
|
||||
go func() {
|
||||
defer util.HandlePanicThen(func() {})
|
||||
prev, found := h.App.Database.GetDebridSettings()
|
||||
if found {
|
||||
prev.Enabled = true
|
||||
prev.Provider = b.DebridProvider
|
||||
prev.ApiKey = b.DebridApiKey
|
||||
//prev.IncludeDebridStreamInLibrary = true
|
||||
_, _ = h.App.Database.UpsertDebridSettings(prev)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
h.App.WSEventManager.SendEvent("settings", settings)
|
||||
|
||||
status := h.NewStatus(c)
|
||||
|
||||
// Refresh modules that depend on the settings
|
||||
h.App.InitOrRefreshModules()
|
||||
|
||||
return h.RespondWithData(c, status)
|
||||
}
|
||||
|
||||
// HandleSaveSettings
|
||||
//
|
||||
// @summary updates the app settings.
|
||||
// @desc This will update the app settings.
|
||||
// @desc The client should re-fetch the server status after this.
|
||||
// @route /api/v1/settings [PATCH]
|
||||
// @returns handlers.Status
|
||||
func (h *Handler) HandleSaveSettings(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Library models.LibrarySettings `json:"library"`
|
||||
MediaPlayer models.MediaPlayerSettings `json:"mediaPlayer"`
|
||||
Torrent models.TorrentSettings `json:"torrent"`
|
||||
Anilist models.AnilistSettings `json:"anilist"`
|
||||
Discord models.DiscordSettings `json:"discord"`
|
||||
Manga models.MangaSettings `json:"manga"`
|
||||
Notifications models.NotificationSettings `json:"notifications"`
|
||||
Nakama models.NakamaSettings `json:"nakama"`
|
||||
}
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.Library.LibraryPath != "" {
|
||||
b.Library.LibraryPath = filepath.ToSlash(filepath.Clean(b.Library.LibraryPath))
|
||||
}
|
||||
|
||||
if b.Library.LibraryPaths == nil || b.Library.LibraryPath == "" {
|
||||
b.Library.LibraryPaths = []string{}
|
||||
}
|
||||
|
||||
for i, path := range b.Library.LibraryPaths {
|
||||
b.Library.LibraryPaths[i] = filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
|
||||
b.Library.LibraryPaths = lo.Filter(b.Library.LibraryPaths, func(s string, _ int) bool {
|
||||
if s == "" || util.IsSameDir(s, b.Library.LibraryPath) {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.IsDir()
|
||||
})
|
||||
|
||||
// Check that any library paths are not subdirectories of each other
|
||||
for i, path1 := range b.Library.LibraryPaths {
|
||||
if util.IsSubdirectory(b.Library.LibraryPath, path1) || util.IsSubdirectory(path1, b.Library.LibraryPath) {
|
||||
return h.RespondWithError(c, errors.New("library paths cannot be subdirectories of each other"))
|
||||
}
|
||||
for j, path2 := range b.Library.LibraryPaths {
|
||||
if i != j && util.IsSubdirectory(path1, path2) {
|
||||
return h.RespondWithError(c, errors.New("library paths cannot be subdirectories of each other"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autoDownloaderSettings := models.AutoDownloaderSettings{}
|
||||
prevSettings, err := h.App.Database.GetSettings()
|
||||
if err == nil && prevSettings.AutoDownloader != nil {
|
||||
autoDownloaderSettings = *prevSettings.AutoDownloader
|
||||
}
|
||||
// Disable auto-downloader if the torrent provider is set to none
|
||||
if b.Library.TorrentProvider == torrent.ProviderNone && autoDownloaderSettings.Enabled {
|
||||
h.App.Logger.Debug().Msg("app: Disabling auto-downloader because the torrent provider is set to none")
|
||||
autoDownloaderSettings.Enabled = false
|
||||
}
|
||||
|
||||
settings, err := h.App.Database.UpsertSettings(&models.Settings{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: 1,
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
Library: &b.Library,
|
||||
MediaPlayer: &b.MediaPlayer,
|
||||
Torrent: &b.Torrent,
|
||||
Anilist: &b.Anilist,
|
||||
Manga: &b.Manga,
|
||||
Discord: &b.Discord,
|
||||
Notifications: &b.Notifications,
|
||||
Nakama: &b.Nakama,
|
||||
AutoDownloader: &autoDownloaderSettings,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.WSEventManager.SendEvent("settings", settings)
|
||||
|
||||
status := h.NewStatus(c)
|
||||
|
||||
// Refresh modules that depend on the settings
|
||||
h.App.InitOrRefreshModules()
|
||||
|
||||
return h.RespondWithData(c, status)
|
||||
}
|
||||
|
||||
// HandleSaveAutoDownloaderSettings
|
||||
//
|
||||
// @summary updates the auto-downloader settings.
|
||||
// @route /api/v1/settings/auto-downloader [PATCH]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleSaveAutoDownloaderSettings(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Interval int `json:"interval"`
|
||||
Enabled bool `json:"enabled"`
|
||||
DownloadAutomatically bool `json:"downloadAutomatically"`
|
||||
EnableEnhancedQueries bool `json:"enableEnhancedQueries"`
|
||||
EnableSeasonCheck bool `json:"enableSeasonCheck"`
|
||||
UseDebrid bool `json:"useDebrid"`
|
||||
}
|
||||
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
currSettings, err := h.App.Database.GetSettings()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Validation
|
||||
if b.Interval < 15 {
|
||||
return h.RespondWithError(c, errors.New("interval must be at least 15 minutes"))
|
||||
}
|
||||
|
||||
autoDownloaderSettings := &models.AutoDownloaderSettings{
|
||||
Provider: currSettings.Library.TorrentProvider,
|
||||
Interval: b.Interval,
|
||||
Enabled: b.Enabled,
|
||||
DownloadAutomatically: b.DownloadAutomatically,
|
||||
EnableEnhancedQueries: b.EnableEnhancedQueries,
|
||||
EnableSeasonCheck: b.EnableSeasonCheck,
|
||||
UseDebrid: b.UseDebrid,
|
||||
}
|
||||
|
||||
currSettings.AutoDownloader = autoDownloaderSettings
|
||||
currSettings.BaseModel = models.BaseModel{
|
||||
ID: 1,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = h.App.Database.UpsertSettings(currSettings)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Update Auto Downloader - This runs in a goroutine
|
||||
h.App.AutoDownloader.SetSettings(autoDownloaderSettings, currSettings.Library.TorrentProvider)
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
599
seanime-2.9.10/internal/handlers/status.go
Normal file
599
seanime-2.9.10/internal/handlers/status.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"seanime/internal/constants"
|
||||
"seanime/internal/core"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/user"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// Status is a struct containing the user data, settings, and OS.
|
||||
// It is used by the client in various places to access necessary information.
|
||||
type Status struct {
|
||||
OS string `json:"os"`
|
||||
ClientDevice string `json:"clientDevice"`
|
||||
ClientPlatform string `json:"clientPlatform"`
|
||||
ClientUserAgent string `json:"clientUserAgent"`
|
||||
DataDir string `json:"dataDir"`
|
||||
User *user.User `json:"user"`
|
||||
Settings *models.Settings `json:"settings"`
|
||||
Version string `json:"version"`
|
||||
VersionName string `json:"versionName"`
|
||||
ThemeSettings *models.Theme `json:"themeSettings"`
|
||||
IsOffline bool `json:"isOffline"`
|
||||
MediastreamSettings *models.MediastreamSettings `json:"mediastreamSettings"`
|
||||
TorrentstreamSettings *models.TorrentstreamSettings `json:"torrentstreamSettings"`
|
||||
DebridSettings *models.DebridSettings `json:"debridSettings"`
|
||||
AnilistClientID string `json:"anilistClientId"`
|
||||
Updating bool `json:"updating"` // If true, a new screen will be displayed
|
||||
IsDesktopSidecar bool `json:"isDesktopSidecar"` // The server is running as a desktop sidecar
|
||||
FeatureFlags core.FeatureFlags `json:"featureFlags"`
|
||||
ServerReady bool `json:"serverReady"`
|
||||
ServerHasPassword bool `json:"serverHasPassword"`
|
||||
}
|
||||
|
||||
var clientInfoCache = result.NewResultMap[string, util.ClientInfo]()
|
||||
|
||||
// NewStatus returns a new Status struct.
|
||||
// It uses the RouteCtx to get the App instance containing the Database instance.
|
||||
func (h *Handler) NewStatus(c echo.Context) *Status {
|
||||
var dbAcc *models.Account
|
||||
var currentUser *user.User
|
||||
var settings *models.Settings
|
||||
var theme *models.Theme
|
||||
//var mal *models.Mal
|
||||
|
||||
// Get the user from the database (if logged in)
|
||||
if dbAcc, _ = h.App.Database.GetAccount(); dbAcc != nil {
|
||||
currentUser, _ = user.NewUser(dbAcc)
|
||||
if currentUser != nil {
|
||||
currentUser.Token = "HIDDEN"
|
||||
}
|
||||
} else {
|
||||
// If the user is not logged in, create a simulated user
|
||||
currentUser = user.NewSimulatedUser()
|
||||
}
|
||||
|
||||
if settings, _ = h.App.Database.GetSettings(); settings != nil {
|
||||
if settings.ID == 0 || settings.Library == nil || settings.Torrent == nil || settings.MediaPlayer == nil {
|
||||
settings = nil
|
||||
}
|
||||
}
|
||||
|
||||
clientInfo, found := clientInfoCache.Get(c.Request().UserAgent())
|
||||
if !found {
|
||||
clientInfo = util.GetClientInfo(c.Request().UserAgent())
|
||||
clientInfoCache.Set(c.Request().UserAgent(), clientInfo)
|
||||
}
|
||||
|
||||
theme, _ = h.App.Database.GetTheme()
|
||||
|
||||
status := &Status{
|
||||
OS: runtime.GOOS,
|
||||
ClientDevice: clientInfo.Device,
|
||||
ClientPlatform: clientInfo.Platform,
|
||||
DataDir: h.App.Config.Data.AppDataDir,
|
||||
ClientUserAgent: c.Request().UserAgent(),
|
||||
User: currentUser,
|
||||
Settings: settings,
|
||||
Version: h.App.Version,
|
||||
VersionName: constants.VersionName,
|
||||
ThemeSettings: theme,
|
||||
IsOffline: h.App.Config.Server.Offline,
|
||||
MediastreamSettings: h.App.SecondarySettings.Mediastream,
|
||||
TorrentstreamSettings: h.App.SecondarySettings.Torrentstream,
|
||||
DebridSettings: h.App.SecondarySettings.Debrid,
|
||||
AnilistClientID: h.App.Config.Anilist.ClientID,
|
||||
Updating: false,
|
||||
IsDesktopSidecar: h.App.IsDesktopSidecar,
|
||||
FeatureFlags: h.App.FeatureFlags,
|
||||
ServerReady: h.App.ServerReady,
|
||||
ServerHasPassword: h.App.Config.Server.Password != "",
|
||||
}
|
||||
|
||||
if c.Get("unauthenticated") != nil && c.Get("unauthenticated").(bool) {
|
||||
// If the user is unauthenticated, return a status with no user data
|
||||
status.OS = ""
|
||||
status.DataDir = ""
|
||||
status.User = user.NewSimulatedUser()
|
||||
status.ThemeSettings = nil
|
||||
status.MediastreamSettings = nil
|
||||
status.TorrentstreamSettings = nil
|
||||
status.Settings = &models.Settings{}
|
||||
status.DebridSettings = nil
|
||||
status.FeatureFlags = core.FeatureFlags{}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// HandleGetStatus
|
||||
//
|
||||
// @summary returns the server status.
|
||||
// @desc The server status includes app info, auth info and settings.
|
||||
// @desc The client uses this to set the UI.
|
||||
// @desc It is called on every page load to get the most up-to-date data.
|
||||
// @desc It should be called right after updating the settings.
|
||||
// @route /api/v1/status [GET]
|
||||
// @returns handlers.Status
|
||||
func (h *Handler) HandleGetStatus(c echo.Context) error {
|
||||
|
||||
status := h.NewStatus(c)
|
||||
|
||||
return h.RespondWithData(c, status)
|
||||
|
||||
}
|
||||
|
||||
func (h *Handler) HandleGetLogContent(c echo.Context) error {
|
||||
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
|
||||
return h.RespondWithData(c, "")
|
||||
}
|
||||
|
||||
filename := c.Param("*")
|
||||
if filepath.Base(filename) != filename {
|
||||
h.App.Logger.Error().Msg("handlers: Invalid filename")
|
||||
return h.RespondWithError(c, fmt.Errorf("invalid filename"))
|
||||
}
|
||||
|
||||
fp := filepath.Join(h.App.Config.Logs.Dir, filename)
|
||||
|
||||
if filepath.Ext(fp) != ".log" {
|
||||
h.App.Logger.Error().Msg("handlers: Unsupported file extension")
|
||||
return h.RespondWithError(c, fmt.Errorf("unsupported file extension"))
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fp); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Stat error")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
contentB, err := os.ReadFile(fp)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Failed to read log file")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, string(contentB))
|
||||
}
|
||||
|
||||
var newestLogFilename = ""
|
||||
|
||||
// HandleGetLogFilenames
|
||||
//
|
||||
// @summary returns the log filenames.
|
||||
// @desc This returns the filenames of all log files in the logs directory.
|
||||
// @route /api/v1/logs/filenames [GET]
|
||||
// @returns []string
|
||||
func (h *Handler) HandleGetLogFilenames(c echo.Context) error {
|
||||
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
|
||||
return h.RespondWithData(c, []string{})
|
||||
}
|
||||
|
||||
var filenames []string
|
||||
filepath.WalkDir(h.App.Config.Logs.Dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if filepath.Ext(path) != ".log" {
|
||||
return nil
|
||||
}
|
||||
|
||||
filenames = append(filenames, filepath.Base(path))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Sort from newest to oldest & store the newest log filename
|
||||
if len(filenames) > 0 {
|
||||
slices.SortStableFunc(filenames, func(i, j string) int {
|
||||
return strings.Compare(j, i)
|
||||
})
|
||||
for _, filename := range filenames {
|
||||
if strings.HasPrefix(strings.ToLower(filename), "seanime-") {
|
||||
newestLogFilename = filename
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, filenames)
|
||||
}
|
||||
|
||||
// HandleDeleteLogs
|
||||
//
|
||||
// @summary deletes certain log files.
|
||||
// @desc This deletes the log files with the given filenames.
|
||||
// @route /api/v1/logs [DELETE]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleDeleteLogs(c echo.Context) error {
|
||||
type body struct {
|
||||
Filenames []string `json:"filenames"`
|
||||
}
|
||||
|
||||
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
|
||||
return h.RespondWithData(c, false)
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
filepath.WalkDir(h.App.Config.Logs.Dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if filepath.Ext(path) != ".log" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, filename := range b.Filenames {
|
||||
if util.NormalizePath(filepath.Base(path)) == util.NormalizePath(filename) {
|
||||
if util.NormalizePath(newestLogFilename) == util.NormalizePath(filename) {
|
||||
return fmt.Errorf("cannot delete the newest log file")
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetLatestLogContent
|
||||
//
|
||||
// @summary returns the content of the latest server log file.
|
||||
// @desc This returns the content of the most recent seanime- log file after flushing logs.
|
||||
// @route /api/v1/logs/latest [GET]
|
||||
// @returns string
|
||||
func (h *Handler) HandleGetLatestLogContent(c echo.Context) error {
|
||||
if h.App.Config == nil || h.App.Config.Logs.Dir == "" {
|
||||
return h.RespondWithData(c, "")
|
||||
}
|
||||
|
||||
// Flush logs first
|
||||
if h.App.OnFlushLogs != nil {
|
||||
h.App.OnFlushLogs()
|
||||
// Small delay to ensure logs are written
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
dirEntries, err := os.ReadDir(h.App.Config.Logs.Dir)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Failed to read log directory")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
var logFiles []string
|
||||
for _, entry := range dirEntries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if filepath.Ext(name) != ".log" || !strings.HasPrefix(strings.ToLower(name), "seanime-") {
|
||||
continue
|
||||
}
|
||||
logFiles = append(logFiles, filepath.Join(h.App.Config.Logs.Dir, name))
|
||||
}
|
||||
|
||||
if len(logFiles) == 0 {
|
||||
h.App.Logger.Warn().Msg("handlers: No log files found")
|
||||
return h.RespondWithData(c, "")
|
||||
}
|
||||
|
||||
// Sort files in descending order based on filename
|
||||
slices.SortFunc(logFiles, func(a, b string) int {
|
||||
return strings.Compare(filepath.Base(b), filepath.Base(a))
|
||||
})
|
||||
|
||||
latestLogFile := logFiles[0]
|
||||
|
||||
contentB, err := os.ReadFile(latestLogFile)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Failed to read latest log file")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, string(contentB))
|
||||
}
|
||||
|
||||
// HandleGetAnnouncements
|
||||
//
|
||||
// @summary returns the server announcements.
|
||||
// @desc This returns the announcements for the server.
|
||||
// @route /api/v1/announcements [POST]
|
||||
// @returns []updater.Announcement
|
||||
func (h *Handler) HandleGetAnnouncements(c echo.Context) error {
|
||||
type body struct {
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
settings, _ := h.App.Database.GetSettings()
|
||||
|
||||
announcements := h.App.Updater.GetAnnouncements(h.App.Version, b.Platform, settings)
|
||||
|
||||
return h.RespondWithData(c, announcements)
|
||||
|
||||
}
|
||||
|
||||
type MemoryStatsResponse struct {
|
||||
Alloc uint64 `json:"alloc"` // bytes allocated and not yet freed
|
||||
TotalAlloc uint64 `json:"totalAlloc"` // bytes allocated (even if freed)
|
||||
Sys uint64 `json:"sys"` // bytes obtained from system
|
||||
Lookups uint64 `json:"lookups"` // number of pointer lookups
|
||||
Mallocs uint64 `json:"mallocs"` // number of mallocs
|
||||
Frees uint64 `json:"frees"` // number of frees
|
||||
HeapAlloc uint64 `json:"heapAlloc"` // bytes allocated and not yet freed
|
||||
HeapSys uint64 `json:"heapSys"` // bytes obtained from system
|
||||
HeapIdle uint64 `json:"heapIdle"` // bytes in idle spans
|
||||
HeapInuse uint64 `json:"heapInuse"` // bytes in non-idle span
|
||||
HeapReleased uint64 `json:"heapReleased"` // bytes released to OS
|
||||
HeapObjects uint64 `json:"heapObjects"` // total number of allocated objects
|
||||
StackInuse uint64 `json:"stackInuse"` // bytes used by stack allocator
|
||||
StackSys uint64 `json:"stackSys"` // bytes obtained from system for stack allocator
|
||||
MSpanInuse uint64 `json:"mSpanInuse"` // bytes used by mspan structures
|
||||
MSpanSys uint64 `json:"mSpanSys"` // bytes obtained from system for mspan structures
|
||||
MCacheInuse uint64 `json:"mCacheInuse"` // bytes used by mcache structures
|
||||
MCacheSys uint64 `json:"mCacheSys"` // bytes obtained from system for mcache structures
|
||||
BuckHashSys uint64 `json:"buckHashSys"` // bytes used by the profiling bucket hash table
|
||||
GCSys uint64 `json:"gcSys"` // bytes used for garbage collection system metadata
|
||||
OtherSys uint64 `json:"otherSys"` // bytes used for other system allocations
|
||||
NextGC uint64 `json:"nextGC"` // next collection will happen when HeapAlloc ≥ this amount
|
||||
LastGC uint64 `json:"lastGC"` // time the last garbage collection finished
|
||||
PauseTotalNs uint64 `json:"pauseTotalNs"` // cumulative nanoseconds in GC stop-the-world pauses
|
||||
PauseNs uint64 `json:"pauseNs"` // nanoseconds in recent GC stop-the-world pause
|
||||
NumGC uint32 `json:"numGC"` // number of completed GC cycles
|
||||
NumForcedGC uint32 `json:"numForcedGC"` // number of GC cycles that were forced by the application calling the GC function
|
||||
GCCPUFraction float64 `json:"gcCPUFraction"` // fraction of this program's available CPU time used by the GC since the program started
|
||||
EnableGC bool `json:"enableGC"` // boolean that indicates GC is enabled
|
||||
DebugGC bool `json:"debugGC"` // boolean that indicates GC debug mode is enabled
|
||||
NumGoroutine int `json:"numGoroutine"` // number of goroutines
|
||||
}
|
||||
|
||||
// HandleGetMemoryStats
|
||||
//
|
||||
// @summary returns current memory statistics.
|
||||
// @desc This returns real-time memory usage statistics from the Go runtime.
|
||||
// @route /api/v1/memory/stats [GET]
|
||||
// @returns handlers.MemoryStatsResponse
|
||||
func (h *Handler) HandleGetMemoryStats(c echo.Context) error {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
// Force garbage collection to get accurate memory stats
|
||||
// runtime.GC()
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
response := MemoryStatsResponse{
|
||||
Alloc: m.Alloc,
|
||||
TotalAlloc: m.TotalAlloc,
|
||||
Sys: m.Sys,
|
||||
Lookups: m.Lookups,
|
||||
Mallocs: m.Mallocs,
|
||||
Frees: m.Frees,
|
||||
HeapAlloc: m.HeapAlloc,
|
||||
HeapSys: m.HeapSys,
|
||||
HeapIdle: m.HeapIdle,
|
||||
HeapInuse: m.HeapInuse,
|
||||
HeapReleased: m.HeapReleased,
|
||||
HeapObjects: m.HeapObjects,
|
||||
StackInuse: m.StackInuse,
|
||||
StackSys: m.StackSys,
|
||||
MSpanInuse: m.MSpanInuse,
|
||||
MSpanSys: m.MSpanSys,
|
||||
MCacheInuse: m.MCacheInuse,
|
||||
MCacheSys: m.MCacheSys,
|
||||
BuckHashSys: m.BuckHashSys,
|
||||
GCSys: m.GCSys,
|
||||
OtherSys: m.OtherSys,
|
||||
NextGC: m.NextGC,
|
||||
LastGC: m.LastGC,
|
||||
PauseTotalNs: m.PauseTotalNs,
|
||||
PauseNs: m.PauseNs[0], // Most recent pause
|
||||
NumGC: m.NumGC,
|
||||
NumForcedGC: m.NumForcedGC,
|
||||
GCCPUFraction: m.GCCPUFraction,
|
||||
EnableGC: m.EnableGC,
|
||||
DebugGC: m.DebugGC,
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, response)
|
||||
}
|
||||
|
||||
// HandleGetMemoryProfile
|
||||
//
|
||||
// @summary generates and returns a memory profile.
|
||||
// @desc This generates a memory profile that can be analyzed with go tool pprof.
|
||||
// @desc Query parameters: heap=true for heap profile, allocs=true for alloc profile.
|
||||
// @route /api/v1/memory/profile [GET]
|
||||
// @returns nil
|
||||
func (h *Handler) HandleGetMemoryProfile(c echo.Context) error {
|
||||
// Parse query parameters
|
||||
heap := c.QueryParam("heap") == "true"
|
||||
allocs := c.QueryParam("allocs") == "true"
|
||||
|
||||
// Default to heap profile if no specific type requested
|
||||
if !heap && !allocs {
|
||||
heap = true
|
||||
}
|
||||
|
||||
// Set response headers for file download
|
||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||
var filename string
|
||||
var profile *pprof.Profile
|
||||
var err error
|
||||
|
||||
if heap {
|
||||
filename = fmt.Sprintf("seanime-heap-profile-%s.pprof", timestamp)
|
||||
profile = pprof.Lookup("heap")
|
||||
} else if allocs {
|
||||
filename = fmt.Sprintf("seanime-allocs-profile-%s.pprof", timestamp)
|
||||
profile = pprof.Lookup("allocs")
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
h.App.Logger.Error().Msg("handlers: Failed to lookup memory profile")
|
||||
return h.RespondWithError(c, fmt.Errorf("failed to lookup memory profile"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// // Force garbage collection before profiling for more accurate results
|
||||
// runtime.GC()
|
||||
|
||||
// Write profile to response
|
||||
if err = profile.WriteTo(c.Response().Writer, 0); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Failed to write memory profile")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleGetGoRoutineProfile
|
||||
//
|
||||
// @summary generates and returns a goroutine profile.
|
||||
// @desc This generates a goroutine profile showing all running goroutines and their stack traces.
|
||||
// @route /api/v1/memory/goroutine [GET]
|
||||
// @returns nil
|
||||
func (h *Handler) HandleGetGoRoutineProfile(c echo.Context) error {
|
||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||
filename := fmt.Sprintf("seanime-goroutine-profile-%s.pprof", timestamp)
|
||||
|
||||
profile := pprof.Lookup("goroutine")
|
||||
if profile == nil {
|
||||
h.App.Logger.Error().Msg("handlers: Failed to lookup goroutine profile")
|
||||
return h.RespondWithError(c, fmt.Errorf("failed to lookup goroutine profile"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
if err := profile.WriteTo(c.Response().Writer, 0); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Failed to write goroutine profile")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleGetCPUProfile
|
||||
//
|
||||
// @summary generates and returns a CPU profile.
|
||||
// @desc This generates a CPU profile for the specified duration (default 30 seconds).
|
||||
// @desc Query parameter: duration=30 for duration in seconds.
|
||||
// @route /api/v1/memory/cpu [GET]
|
||||
// @returns nil
|
||||
func (h *Handler) HandleGetCPUProfile(c echo.Context) error {
|
||||
// Parse duration from query parameter (default to 30 seconds)
|
||||
durationStr := c.QueryParam("duration")
|
||||
duration := 30 * time.Second
|
||||
if durationStr != "" {
|
||||
if d, err := strconv.Atoi(durationStr); err == nil && d > 0 && d <= 300 { // Max 5 minutes
|
||||
duration = time.Duration(d) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||
filename := fmt.Sprintf("seanime-cpu-profile-%s.pprof", timestamp)
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/octet-stream")
|
||||
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// Start CPU profiling
|
||||
if err := pprof.StartCPUProfile(c.Response().Writer); err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("handlers: Failed to start CPU profile")
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Profile for the specified duration
|
||||
h.App.Logger.Info().Msgf("handlers: Starting CPU profile for %v", duration)
|
||||
time.Sleep(duration)
|
||||
|
||||
// Stop CPU profiling
|
||||
pprof.StopCPUProfile()
|
||||
h.App.Logger.Info().Msg("handlers: CPU profile completed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleForceGC
|
||||
//
|
||||
// @summary forces garbage collection and returns memory stats.
|
||||
// @desc This forces a garbage collection cycle and returns the updated memory statistics.
|
||||
// @route /api/v1/memory/gc [POST]
|
||||
// @returns handlers.MemoryStatsResponse
|
||||
func (h *Handler) HandleForceGC(c echo.Context) error {
|
||||
h.App.Logger.Info().Msg("handlers: Forcing garbage collection")
|
||||
|
||||
// Force garbage collection
|
||||
runtime.GC()
|
||||
runtime.GC() // Run twice to ensure cleanup
|
||||
|
||||
// Get updated memory stats
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
response := MemoryStatsResponse{
|
||||
Alloc: m.Alloc,
|
||||
TotalAlloc: m.TotalAlloc,
|
||||
Sys: m.Sys,
|
||||
Lookups: m.Lookups,
|
||||
Mallocs: m.Mallocs,
|
||||
Frees: m.Frees,
|
||||
HeapAlloc: m.HeapAlloc,
|
||||
HeapSys: m.HeapSys,
|
||||
HeapIdle: m.HeapIdle,
|
||||
HeapInuse: m.HeapInuse,
|
||||
HeapReleased: m.HeapReleased,
|
||||
HeapObjects: m.HeapObjects,
|
||||
StackInuse: m.StackInuse,
|
||||
StackSys: m.StackSys,
|
||||
MSpanInuse: m.MSpanInuse,
|
||||
MSpanSys: m.MSpanSys,
|
||||
MCacheInuse: m.MCacheInuse,
|
||||
MCacheSys: m.MCacheSys,
|
||||
BuckHashSys: m.BuckHashSys,
|
||||
GCSys: m.GCSys,
|
||||
OtherSys: m.OtherSys,
|
||||
NextGC: m.NextGC,
|
||||
LastGC: m.LastGC,
|
||||
PauseTotalNs: m.PauseTotalNs,
|
||||
PauseNs: m.PauseNs[0],
|
||||
NumGC: m.NumGC,
|
||||
NumForcedGC: m.NumForcedGC,
|
||||
GCCPUFraction: m.GCCPUFraction,
|
||||
EnableGC: m.EnableGC,
|
||||
DebugGC: m.DebugGC,
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
}
|
||||
|
||||
h.App.Logger.Info().Msgf("handlers: GC completed, heap size: %d bytes", response.HeapAlloc)
|
||||
|
||||
return h.RespondWithData(c, response)
|
||||
}
|
||||
51
seanime-2.9.10/internal/handlers/theme.go
Normal file
51
seanime-2.9.10/internal/handlers/theme.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/database/models"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetTheme
|
||||
//
|
||||
// @summary returns the theme settings.
|
||||
// @route /api/v1/theme [GET]
|
||||
// @returns models.Theme
|
||||
func (h *Handler) HandleGetTheme(c echo.Context) error {
|
||||
theme, err := h.App.Database.GetTheme()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
return h.RespondWithData(c, theme)
|
||||
}
|
||||
|
||||
// HandleUpdateTheme
|
||||
//
|
||||
// @summary updates the theme settings.
|
||||
// @desc The server status should be re-fetched after this on the client.
|
||||
// @route /api/v1/theme [PATCH]
|
||||
// @returns models.Theme
|
||||
func (h *Handler) HandleUpdateTheme(c echo.Context) error {
|
||||
type body struct {
|
||||
Theme models.Theme `json:"theme"`
|
||||
}
|
||||
|
||||
var b body
|
||||
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Set the theme ID to 1, so we overwrite the previous settings
|
||||
b.Theme.BaseModel = models.BaseModel{
|
||||
ID: 1,
|
||||
}
|
||||
|
||||
// Update the theme settings
|
||||
if _, err := h.App.Database.UpsertTheme(&b.Theme); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Send the new theme to the client
|
||||
return h.RespondWithData(c, b.Theme)
|
||||
}
|
||||
264
seanime-2.9.10/internal/handlers/torrent_client.go
Normal file
264
seanime-2.9.10/internal/handlers/torrent_client.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/events"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/torrent_clients/torrent_client"
|
||||
"seanime/internal/util"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetActiveTorrentList
|
||||
//
|
||||
// @summary returns all active torrents.
|
||||
// @desc This handler is used by the client to display the active torrents.
|
||||
//
|
||||
// @route /api/v1/torrent-client/list [GET]
|
||||
// @returns []torrent_client.Torrent
|
||||
func (h *Handler) HandleGetActiveTorrentList(c echo.Context) error {
|
||||
|
||||
// Get torrent list
|
||||
res, err := h.App.TorrentClientRepository.GetActiveTorrents()
|
||||
// If an error occurred, try to start the torrent client and get the list again
|
||||
// DEVNOTE: We try to get the list first because this route is called repeatedly by the client.
|
||||
if err != nil {
|
||||
ok := h.App.TorrentClientRepository.Start()
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("could not start torrent client, verify your settings"))
|
||||
}
|
||||
res, err = h.App.TorrentClientRepository.GetActiveTorrents()
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, res)
|
||||
|
||||
}
|
||||
|
||||
// HandleTorrentClientAction
|
||||
//
|
||||
// @summary performs an action on a torrent.
|
||||
// @desc This handler is used to pause, resume or remove a torrent.
|
||||
// @route /api/v1/torrent-client/action [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleTorrentClientAction(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Hash string `json:"hash"`
|
||||
Action string `json:"action"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.Hash == "" || b.Action == "" {
|
||||
return h.RespondWithError(c, errors.New("missing arguments"))
|
||||
}
|
||||
|
||||
switch b.Action {
|
||||
case "pause":
|
||||
err := h.App.TorrentClientRepository.PauseTorrents([]string{b.Hash})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
case "resume":
|
||||
err := h.App.TorrentClientRepository.ResumeTorrents([]string{b.Hash})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
case "remove":
|
||||
err := h.App.TorrentClientRepository.RemoveTorrents([]string{b.Hash})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
case "open":
|
||||
if b.Dir == "" {
|
||||
return h.RespondWithError(c, errors.New("directory not found"))
|
||||
}
|
||||
OpenDirInExplorer(b.Dir)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
|
||||
}
|
||||
|
||||
// HandleTorrentClientDownload
|
||||
//
|
||||
// @summary adds torrents to the torrent client.
|
||||
// @desc It fetches the magnets from the provided URLs and adds them to the torrent client.
|
||||
// @desc If smart select is enabled, it will try to select the best torrent based on the missing episodes.
|
||||
// @route /api/v1/torrent-client/download [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleTorrentClientDownload(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Torrents []hibiketorrent.AnimeTorrent `json:"torrents"`
|
||||
Destination string `json:"destination"`
|
||||
SmartSelect struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MissingEpisodeNumbers []int `json:"missingEpisodeNumbers"`
|
||||
} `json:"smartSelect"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.Destination == "" {
|
||||
return h.RespondWithError(c, errors.New("destination not found"))
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(b.Destination) {
|
||||
return h.RespondWithError(c, errors.New("destination path must be absolute"))
|
||||
}
|
||||
|
||||
// Check that the destination path is a library path
|
||||
//libraryPaths, err := h.App.Database.GetAllLibraryPathsFromSettings()
|
||||
//if err != nil {
|
||||
// return h.RespondWithError(c, err)
|
||||
//}
|
||||
//isInLibrary := util.IsSubdirectoryOfAny(libraryPaths, b.Destination)
|
||||
//if !isInLibrary {
|
||||
// return h.RespondWithError(c, errors.New("destination path is not a library path"))
|
||||
//}
|
||||
|
||||
// try to start torrent client if it's not running
|
||||
ok := h.App.TorrentClientRepository.Start()
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("could not contact torrent client, verify your settings or make sure it's running"))
|
||||
}
|
||||
|
||||
completeAnime, err := h.App.AnilistPlatform.GetAnimeWithRelations(c.Request().Context(), b.Media.ID)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.SmartSelect.Enabled {
|
||||
if len(b.Torrents) > 1 {
|
||||
return h.RespondWithError(c, errors.New("smart select is not supported for multiple torrents"))
|
||||
}
|
||||
|
||||
// smart select
|
||||
err = h.App.TorrentClientRepository.SmartSelect(&torrent_client.SmartSelectParams{
|
||||
Torrent: &b.Torrents[0],
|
||||
EpisodeNumbers: b.SmartSelect.MissingEpisodeNumbers,
|
||||
Media: completeAnime,
|
||||
Destination: b.Destination,
|
||||
Platform: h.App.AnilistPlatform,
|
||||
ShouldAddTorrent: true,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
} else {
|
||||
|
||||
// Get magnets
|
||||
magnets := make([]string, 0)
|
||||
for _, t := range b.Torrents {
|
||||
// Get the torrent's provider extension
|
||||
providerExtension, ok := h.App.TorrentRepository.GetAnimeProviderExtension(t.Provider)
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("provider extension not found for torrent"))
|
||||
}
|
||||
// Get the torrent magnet link
|
||||
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(&t)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
magnets = append(magnets, magnet)
|
||||
}
|
||||
|
||||
// try to add torrents to client, on error return error
|
||||
err = h.App.TorrentClientRepository.AddMagnets(magnets, b.Destination)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add the media to the collection (if it wasn't already)
|
||||
go func() {
|
||||
defer util.HandlePanicInModuleThen("handlers/HandleTorrentClientDownload", func() {})
|
||||
if b.Media != nil {
|
||||
// Check if the media is already in the collection
|
||||
animeCollection, err := h.App.GetAnimeCollection(false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, found := animeCollection.FindAnime(b.Media.ID)
|
||||
if found {
|
||||
return
|
||||
}
|
||||
// Add the media to the collection
|
||||
err = h.App.AnilistPlatform.AddMediaToCollection(c.Request().Context(), []int{b.Media.ID})
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("anilist: Failed to add media to collection")
|
||||
}
|
||||
ac, _ := h.App.RefreshAnimeCollection()
|
||||
h.App.WSEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, ac)
|
||||
}
|
||||
}()
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
|
||||
}
|
||||
|
||||
// HandleTorrentClientAddMagnetFromRule
|
||||
//
|
||||
// @summary adds magnets to the torrent client based on the AutoDownloader item.
|
||||
// @desc This is used to download torrents that were queued by the AutoDownloader.
|
||||
// @desc The item will be removed from the queue if the magnet was added successfully.
|
||||
// @desc The AutoDownloader items should be re-fetched after this.
|
||||
// @route /api/v1/torrent-client/rule-magnet [POST]
|
||||
// @returns bool
|
||||
func (h *Handler) HandleTorrentClientAddMagnetFromRule(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MagnetUrl string `json:"magnetUrl"`
|
||||
RuleId uint `json:"ruleId"`
|
||||
QueuedItemId uint `json:"queuedItemId"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.MagnetUrl == "" || b.RuleId == 0 {
|
||||
return h.RespondWithError(c, errors.New("missing parameters"))
|
||||
}
|
||||
|
||||
// Get rule from database
|
||||
rule, err := db_bridge.GetAutoDownloaderRule(h.App.Database, b.RuleId)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// try to start torrent client if it's not running
|
||||
ok := h.App.TorrentClientRepository.Start()
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("could not start torrent client, verify your settings"))
|
||||
}
|
||||
|
||||
// try to add torrents to client, on error return error
|
||||
err = h.App.TorrentClientRepository.AddMagnets([]string{b.MagnetUrl}, rule.Destination)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
if b.QueuedItemId > 0 {
|
||||
// the magnet was added successfully, remove the item from the queue
|
||||
err = h.App.Database.DeleteAutoDownloaderItem(b.QueuedItemId)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
|
||||
}
|
||||
81
seanime-2.9.10/internal/handlers/torrent_search.go
Normal file
81
seanime-2.9.10/internal/handlers/torrent_search.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util/result"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var debridInstantAvailabilityCache = result.NewCache[string, map[string]debrid.TorrentItemInstantAvailability]()
|
||||
|
||||
// HandleSearchTorrent
|
||||
//
|
||||
// @summary searches torrents and returns a list of torrents and their previews.
|
||||
// @desc This will search for torrents and return a list of torrents with previews.
|
||||
// @desc If smart search is enabled, it will filter the torrents based on search parameters.
|
||||
// @route /api/v1/torrent/search [POST]
|
||||
// @returns torrent.SearchData
|
||||
func (h *Handler) HandleSearchTorrent(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
// "smart" or "simple"
|
||||
Type string `json:"type,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
EpisodeNumber int `json:"episodeNumber,omitempty"`
|
||||
Batch bool `json:"batch,omitempty"`
|
||||
Media anilist.BaseAnime `json:"media,omitempty"`
|
||||
AbsoluteOffset int `json:"absoluteOffset,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
BestRelease bool `json:"bestRelease,omitempty"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
data, err := h.App.TorrentRepository.SearchAnime(c.Request().Context(), torrent.AnimeSearchOptions{
|
||||
Provider: b.Provider,
|
||||
Type: torrent.AnimeSearchType(b.Type),
|
||||
Media: &b.Media,
|
||||
Query: b.Query,
|
||||
Batch: b.Batch,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
BestReleases: b.BestRelease,
|
||||
Resolution: b.Resolution,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
//
|
||||
// Debrid torrent instant availability
|
||||
//
|
||||
if h.App.SecondarySettings.Debrid.Enabled {
|
||||
hashes := make([]string, 0)
|
||||
for _, t := range data.Torrents {
|
||||
if t.InfoHash == "" {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes, t.InfoHash)
|
||||
}
|
||||
hashesKey := strings.Join(hashes, ",")
|
||||
var found bool
|
||||
data.DebridInstantAvailability, found = debridInstantAvailabilityCache.Get(hashesKey)
|
||||
if !found {
|
||||
provider, err := h.App.DebridClientRepository.GetProvider()
|
||||
if err == nil {
|
||||
instantAvail := provider.GetInstantAvailability(hashes)
|
||||
data.DebridInstantAvailability = instantAvail
|
||||
debridInstantAvailabilityCache.Set(hashesKey, instantAvail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, data)
|
||||
}
|
||||
223
seanime-2.9.10/internal/handlers/torrentstream.go
Normal file
223
seanime-2.9.10/internal/handlers/torrentstream.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/torrentstream"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// HandleGetTorrentstreamSettings
|
||||
//
|
||||
// @summary get torrentstream settings.
|
||||
// @desc This returns the torrentstream settings.
|
||||
// @returns models.TorrentstreamSettings
|
||||
// @route /api/v1/torrentstream/settings [GET]
|
||||
func (h *Handler) HandleGetTorrentstreamSettings(c echo.Context) error {
|
||||
torrentstreamSettings, found := h.App.Database.GetTorrentstreamSettings()
|
||||
if !found {
|
||||
return h.RespondWithError(c, errors.New("torrent streaming settings not found"))
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, torrentstreamSettings)
|
||||
}
|
||||
|
||||
// HandleSaveTorrentstreamSettings
|
||||
//
|
||||
// @summary save torrentstream settings.
|
||||
// @desc This saves the torrentstream settings.
|
||||
// @desc The client should refetch the server status.
|
||||
// @returns models.TorrentstreamSettings
|
||||
// @route /api/v1/torrentstream/settings [PATCH]
|
||||
func (h *Handler) HandleSaveTorrentstreamSettings(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
Settings models.TorrentstreamSettings `json:"settings"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Validate the download directory
|
||||
if b.Settings.DownloadDir != "" {
|
||||
dir, err := os.Stat(b.Settings.DownloadDir)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msgf("torrentstream: Download directory %s does not exist", b.Settings.DownloadDir)
|
||||
h.App.WSEventManager.SendEvent(events.ErrorToast, "Download directory does not exist")
|
||||
b.Settings.DownloadDir = ""
|
||||
}
|
||||
if !dir.IsDir() {
|
||||
h.App.Logger.Error().Msgf("torrentstream: Download directory %s is not a directory", b.Settings.DownloadDir)
|
||||
h.App.WSEventManager.SendEvent(events.ErrorToast, "Download directory is not a directory")
|
||||
b.Settings.DownloadDir = ""
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := h.App.Database.UpsertTorrentstreamSettings(&b.Settings)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
h.App.InitOrRefreshTorrentstreamSettings()
|
||||
|
||||
return h.RespondWithData(c, settings)
|
||||
}
|
||||
|
||||
// HandleGetTorrentstreamTorrentFilePreviews
|
||||
//
|
||||
// @summary get list of torrent files from a batch
|
||||
// @desc This returns a list of file previews from the torrent
|
||||
// @returns []torrentstream.FilePreview
|
||||
// @route /api/v1/torrentstream/torrent-file-previews [POST]
|
||||
func (h *Handler) HandleGetTorrentstreamTorrentFilePreviews(c echo.Context) error {
|
||||
type body struct {
|
||||
Torrent *hibiketorrent.AnimeTorrent `json:"torrent"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
}
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
providerExtension, ok := h.App.ExtensionRepository.GetAnimeTorrentProviderExtensionByID(b.Torrent.Provider)
|
||||
if !ok {
|
||||
return h.RespondWithError(c, errors.New("torrentstream: Torrent provider extension not found"))
|
||||
}
|
||||
|
||||
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(b.Torrent)
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
// Get the media metadata
|
||||
animeMetadata, _ := h.App.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, b.Media.ID)
|
||||
absoluteOffset := 0
|
||||
if animeMetadata != nil {
|
||||
absoluteOffset = animeMetadata.GetOffset()
|
||||
}
|
||||
|
||||
files, err := h.App.TorrentstreamRepository.GetTorrentFilePreviewsFromManualSelection(&torrentstream.GetTorrentFilePreviewsOptions{
|
||||
Torrent: b.Torrent,
|
||||
Magnet: magnet,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
AbsoluteOffset: absoluteOffset,
|
||||
Media: b.Media,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, files)
|
||||
}
|
||||
|
||||
// HandleTorrentstreamStartStream
|
||||
//
|
||||
// @summary starts a torrent stream.
|
||||
// @desc This starts the entire streaming process.
|
||||
// @returns bool
|
||||
// @route /api/v1/torrentstream/start [POST]
|
||||
func (h *Handler) HandleTorrentstreamStartStream(c echo.Context) error {
|
||||
|
||||
type body struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
AniDBEpisode string `json:"aniDBEpisode"`
|
||||
AutoSelect bool `json:"autoSelect"`
|
||||
Torrent *hibiketorrent.AnimeTorrent `json:"torrent,omitempty"` // Nil if autoSelect is true
|
||||
FileIndex *int `json:"fileIndex,omitempty"`
|
||||
PlaybackType torrentstream.PlaybackType `json:"playbackType"` // "default" or "externalPlayerLink"
|
||||
ClientId string `json:"clientId"`
|
||||
}
|
||||
|
||||
var b body
|
||||
if err := c.Bind(&b); err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
userAgent := c.Request().Header.Get("User-Agent")
|
||||
|
||||
err := h.App.TorrentstreamRepository.StartStream(c.Request().Context(), &torrentstream.StartStreamOptions{
|
||||
MediaId: b.MediaId,
|
||||
EpisodeNumber: b.EpisodeNumber,
|
||||
AniDBEpisode: b.AniDBEpisode,
|
||||
AutoSelect: b.AutoSelect,
|
||||
Torrent: b.Torrent,
|
||||
FileIndex: b.FileIndex,
|
||||
UserAgent: userAgent,
|
||||
ClientId: b.ClientId,
|
||||
PlaybackType: b.PlaybackType,
|
||||
})
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleTorrentstreamStopStream
|
||||
//
|
||||
// @summary stop a torrent stream.
|
||||
// @desc This stops the entire streaming process and drops the torrent if it's below a threshold.
|
||||
// @desc This is made to be used while the stream is running.
|
||||
// @returns bool
|
||||
// @route /api/v1/torrentstream/stop [POST]
|
||||
func (h *Handler) HandleTorrentstreamStopStream(c echo.Context) error {
|
||||
|
||||
err := h.App.TorrentstreamRepository.StopStream()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleTorrentstreamDropTorrent
|
||||
//
|
||||
// @summary drops a torrent stream.
|
||||
// @desc This stops the entire streaming process and drops the torrent completely.
|
||||
// @desc This is made to be used to force drop a torrent.
|
||||
// @returns bool
|
||||
// @route /api/v1/torrentstream/drop [POST]
|
||||
func (h *Handler) HandleTorrentstreamDropTorrent(c echo.Context) error {
|
||||
|
||||
err := h.App.TorrentstreamRepository.DropTorrent()
|
||||
if err != nil {
|
||||
return h.RespondWithError(c, err)
|
||||
}
|
||||
|
||||
return h.RespondWithData(c, true)
|
||||
}
|
||||
|
||||
// HandleGetTorrentstreamBatchHistory
|
||||
//
|
||||
// @summary returns the most recent batch selected.
|
||||
// @desc This returns the most recent batch selected.
|
||||
// @returns torrentstream.BatchHistoryResponse
|
||||
// @route /api/v1/torrentstream/batch-history [POST]
|
||||
func (h *Handler) HandleGetTorrentstreamBatchHistory(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)
|
||||
}
|
||||
|
||||
ret := h.App.TorrentstreamRepository.GetBatchHistory(b.MediaID)
|
||||
return h.RespondWithData(c, ret)
|
||||
}
|
||||
|
||||
// route /api/v1/torrentstream/stream/*
|
||||
func (h *Handler) HandleTorrentstreamServeStream(c echo.Context) error {
|
||||
h.App.TorrentstreamRepository.HTTPStreamHandler().ServeHTTP(c.Response().Writer, c.Request())
|
||||
return nil
|
||||
}
|
||||
94
seanime-2.9.10/internal/handlers/websocket.go
Normal file
94
seanime-2.9.10/internal/handlers/websocket.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"seanime/internal/events"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// webSocketEventHandler creates a new websocket handler for real-time event communication
|
||||
func (h *Handler) webSocketEventHandler(c echo.Context) error {
|
||||
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
// Get connection ID from query parameter
|
||||
id := c.QueryParam("id")
|
||||
if id == "" {
|
||||
id = "0"
|
||||
}
|
||||
|
||||
// Add connection to manager
|
||||
h.App.WSEventManager.AddConn(id, ws)
|
||||
h.App.Logger.Debug().Str("id", id).Msg("ws: Client connected")
|
||||
|
||||
for {
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
h.App.Logger.Debug().Str("id", id).Msg("ws: Client disconnected")
|
||||
} else {
|
||||
h.App.Logger.Debug().Str("id", id).Msg("ws: Client disconnection")
|
||||
}
|
||||
h.App.WSEventManager.RemoveConn(id)
|
||||
break
|
||||
}
|
||||
|
||||
event, err := UnmarshalWebsocketClientEvent(msg)
|
||||
if err != nil {
|
||||
h.App.Logger.Error().Err(err).Msg("ws: Failed to unmarshal message sent from webview")
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle ping messages
|
||||
if event.Type == "ping" {
|
||||
timestamp := int64(0)
|
||||
if payload, ok := event.Payload.(map[string]interface{}); ok {
|
||||
if ts, ok := payload["timestamp"]; ok {
|
||||
if tsFloat, ok := ts.(float64); ok {
|
||||
timestamp = int64(tsFloat)
|
||||
} else if tsInt, ok := ts.(int64); ok {
|
||||
timestamp = tsInt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send pong response back to the same client
|
||||
h.App.WSEventManager.SendEventTo(event.ClientID, "pong", map[string]int64{"timestamp": timestamp})
|
||||
continue // Skip further processing for ping messages
|
||||
}
|
||||
|
||||
h.HandleClientEvents(event)
|
||||
|
||||
// h.App.Logger.Debug().Msgf("ws: message received: %+v", msg)
|
||||
|
||||
// // Echo the message back
|
||||
// if err = ws.WriteMessage(messageType, msg); err != nil {
|
||||
// h.App.Logger.Err(err).Msg("ws: Failed to send message")
|
||||
// break
|
||||
// }
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func UnmarshalWebsocketClientEvent(msg []byte) (*events.WebsocketClientEvent, error) {
|
||||
var event events.WebsocketClientEvent
|
||||
if err := json.Unmarshal(msg, &event); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
Reference in New Issue
Block a user