node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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