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