node build fixed
This commit is contained in:
497
seanime-2.9.10/internal/manga/chapter_container.go
Normal file
497
seanime-2.9.10/internal/manga/chapter_container.go
Normal file
@@ -0,0 +1,497 @@
|
||||
package manga
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/extension"
|
||||
hibikemanga "seanime/internal/extension/hibike/manga"
|
||||
"seanime/internal/hook"
|
||||
manga_providers "seanime/internal/manga/providers"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"seanime/internal/util/result"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
// ChapterContainer is used to display the list of chapters from a provider in the client.
|
||||
// It is cached in a unique file cache bucket with a key of the format: {provider}${mediaId}
|
||||
ChapterContainer struct {
|
||||
MediaId int `json:"mediaId"`
|
||||
Provider string `json:"provider"`
|
||||
Chapters []*hibikemanga.ChapterDetails `json:"chapters"`
|
||||
}
|
||||
)
|
||||
|
||||
func getMangaChapterContainerCacheKey(provider string, mediaId int) string {
|
||||
return fmt.Sprintf("%s$%d", provider, mediaId)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type GetMangaChapterContainerOptions struct {
|
||||
Provider string
|
||||
MediaId int
|
||||
Titles []*string
|
||||
Year int
|
||||
}
|
||||
|
||||
// GetMangaChapterContainer returns the ChapterContainer for a manga entry based on the provider.
|
||||
// If it isn't cached, it will search for the manga, create a ChapterContainer and cache it.
|
||||
func (r *Repository) GetMangaChapterContainer(opts *GetMangaChapterContainerOptions) (ret *ChapterContainer, err error) {
|
||||
defer util.HandlePanicInModuleWithError("manga/GetMangaChapterContainer", &err)
|
||||
|
||||
provider := opts.Provider
|
||||
mediaId := opts.MediaId
|
||||
titles := opts.Titles
|
||||
|
||||
providerExtension, ok := extension.GetExtension[extension.MangaProviderExtension](r.providerExtensionBank, provider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", provider).Msg("manga: Provider not found")
|
||||
return nil, errors.New("manga: Provider not found")
|
||||
}
|
||||
|
||||
// DEVNOTE: Local chapters can be cached
|
||||
localProvider, isLocalProvider := providerExtension.GetProvider().(*manga_providers.Local)
|
||||
|
||||
// Set the source directory for local provider
|
||||
if isLocalProvider && r.settings.Manga.LocalSourceDirectory != "" {
|
||||
localProvider.SetSourceDirectory(r.settings.Manga.LocalSourceDirectory)
|
||||
}
|
||||
|
||||
r.logger.Trace().
|
||||
Str("provider", provider).
|
||||
Int("mediaId", mediaId).
|
||||
Msgf("manga: Getting chapters")
|
||||
|
||||
chapterContainerKey := getMangaChapterContainerCacheKey(provider, mediaId)
|
||||
|
||||
// +---------------------+
|
||||
// | Hook event |
|
||||
// +---------------------+
|
||||
|
||||
// Trigger hook event
|
||||
reqEvent := &MangaChapterContainerRequestedEvent{
|
||||
Provider: provider,
|
||||
MediaId: mediaId,
|
||||
Titles: titles,
|
||||
Year: opts.Year,
|
||||
ChapterContainer: &ChapterContainer{
|
||||
MediaId: mediaId,
|
||||
Provider: provider,
|
||||
Chapters: []*hibikemanga.ChapterDetails{},
|
||||
},
|
||||
}
|
||||
err = hook.GlobalHookManager.OnMangaChapterContainerRequested().Trigger(reqEvent)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event")
|
||||
return nil, fmt.Errorf("manga: Error in hook, %w", err)
|
||||
}
|
||||
|
||||
// Default prevented, return the chapter container
|
||||
if reqEvent.DefaultPrevented {
|
||||
if reqEvent.ChapterContainer == nil {
|
||||
return nil, fmt.Errorf("manga: No chapter container returned by hook event")
|
||||
}
|
||||
return reqEvent.ChapterContainer, nil
|
||||
}
|
||||
|
||||
// +---------------------+
|
||||
// | Cache |
|
||||
// +---------------------+
|
||||
|
||||
var container *ChapterContainer
|
||||
containerBucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter)
|
||||
|
||||
// Check if the container is in the cache
|
||||
if found, _ := r.fileCacher.Get(containerBucket, chapterContainerKey, &container); found {
|
||||
r.logger.Info().Str("bucket", containerBucket.Name()).Msg("manga: Chapter Container Cache HIT")
|
||||
|
||||
// Trigger hook event
|
||||
ev := &MangaChapterContainerEvent{
|
||||
ChapterContainer: container,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnMangaChapterContainer().Trigger(ev)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event")
|
||||
}
|
||||
container = ev.ChapterContainer
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
// Delete the map cache
|
||||
mangaLatestChapterNumberMap.Delete(ChapterCountMapCacheKey)
|
||||
|
||||
var mangaId string
|
||||
|
||||
// +---------------------+
|
||||
// | Database |
|
||||
// +---------------------+
|
||||
|
||||
// Search for the mapping in the database
|
||||
mapping, found := r.db.GetMangaMapping(provider, mediaId)
|
||||
if found {
|
||||
r.logger.Debug().Str("mangaId", mapping.MangaID).Msg("manga: Using manual mapping")
|
||||
mangaId = mapping.MangaID
|
||||
}
|
||||
|
||||
if mangaId == "" {
|
||||
// +---------------------+
|
||||
// | Search |
|
||||
// +---------------------+
|
||||
|
||||
r.logger.Trace().Msg("manga: Searching for manga")
|
||||
|
||||
if titles == nil {
|
||||
return nil, ErrNoTitlesProvided
|
||||
}
|
||||
|
||||
titles = lo.Filter(titles, func(title *string, _ int) bool {
|
||||
return util.IsMostlyLatinString(*title)
|
||||
})
|
||||
|
||||
var searchRes []*hibikemanga.SearchResult
|
||||
|
||||
var err error
|
||||
for _, title := range titles {
|
||||
var _searchRes []*hibikemanga.SearchResult
|
||||
|
||||
_searchRes, err = providerExtension.GetProvider().Search(hibikemanga.SearchOptions{
|
||||
Query: *title,
|
||||
Year: opts.Year,
|
||||
})
|
||||
if err == nil {
|
||||
|
||||
HydrateSearchResultSearchRating(_searchRes, title)
|
||||
|
||||
searchRes = append(searchRes, _searchRes...)
|
||||
} else {
|
||||
r.logger.Warn().Err(err).Msg("manga: Search failed")
|
||||
}
|
||||
}
|
||||
|
||||
if len(searchRes) == 0 {
|
||||
r.logger.Error().Msg("manga: No search results found")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w, %w", ErrNoResults, err)
|
||||
} else {
|
||||
return nil, ErrNoResults
|
||||
}
|
||||
}
|
||||
|
||||
// Overwrite the provider just in case
|
||||
for _, res := range searchRes {
|
||||
res.Provider = provider
|
||||
}
|
||||
|
||||
bestRes := GetBestSearchResult(searchRes)
|
||||
|
||||
mangaId = bestRes.ID
|
||||
}
|
||||
|
||||
// +---------------------+
|
||||
// | Get chapters |
|
||||
// +---------------------+
|
||||
|
||||
chapterList, err := providerExtension.GetProvider().FindChapters(mangaId)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msg("manga: Failed to get chapters")
|
||||
return nil, ErrNoChapters
|
||||
}
|
||||
|
||||
// Overwrite the provider just in case
|
||||
for _, chapter := range chapterList {
|
||||
chapter.Provider = provider
|
||||
}
|
||||
|
||||
container = &ChapterContainer{
|
||||
MediaId: mediaId,
|
||||
Provider: provider,
|
||||
Chapters: chapterList,
|
||||
}
|
||||
|
||||
// Trigger hook event
|
||||
ev := &MangaChapterContainerEvent{
|
||||
ChapterContainer: container,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnMangaChapterContainer().Trigger(ev)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event")
|
||||
}
|
||||
container = ev.ChapterContainer
|
||||
|
||||
// Cache the container only if it has chapters
|
||||
if len(container.Chapters) > 0 {
|
||||
err = r.fileCacher.Set(containerBucket, chapterContainerKey, container)
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msg("manga: Failed to populate cache")
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info().Str("bucket", containerBucket.Name()).Msg("manga: Retrieved chapters")
|
||||
return container, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// RefreshChapterContainers deletes all cached chapter containers and refetches them based on the selected provider map.
|
||||
func (r *Repository) RefreshChapterContainers(mangaCollection *anilist.MangaCollection, selectedProviderMap map[int]string) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("manga/RefreshChapterContainers", &err)
|
||||
|
||||
// Read the cache directory
|
||||
entries, err := os.ReadDir(r.cacheDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
removedMediaIds := make(map[int]struct{})
|
||||
mu := sync.Mutex{}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(entries))
|
||||
for _, entry := range entries {
|
||||
go func(entry os.DirEntry) {
|
||||
defer wg.Done()
|
||||
|
||||
if entry.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
provider, bucketType, mediaId, ok := ParseChapterContainerFileName(entry.Name())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// If the bucket type is not chapter, skip
|
||||
if bucketType != bucketTypeChapter {
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Trace().Str("provider", provider).Int("mediaId", mediaId).Msg("manga: Refetching chapter container")
|
||||
|
||||
mu.Lock()
|
||||
// Remove the container from the cache if it hasn't been removed yet
|
||||
if _, ok := removedMediaIds[mediaId]; !ok {
|
||||
_ = r.EmptyMangaCache(mediaId)
|
||||
removedMediaIds[mediaId] = struct{}{}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// If a selectedProviderMap is provided, check if the provider is in the map
|
||||
if selectedProviderMap != nil {
|
||||
// If the manga is not in the map, continue
|
||||
if _, ok := selectedProviderMap[mediaId]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// If the provider is not the one selected, continue
|
||||
if selectedProviderMap[mediaId] != provider {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the manga from the collection
|
||||
mangaEntry, found := mangaCollection.GetListEntryFromMangaId(mediaId)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
// If the manga is not currently reading or repeating, continue
|
||||
if *mangaEntry.GetStatus() != anilist.MediaListStatusCurrent && *mangaEntry.GetStatus() != anilist.MediaListStatusRepeating {
|
||||
return
|
||||
}
|
||||
|
||||
// Refetch the container
|
||||
_, err = r.GetMangaChapterContainer(&GetMangaChapterContainerOptions{
|
||||
Provider: provider,
|
||||
MediaId: mediaId,
|
||||
Titles: mangaEntry.GetMedia().GetAllTitles(),
|
||||
Year: mangaEntry.GetMedia().GetStartYearSafe(),
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msg("manga: Failed to refetch chapter container")
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Trace().Str("provider", provider).Int("mediaId", mediaId).Msg("manga: Refetched chapter container")
|
||||
}(entry)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const ChapterCountMapCacheKey = 1
|
||||
|
||||
var mangaLatestChapterNumberMap = result.NewResultMap[int, map[int][]MangaLatestChapterNumberItem]()
|
||||
|
||||
type MangaLatestChapterNumberItem struct {
|
||||
Provider string `json:"provider"`
|
||||
Scanlator string `json:"scanlator"`
|
||||
Language string `json:"language"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
// GetMangaLatestChapterNumbersMap retrieves the latest chapter number for all manga entries.
|
||||
// It scans the cache directory for chapter containers and counts the number of chapters fetched from the provider for each manga.
|
||||
//
|
||||
// Unlike [GetMangaLatestChapterNumberMap], it will segregate the chapter numbers by scanlator and language.
|
||||
func (r *Repository) GetMangaLatestChapterNumbersMap() (ret map[int][]MangaLatestChapterNumberItem, err error) {
|
||||
defer util.HandlePanicInModuleThen("manga/GetMangaLatestChapterNumbersMap", func() {})
|
||||
ret = make(map[int][]MangaLatestChapterNumberItem)
|
||||
|
||||
if m, ok := mangaLatestChapterNumberMap.Get(ChapterCountMapCacheKey); ok {
|
||||
ret = m
|
||||
return
|
||||
}
|
||||
|
||||
// Go through all chapter container caches
|
||||
entries, err := os.ReadDir(r.cacheDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the provider and mediaId from the file cache name
|
||||
provider, mediaId, ok := parseChapterFileName(entry.Name())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
containerBucket := r.getFcProviderBucket(provider, mediaId, bucketTypeChapter)
|
||||
|
||||
// Get the container from the file cache
|
||||
var container *ChapterContainer
|
||||
chapterContainerKey := getMangaChapterContainerCacheKey(provider, mediaId)
|
||||
if found, _ := r.fileCacher.Get(containerBucket, chapterContainerKey, &container); !found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create groups
|
||||
groupByScanlator := lo.GroupBy(container.Chapters, func(c *hibikemanga.ChapterDetails) string {
|
||||
return c.Scanlator
|
||||
})
|
||||
|
||||
for scanlator, chapters := range groupByScanlator {
|
||||
groupByLanguage := lo.GroupBy(chapters, func(c *hibikemanga.ChapterDetails) string {
|
||||
return c.Language
|
||||
})
|
||||
|
||||
for language, chapters := range groupByLanguage {
|
||||
lastChapter := slices.MaxFunc(chapters, func(a *hibikemanga.ChapterDetails, b *hibikemanga.ChapterDetails) int {
|
||||
return cmp.Compare(a.Index, b.Index)
|
||||
})
|
||||
|
||||
chapterNumFloat, _ := strconv.ParseFloat(lastChapter.Chapter, 32)
|
||||
chapterCount := int(math.Floor(chapterNumFloat))
|
||||
|
||||
if _, ok := ret[mediaId]; !ok {
|
||||
ret[mediaId] = []MangaLatestChapterNumberItem{}
|
||||
}
|
||||
|
||||
ret[mediaId] = append(ret[mediaId], MangaLatestChapterNumberItem{
|
||||
Provider: provider,
|
||||
Scanlator: scanlator,
|
||||
Language: language,
|
||||
Number: chapterCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger hook event
|
||||
ev := &MangaLatestChapterNumbersMapEvent{
|
||||
LatestChapterNumbersMap: ret,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnMangaLatestChapterNumbersMap().Trigger(ev)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msg("manga: Exception occurred while triggering hook event")
|
||||
}
|
||||
ret = ev.LatestChapterNumbersMap
|
||||
|
||||
mangaLatestChapterNumberMap.Set(ChapterCountMapCacheKey, ret)
|
||||
return
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func parseChapterFileName(dirName string) (provider string, mId int, ok bool) {
|
||||
if !strings.HasPrefix(dirName, "manga_") {
|
||||
return "", 0, false
|
||||
}
|
||||
dirName = strings.TrimSuffix(dirName, ".cache")
|
||||
parts := strings.Split(dirName, "_")
|
||||
if len(parts) != 4 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
provider = parts[1]
|
||||
mId, err := strconv.Atoi(parts[3])
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
return provider, mId, true
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func GetBestSearchResult(searchRes []*hibikemanga.SearchResult) *hibikemanga.SearchResult {
|
||||
bestRes := searchRes[0]
|
||||
for _, res := range searchRes {
|
||||
if res.SearchRating > bestRes.SearchRating {
|
||||
bestRes = res
|
||||
}
|
||||
}
|
||||
return bestRes
|
||||
}
|
||||
|
||||
// HydrateSearchResultSearchRating rates the search results based on the provided title
|
||||
// It checks if all search results have a rating of 0 and if so, it calculates ratings
|
||||
// using the Sorensen-Dice
|
||||
func HydrateSearchResultSearchRating(_searchRes []*hibikemanga.SearchResult, title *string) {
|
||||
// Rate the search results if all ratings are 0
|
||||
if noRatings := lo.EveryBy(_searchRes, func(res *hibikemanga.SearchResult) bool {
|
||||
return res.SearchRating == 0
|
||||
}); noRatings {
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(_searchRes))
|
||||
for _, res := range _searchRes {
|
||||
go func(res *hibikemanga.SearchResult) {
|
||||
defer wg.Done()
|
||||
|
||||
compTitles := []*string{&res.Title}
|
||||
if res.Synonyms == nil || len(res.Synonyms) == 0 {
|
||||
return
|
||||
}
|
||||
for _, syn := range res.Synonyms {
|
||||
compTitles = append(compTitles, &syn)
|
||||
}
|
||||
|
||||
compRes, ok := comparison.FindBestMatchWithSorensenDice(title, compTitles)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
res.SearchRating = compRes.Rating
|
||||
return
|
||||
}(res)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user