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