package torrentstream import ( "cmp" "context" "fmt" "seanime/internal/api/anilist" hibiketorrent "seanime/internal/extension/hibike/torrent" "seanime/internal/hook" torrentanalyzer "seanime/internal/torrents/analyzer" itorrent "seanime/internal/torrents/torrent" "seanime/internal/util" "seanime/internal/util/torrentutil" "slices" "time" "github.com/anacrolix/torrent" "github.com/samber/lo" ) var ( ErrNoTorrentsFound = fmt.Errorf("no torrents found, please select manually") ErrNoEpisodeFound = fmt.Errorf("could not select episode from torrents, please select manually") ) type ( playbackTorrent struct { Torrent *torrent.Torrent File *torrent.File } ) // setPriorityDownloadStrategy sets piece priorities for optimal streaming experience // This helps to optimize initial buffering, seeking, and end-of-file playback func (r *Repository) setPriorityDownloadStrategy(t *torrent.Torrent, file *torrent.File) { torrentutil.PrioritizeDownloadPieces(t, file, r.logger) } func (r *Repository) findBestTorrent(media *anilist.CompleteAnime, aniDbEpisode string, episodeNumber int) (ret *playbackTorrent, err error) { defer util.HandlePanicInModuleWithError("torrentstream/findBestTorrent", &err) r.logger.Debug().Msgf("torrentstream: Finding best torrent for %s, Episode %d", media.GetTitleSafe(), episodeNumber) providerId := itorrent.ProviderAnimeTosho // todo: get provider from settings fallbackProviderId := itorrent.ProviderNyaa // Get AnimeTosho provider extension providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(providerId) if !ok { r.logger.Error().Str("provider", itorrent.ProviderAnimeTosho).Msg("torrentstream: AnimeTosho provider extension not found") return nil, fmt.Errorf("provider extension not found") } searchBatch := false // Search batch if not a movie and finished yearsSinceStart := 999 if media.StartDate != nil && *media.StartDate.Year > 0 { yearsSinceStart = time.Now().Year() - *media.StartDate.Year // e.g. 2024 - 2020 = 4 } if !media.IsMovie() && media.IsFinished() && yearsSinceStart > 4 { searchBatch = true } r.sendStateEvent(eventLoading, TLSStateSearchingTorrents) var data *itorrent.SearchData var currentProvider string = providerId searchLoop: for { var err error data, err = r.torrentRepository.SearchAnime(context.Background(), itorrent.AnimeSearchOptions{ Provider: currentProvider, Type: itorrent.AnimeSearchTypeSmart, Media: media.ToBaseAnime(), Query: "", Batch: searchBatch, EpisodeNumber: episodeNumber, BestReleases: false, Resolution: r.settings.MustGet().PreferredResolution, }) // If we are searching for batches, we don't want to return an error if no torrents are found // We will just search again without the batch flag if err != nil && !searchBatch { r.logger.Error().Err(err).Msg("torrentstream: Error searching torrents") // Try fallback provider if we're still on primary provider if currentProvider == providerId { r.logger.Debug().Msgf("torrentstream: Primary provider failed, trying fallback provider %s", fallbackProviderId) currentProvider = fallbackProviderId // Get fallback provider extension providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider) if !ok { r.logger.Error().Str("provider", fallbackProviderId).Msg("torrentstream: Fallback provider extension not found") return nil, fmt.Errorf("fallback provider extension not found") } continue } return nil, err } else if err != nil { searchBatch = false continue } // This whole thing below just means that // If we are looking for batches, there should be at least 3 torrents found or the max seeders should be at least 15 if searchBatch { nbFound := len(data.Torrents) seedersArr := lo.Map(data.Torrents, func(t *hibiketorrent.AnimeTorrent, _ int) int { return t.Seeders }) if len(seedersArr) == 0 { searchBatch = false continue } maxSeeders := slices.Max(seedersArr) if maxSeeders >= 15 || nbFound > 2 { break searchLoop } else { searchBatch = false } } else { break searchLoop } } if data == nil || len(data.Torrents) == 0 { // Try fallback provider if we're still on primary provider if currentProvider == providerId { r.logger.Debug().Msgf("torrentstream: No torrents found with primary provider, trying fallback provider %s", fallbackProviderId) currentProvider = fallbackProviderId // Get fallback provider extension providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider) if !ok { r.logger.Error().Str("provider", fallbackProviderId).Msg("torrentstream: Fallback provider extension not found") return nil, fmt.Errorf("fallback provider extension not found") } // Try searching with fallback provider (reset searchBatch) searchBatch = false if !media.IsMovie() && media.IsFinished() && yearsSinceStart > 4 { searchBatch = true } // Restart the search with fallback provider goto searchLoop } r.logger.Error().Msg("torrentstream: No torrents found") return nil, ErrNoTorrentsFound } // Sort by seeders from highest to lowest slices.SortStableFunc(data.Torrents, func(a, b *hibiketorrent.AnimeTorrent) int { return cmp.Compare(b.Seeders, a.Seeders) }) // Trigger hook fetchedEvent := &TorrentStreamAutoSelectTorrentsFetchedEvent{ Torrents: data.Torrents, } _ = hook.GlobalHookManager.OnTorrentStreamAutoSelectTorrentsFetched().Trigger(fetchedEvent) data.Torrents = fetchedEvent.Torrents r.logger.Debug().Msgf("torrentstream: Found %d torrents", len(data.Torrents)) // Go through the top 3 torrents // - For each torrent, add it, get the files, and check if it has the episode // - If it does, return the magnet link var selectedTorrent *torrent.Torrent var selectedFile *torrent.File tries := 0 for _, searchT := range data.Torrents { if tries >= 2 { break } r.sendStateEvent(eventLoading, struct { State any `json:"state"` TorrentBeingLoaded string `json:"torrentBeingLoaded"` }{ State: TLSStateAddingTorrent, TorrentBeingLoaded: searchT.Name, }) r.logger.Trace().Msgf("torrentstream: Getting torrent magnet") magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(searchT) if err != nil { r.logger.Warn().Err(err).Msgf("torrentstream: Error scraping magnet link for %s", searchT.Link) tries++ continue } r.logger.Debug().Msgf("torrentstream: Adding torrent %s from magnet", searchT.Link) t, err := r.client.AddTorrent(magnet) if err != nil { r.logger.Warn().Err(err).Msgf("torrentstream: Error adding torrent %s", searchT.Link) tries++ continue } r.sendStateEvent(eventLoading, struct { State any `json:"state"` TorrentBeingLoaded string `json:"torrentBeingLoaded"` }{ State: TLSStateCheckingTorrent, TorrentBeingLoaded: searchT.Name, }) // If the torrent has only one file, return it if len(t.Files()) == 1 { tFile := t.Files()[0] tFile.Download() r.setPriorityDownloadStrategy(t, tFile) r.logger.Debug().Msgf("torrentstream: Found single file torrent: %s", tFile.DisplayPath()) return &playbackTorrent{ Torrent: t, File: tFile, }, nil } r.sendStateEvent(eventLoading, TLSStateSelectingFile) // DEVNOTE: The gap between adding the torrent and file analysis causes some pieces to be downloaded // We currently can't Pause/Resume torrents so :shrug: filepaths := lo.Map(t.Files(), func(f *torrent.File, _ int) string { return f.DisplayPath() }) if len(filepaths) == 0 { r.logger.Error().Msg("torrentstream: No files found in the torrent") return nil, fmt.Errorf("no files found in the torrent") } // Create a new Torrent Analyzer analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{ Logger: r.logger, Filepaths: filepaths, Media: media, Platform: r.platform, MetadataProvider: r.metadataProvider, ForceMatch: true, }) r.logger.Debug().Msgf("torrentstream: Analyzing torrent %s", searchT.Link) // Analyze torrent files analysis, err := analyzer.AnalyzeTorrentFiles() if err != nil { r.logger.Warn().Err(err).Msg("torrentstream: Error analyzing torrent files") // Remove torrent on failure go func() { _ = r.client.RemoveTorrent(t.InfoHash().AsString()) }() tries++ continue } analysisFile, found := analysis.GetFileByAniDBEpisode(aniDbEpisode) // Check if analyzer found the episode if !found { r.logger.Error().Msgf("torrentstream: Failed to auto-select episode from torrent %s", searchT.Link) // Remove torrent on failure go func() { _ = r.client.RemoveTorrent(t.InfoHash().AsString()) }() tries++ continue } r.logger.Debug().Msgf("torrentstream: Found corresponding file for episode %s: %s", aniDbEpisode, analysisFile.GetLocalFile().Name) // Download the file and unselect the rest for i, f := range t.Files() { if i != analysisFile.GetIndex() { f.SetPriority(torrent.PiecePriorityNone) } } tFile := t.Files()[analysisFile.GetIndex()] r.logger.Debug().Msgf("torrentstream: Selecting file %s", tFile.DisplayPath()) r.setPriorityDownloadStrategy(t, tFile) selectedTorrent = t selectedFile = tFile break } if selectedTorrent == nil { return nil, ErrNoEpisodeFound } ret = &playbackTorrent{ Torrent: selectedTorrent, File: selectedFile, } return ret, nil } // findBestTorrentFromManualSelection is like findBestTorrent but no need to search for the best torrent first func (r *Repository) findBestTorrentFromManualSelection(t *hibiketorrent.AnimeTorrent, media *anilist.CompleteAnime, aniDbEpisode string, chosenFileIndex *int) (*playbackTorrent, error) { r.logger.Debug().Msgf("torrentstream: Analyzing torrent from %s for %s", t.Link, media.GetTitleSafe()) // Get the torrent's provider extension providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(t.Provider) if !ok { r.logger.Error().Str("provider", t.Provider).Msg("torrentstream: provider extension not found") return nil, fmt.Errorf("provider extension not found") } // First, add the torrent magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(t) if err != nil { r.logger.Error().Err(err).Msgf("torrentstream: Error scraping magnet link for %s", t.Link) return nil, fmt.Errorf("could not get magnet link from %s", t.Link) } selectedTorrent, err := r.client.AddTorrent(magnet) if err != nil { r.logger.Error().Err(err).Msgf("torrentstream: Error adding torrent %s", t.Link) return nil, err } // If the torrent has only one file, return it if len(selectedTorrent.Files()) == 1 { tFile := selectedTorrent.Files()[0] tFile.Download() r.setPriorityDownloadStrategy(selectedTorrent, tFile) r.logger.Debug().Msgf("torrentstream: Found single file torrent: %s", tFile.DisplayPath()) return &playbackTorrent{ Torrent: selectedTorrent, File: tFile, }, nil } var fileIndex int // If the file index is already selected if chosenFileIndex != nil { fileIndex = *chosenFileIndex } else { // We know the torrent has multiple files, so we'll need to analyze it filepaths := lo.Map(selectedTorrent.Files(), func(f *torrent.File, _ int) string { return f.DisplayPath() }) if len(filepaths) == 0 { r.logger.Error().Msg("torrentstream: No files found in the torrent") return nil, fmt.Errorf("no files found in the torrent") } // Create a new Torrent Analyzer analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{ Logger: r.logger, Filepaths: filepaths, Media: media, Platform: r.platform, MetadataProvider: r.metadataProvider, ForceMatch: true, }) // Analyze torrent files analysis, err := analyzer.AnalyzeTorrentFiles() if err != nil { r.logger.Warn().Err(err).Msg("torrentstream: Error analyzing torrent files") // Remove torrent on failure go func() { _ = r.client.RemoveTorrent(selectedTorrent.InfoHash().AsString()) }() return nil, err } analysisFile, found := analysis.GetFileByAniDBEpisode(aniDbEpisode) // Check if analyzer found the episode if !found { r.logger.Error().Msgf("torrentstream: Failed to auto-select episode from torrent %s", selectedTorrent.Info().Name) // Remove torrent on failure go func() { _ = r.client.RemoveTorrent(selectedTorrent.InfoHash().AsString()) }() return nil, ErrNoEpisodeFound } r.logger.Debug().Msgf("torrentstream: Found corresponding file for episode %s: %s", aniDbEpisode, analysisFile.GetLocalFile().Name) fileIndex = analysisFile.GetIndex() } // Download the file and unselect the rest for i, f := range selectedTorrent.Files() { if i != fileIndex { f.SetPriority(torrent.PiecePriorityNone) } } //selectedTorrent.Files()[fileIndex].SetPriority(torrent.PiecePriorityNormal) r.logger.Debug().Msgf("torrentstream: Selected torrent %s", selectedTorrent.Files()[fileIndex].DisplayPath()) tFile := selectedTorrent.Files()[fileIndex] tFile.Download() r.setPriorityDownloadStrategy(selectedTorrent, tFile) ret := &playbackTorrent{ Torrent: selectedTorrent, File: selectedTorrent.Files()[fileIndex], } return ret, nil }