package torrent import ( "cmp" "context" "fmt" "seanime/internal/api/anilist" "seanime/internal/api/metadata" "seanime/internal/debrid/debrid" "seanime/internal/extension" hibiketorrent "seanime/internal/extension/hibike/torrent" "seanime/internal/library/anime" "seanime/internal/util" "seanime/internal/util/comparison" "seanime/internal/util/result" "slices" "strconv" "sync" "github.com/5rahim/habari" "github.com/samber/lo" "github.com/samber/mo" ) const ( AnimeSearchTypeSmart AnimeSearchType = "smart" AnimeSearchTypeSimple AnimeSearchType = "simple" ) var ( metadataCache = result.NewResultMap[string, *TorrentMetadata]() ) type ( AnimeSearchType string AnimeSearchOptions struct { // Provider extension ID Provider string Type AnimeSearchType Media *anilist.BaseAnime // Search options Query string // Filter options Batch bool EpisodeNumber int BestReleases bool Resolution string } // Preview contains the torrent and episode information Preview struct { Episode *anime.Episode `json:"episode"` // nil if batch Torrent *hibiketorrent.AnimeTorrent `json:"torrent"` } TorrentMetadata struct { Distance int `json:"distance"` Metadata *habari.Metadata `json:"metadata"` } // SearchData is the struct returned by NewSmartSearch SearchData struct { Torrents []*hibiketorrent.AnimeTorrent `json:"torrents"` // Torrents found Previews []*Preview `json:"previews"` // TorrentPreview for each torrent TorrentMetadata map[string]*TorrentMetadata `json:"torrentMetadata"` // Torrent metadata DebridInstantAvailability map[string]debrid.TorrentItemInstantAvailability `json:"debridInstantAvailability"` // Debrid instant availability AnimeMetadata *metadata.AnimeMetadata `json:"animeMetadata"` // Animap media } ) func (r *Repository) SearchAnime(ctx context.Context, opts AnimeSearchOptions) (ret *SearchData, err error) { defer util.HandlePanicInModuleWithError("torrents/torrent/SearchAnime", &err) r.logger.Debug().Str("provider", opts.Provider).Str("type", string(opts.Type)).Str("query", opts.Query).Msg("torrent repo: Searching for anime torrents") // Find the provider by ID providerExtension, ok := extension.GetExtension[extension.AnimeTorrentProviderExtension](r.extensionBank, opts.Provider) if !ok { // Get the default provider providerExtension, ok = r.GetDefaultAnimeProviderExtension() if !ok { return nil, fmt.Errorf("torrent provider not found") } } if opts.Type == AnimeSearchTypeSmart && !providerExtension.GetProvider().GetSettings().CanSmartSearch { return nil, fmt.Errorf("provider does not support smart search") } var torrents []*hibiketorrent.AnimeTorrent // Fetch Animap media animeMetadata := mo.None[*metadata.AnimeMetadata]() animeMetadataF, err := r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, opts.Media.GetID()) if err == nil { animeMetadata = mo.Some(animeMetadataF) } queryMedia := hibiketorrent.Media{ ID: opts.Media.GetID(), IDMal: opts.Media.GetIDMal(), Status: string(*opts.Media.GetStatus()), Format: string(*opts.Media.GetFormat()), EnglishTitle: opts.Media.GetTitle().GetEnglish(), RomajiTitle: opts.Media.GetRomajiTitleSafe(), EpisodeCount: opts.Media.GetTotalEpisodeCount(), AbsoluteSeasonOffset: 0, Synonyms: opts.Media.GetSynonymsContainingSeason(), IsAdult: *opts.Media.GetIsAdult(), StartDate: &hibiketorrent.FuzzyDate{ Year: *opts.Media.GetStartDate().GetYear(), Month: opts.Media.GetStartDate().GetMonth(), Day: opts.Media.GetStartDate().GetDay(), }, } //// Force simple search if Animap media is absent //if opts.Type == AnimeSearchTypeSmart && animeMetadata.IsAbsent() { // opts.Type = AnimeSearchTypeSimple //} var queryKey string switch opts.Type { case AnimeSearchTypeSmart: anidbAID := 0 anidbEID := 0 // Get the AniDB Anime ID and Episode ID if animeMetadata.IsPresent() { // Override absolute offset value of queryMedia queryMedia.AbsoluteSeasonOffset = animeMetadata.MustGet().GetOffset() if animeMetadata.MustGet().GetMappings() != nil { anidbAID = animeMetadata.MustGet().GetMappings().AnidbId // Find Animap Episode based on inputted episode number episodeMetadata, found := animeMetadata.MustGet().FindEpisode(strconv.Itoa(opts.EpisodeNumber)) if found { anidbEID = episodeMetadata.AnidbEid } } } queryKey = fmt.Sprintf("%d-%s-%d-%d-%d-%s-%t-%t", opts.Media.GetID(), opts.Query, opts.EpisodeNumber, anidbAID, anidbEID, opts.Resolution, opts.BestReleases, opts.Batch) if cache, found := r.animeProviderSmartSearchCaches.Get(opts.Provider); found { // Check the cache data, found := cache.Get(queryKey) if found { r.logger.Debug().Str("provider", opts.Provider).Str("type", string(opts.Type)).Msg("torrent repo: Cache HIT") return data, nil } } // Check for context cancellation before making the request select { case <-ctx.Done(): return nil, ctx.Err() default: } torrents, err = providerExtension.GetProvider().SmartSearch(hibiketorrent.AnimeSmartSearchOptions{ Media: queryMedia, Query: opts.Query, Batch: opts.Batch, EpisodeNumber: opts.EpisodeNumber, Resolution: opts.Resolution, AnidbAID: anidbAID, AnidbEID: anidbEID, BestReleases: opts.BestReleases, }) torrents = lo.UniqBy(torrents, func(t *hibiketorrent.AnimeTorrent) string { return t.InfoHash }) case AnimeSearchTypeSimple: queryKey = fmt.Sprintf("%d-%s", opts.Media.GetID(), opts.Query) if cache, found := r.animeProviderSearchCaches.Get(opts.Provider); found { // Check the cache data, found := cache.Get(queryKey) if found { r.logger.Debug().Str("provider", opts.Provider).Str("type", string(opts.Type)).Msg("torrent repo: Cache HIT") return data, nil } } // Check for context cancellation before making the request select { case <-ctx.Done(): return nil, ctx.Err() default: } torrents, err = providerExtension.GetProvider().Search(hibiketorrent.AnimeSearchOptions{ Media: queryMedia, Query: opts.Query, }) } if err != nil { return nil, err } // // Torrent metadata // torrentMetadata := make(map[string]*TorrentMetadata) mu := sync.Mutex{} wg := sync.WaitGroup{} wg.Add(len(torrents)) for _, t := range torrents { go func(t *hibiketorrent.AnimeTorrent) { defer wg.Done() metadata, found := metadataCache.Get(t.Name) if !found { m := habari.Parse(t.Name) var distance *comparison.LevenshteinResult distance, ok := comparison.FindBestMatchWithLevenshtein(&m.Title, opts.Media.GetAllTitles()) if !ok { distance = &comparison.LevenshteinResult{ Distance: 1000, } } metadata = &TorrentMetadata{ Distance: distance.Distance, Metadata: m, } metadataCache.Set(t.Name, metadata) } mu.Lock() torrentMetadata[t.InfoHash] = metadata mu.Unlock() }(t) } wg.Wait() // // Previews // previews := make([]*Preview, 0) if opts.Type == AnimeSearchTypeSmart { wg := sync.WaitGroup{} wg.Add(len(torrents)) for _, t := range torrents { go func(t *hibiketorrent.AnimeTorrent) { defer wg.Done() // Check for context cancellation in each goroutine select { case <-ctx.Done(): return default: } preview := r.createAnimeTorrentPreview(createAnimeTorrentPreviewOptions{ torrent: t, media: opts.Media, animeMetadata: animeMetadata, searchOpts: &opts, }) if preview != nil { previews = append(previews, preview) } }(t) } wg.Wait() // Check if context was cancelled during preview creation select { case <-ctx.Done(): return nil, ctx.Err() default: } } // sort both by seeders slices.SortFunc(torrents, func(i, j *hibiketorrent.AnimeTorrent) int { return cmp.Compare(j.Seeders, i.Seeders) }) previews = lo.Filter(previews, func(p *Preview, _ int) bool { return p != nil && p.Torrent != nil }) slices.SortFunc(previews, func(i, j *Preview) int { return cmp.Compare(j.Torrent.Seeders, i.Torrent.Seeders) }) ret = &SearchData{ Torrents: torrents, Previews: previews, TorrentMetadata: torrentMetadata, } if animeMetadata.IsPresent() { ret.AnimeMetadata = animeMetadata.MustGet() } // Store the data in the cache switch opts.Type { case AnimeSearchTypeSmart: if cache, found := r.animeProviderSmartSearchCaches.Get(opts.Provider); found { cache.Set(queryKey, ret) } case AnimeSearchTypeSimple: if cache, found := r.animeProviderSearchCaches.Get(opts.Provider); found { cache.Set(queryKey, ret) } } return } type createAnimeTorrentPreviewOptions struct { torrent *hibiketorrent.AnimeTorrent media *anilist.BaseAnime animeMetadata mo.Option[*metadata.AnimeMetadata] searchOpts *AnimeSearchOptions } func (r *Repository) createAnimeTorrentPreview(opts createAnimeTorrentPreviewOptions) *Preview { var parsedData *habari.Metadata metadata, found := metadataCache.Get(opts.torrent.Name) if !found { // Should always be found parsedData = habari.Parse(opts.torrent.Name) metadataCache.Set(opts.torrent.Name, &TorrentMetadata{ Distance: 1000, Metadata: parsedData, }) } parsedData = metadata.Metadata isBatch := opts.torrent.IsBestRelease || opts.torrent.IsBatch || comparison.ValueContainsBatchKeywords(opts.torrent.Name) || // Contains batch keywords (!opts.media.IsMovieOrSingleEpisode() && len(parsedData.EpisodeNumber) > 1) // Multiple episodes parsed & not a movie if opts.torrent.ReleaseGroup == "" { opts.torrent.ReleaseGroup = parsedData.ReleaseGroup } if opts.torrent.Resolution == "" { opts.torrent.Resolution = parsedData.VideoResolution } if opts.torrent.FormattedSize == "" { opts.torrent.FormattedSize = util.Bytes(uint64(opts.torrent.Size)) } if isBatch { return &Preview{ Episode: nil, // Will be displayed as batch Torrent: opts.torrent, } } // If past this point we haven't detected a batch but the episode number returned from the provider is -1 // we will parse it from the torrent name if opts.torrent.EpisodeNumber == -1 && len(parsedData.EpisodeNumber) == 1 { opts.torrent.EpisodeNumber = util.StringToIntMust(parsedData.EpisodeNumber[0]) } // If the torrent is confirmed, use the episode number from the search options // because it could be absolute if opts.torrent.Confirmed { opts.torrent.EpisodeNumber = opts.searchOpts.EpisodeNumber } // If there was no single episode number parsed but the media is movie, set the episode number to 1 if opts.torrent.EpisodeNumber == -1 && opts.media.IsMovieOrSingleEpisode() { opts.torrent.EpisodeNumber = 1 } if opts.animeMetadata.IsPresent() { // normalize episode number if opts.torrent.EpisodeNumber >= 0 && opts.torrent.EpisodeNumber > opts.media.GetCurrentEpisodeCount() { opts.torrent.EpisodeNumber = opts.torrent.EpisodeNumber - opts.animeMetadata.MustGet().GetOffset() } animeMetadata := opts.animeMetadata.MustGet() _, foundEp := animeMetadata.FindEpisode(strconv.Itoa(opts.searchOpts.EpisodeNumber)) if foundEp { var episode *anime.Episode // Remove the episode if the parsed episode number is not the same as the search option if isProbablySameEpisode(parsedData.EpisodeNumber, opts.searchOpts.EpisodeNumber, opts.animeMetadata.MustGet().GetOffset()) { ep := opts.searchOpts.EpisodeNumber episode = anime.NewEpisode(&anime.NewEpisodeOptions{ LocalFile: nil, OptionalAniDBEpisode: strconv.Itoa(ep), AnimeMetadata: animeMetadata, Media: opts.media, ProgressOffset: 0, IsDownloaded: false, MetadataProvider: r.metadataProvider, }) episode.IsInvalid = false if episode.DisplayTitle == "" { episode.DisplayTitle = parsedData.Title } } return &Preview{ Episode: episode, Torrent: opts.torrent, } } var episode *anime.Episode // Remove the episode if the parsed episode number is not the same as the search option if isProbablySameEpisode(parsedData.EpisodeNumber, opts.searchOpts.EpisodeNumber, opts.animeMetadata.MustGet().GetOffset()) { displayTitle := "" if len(parsedData.EpisodeNumber) == 1 && parsedData.EpisodeNumber[0] != strconv.Itoa(opts.searchOpts.EpisodeNumber) { displayTitle = fmt.Sprintf("Episode %s", parsedData.EpisodeNumber[0]) } // If the episode number could not be found in the Animap media, create a new episode episode = &anime.Episode{ Type: anime.LocalFileTypeMain, DisplayTitle: displayTitle, EpisodeTitle: "", EpisodeNumber: opts.searchOpts.EpisodeNumber, ProgressNumber: opts.searchOpts.EpisodeNumber, AniDBEpisode: "", AbsoluteEpisodeNumber: 0, LocalFile: nil, IsDownloaded: false, EpisodeMetadata: anime.NewEpisodeMetadata(opts.animeMetadata.MustGet(), nil, opts.media, r.metadataProvider), FileMetadata: nil, IsInvalid: false, MetadataIssue: "", BaseAnime: opts.media, } } return &Preview{ Episode: episode, Torrent: opts.torrent, } } return &Preview{ Episode: nil, Torrent: opts.torrent, } } func isProbablySameEpisode(parsedEpisode []string, searchEpisode int, absoluteOffset int) bool { if len(parsedEpisode) == 1 { if util.StringToIntMust(parsedEpisode[0]) == searchEpisode || util.StringToIntMust(parsedEpisode[0]) == searchEpisode+absoluteOffset { return true } } return false }