package onlinestream import ( "errors" "fmt" "seanime/internal/api/anilist" "seanime/internal/extension" hibikeonlinestream "seanime/internal/extension/hibike/onlinestream" onlinestream_providers "seanime/internal/onlinestream/providers" "seanime/internal/util/comparison" "strings" ) var ( ErrNoAnimeFound = errors.New("anime not found, try manual matching") ErrNoEpisodes = errors.New("no episodes found") errNoEpisodeSourceFound = errors.New("no source found for episode") ) type ( // episodeContainer contains results of fetching the episodes from the provider. episodeContainer struct { Provider string // List of episode details from the provider. // It is used to get the episode servers. ProviderEpisodeList []*hibikeonlinestream.EpisodeDetails // List of episodes with their servers. Episodes []*episodeData } // episodeData contains some details about a provider episode and all available servers. episodeData struct { Provider string ID string Number int Title string Servers []*hibikeonlinestream.EpisodeServer } ) // getEpisodeContainer gets the episode details and servers from the specified provider. // It takes the media ID, titles in order to fetch the episode details. // - This function can be used to only get the episode details by setting 'from' and 'to' to 0. // // Since the episode details are cached, we can request episode servers multiple times without fetching the episode details again. func (r *Repository) getEpisodeContainer(provider string, media *anilist.BaseAnime, from int, to int, dubbed bool, year int) (*episodeContainer, error) { r.logger.Debug(). Str("provider", provider). Int("mediaId", media.ID). Int("from", from). Int("to", to). Bool("dubbed", dubbed). Msg("onlinestream: Getting episode container") // Key identifying the provider episode list in the file cache. // It includes "dubbed" because Gogoanime has a different entry for dubbed anime. // e.g. 1$provider$true providerEpisodeListKey := fmt.Sprintf("%d$%s$%v", media.ID, provider, dubbed) // Create the episode container ec := &episodeContainer{ Provider: provider, Episodes: make([]*episodeData, 0), ProviderEpisodeList: make([]*hibikeonlinestream.EpisodeDetails, 0), } // Get the episode details from the provider. r.logger.Debug(). Str("key", providerEpisodeListKey). Msgf("onlinestream: Fetching %s episode list", provider) // Buckets for caching the episode list and episode data. fcEpisodeListBucket := r.getFcEpisodeListBucket(provider, media.ID) fcEpisodeDataBucket := r.getFcEpisodeDataBucket(provider, media.ID) // Check if the episode list is cached to avoid fetching it again. var providerEpisodeList []*hibikeonlinestream.EpisodeDetails if found, _ := r.fileCacher.Get(fcEpisodeListBucket, providerEpisodeListKey, &providerEpisodeList); !found { var err error providerEpisodeList, err = r.getProviderEpisodeList(provider, media, dubbed, year) if err != nil { r.logger.Error().Err(err).Msg("onlinestream: Failed to get provider episodes") return nil, err // ErrNoAnimeFound or ErrNoEpisodes } _ = r.fileCacher.Set(fcEpisodeListBucket, providerEpisodeListKey, providerEpisodeList) } else { r.logger.Debug(). Str("key", providerEpisodeListKey). Msg("onlinestream: Cache HIT for episode list") } ec.ProviderEpisodeList = providerEpisodeList var lastServerError error for _, episodeDetails := range providerEpisodeList { if episodeDetails.Number >= from && episodeDetails.Number <= to { // Check if the episode is cached to avoid fetching the sources again. key := fmt.Sprintf("%d$%s$%d$%v", media.ID, provider, episodeDetails.Number, dubbed) r.logger.Debug(). Str("key", key). Msgf("onlinestream: Fetching episode '%d' servers", episodeDetails.Number) // Check episode cache var cached *episodeData if found, _ := r.fileCacher.Get(fcEpisodeDataBucket, key, &cached); found { ec.Episodes = append(ec.Episodes, cached) r.logger.Debug(). Str("key", key). Msgf("onlinestream: Cache HIT for episode '%d' servers", episodeDetails.Number) continue } // Zoro dubs if provider == onlinestream_providers.ZoroProvider && dubbed { // If the episode details have both sub and dub, we need to get the dub episode. if !strings.HasSuffix(episodeDetails.ID, string(hibikeonlinestream.SubAndDub)) { // Skip sub-only episodes continue } // Replace "both" with "dub" so that [getProviderEpisodeServers] can find the dub episode. episodeDetails.ID = strings.Replace(episodeDetails.ID, string(hibikeonlinestream.SubAndDub), string(hibikeonlinestream.Dub), 1) } // Fetch episode servers servers, err := r.getProviderEpisodeServers(provider, episodeDetails) if err != nil { lastServerError = err r.logger.Error().Err(err).Msgf("onlinestream: failed to get episode '%d' servers", episodeDetails.Number) continue } episode := &episodeData{ ID: episodeDetails.ID, Number: episodeDetails.Number, Title: episodeDetails.Title, Servers: servers, } ec.Episodes = append(ec.Episodes, episode) r.logger.Debug(). Str("key", key). Msgf("onlinestream: Found %d servers for episode '%d'", len(servers), episodeDetails.Number) _ = r.fileCacher.Set(fcEpisodeDataBucket, key, episode) } } if from > 0 && to > 0 && len(ec.Episodes) == 0 { r.logger.Error().Err(lastServerError).Msg("onlinestream: No episode servers found") return nil, fmt.Errorf("no episode servers found, provider returned: '%w'", lastServerError) } if len(ec.ProviderEpisodeList) == 0 { r.logger.Error().Msg("onlinestream: No episodes found for this anime") return nil, fmt.Errorf("no episodes found for this anime") } return ec, nil } // getProviderEpisodeServers gets all the available servers for the episode. // It returns errNoEpisodeSourceFound if no sources are found. // // Example: // // episodeDetails, _ := getProviderEpisodeListFromTitles(provider, titles, dubbed) // episodeServers, err := getProviderEpisodeServers(provider, episodeDetails[0]) func (r *Repository) getProviderEpisodeServers(provider string, episodeDetails *hibikeonlinestream.EpisodeDetails) ([]*hibikeonlinestream.EpisodeServer, error) { var providerServers []*hibikeonlinestream.EpisodeServer providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider) if !ok { return nil, fmt.Errorf("provider extension '%s' not found", provider) } for _, episodeServer := range providerExtension.GetProvider().GetSettings().EpisodeServers { res, err := providerExtension.GetProvider().FindEpisodeServer(episodeDetails, episodeServer) if err == nil { // Add the server to the list for the episode providerServers = append(providerServers, res) } } if len(providerServers) == 0 { return nil, errNoEpisodeSourceFound } return providerServers, nil } // getProviderEpisodeList gets all the hibikeonlinestream.EpisodeDetails from the provider based on the anime's titles. // It returns ErrNoAnimeFound if the anime is not found or ErrNoEpisodes if no episodes are found. func (r *Repository) getProviderEpisodeList(provider string, media *anilist.BaseAnime, dubbed bool, year int) ([]*hibikeonlinestream.EpisodeDetails, error) { var ret []*hibikeonlinestream.EpisodeDetails // romajiTitle := strings.ReplaceAll(media.GetEnglishTitleSafe(), ":", "") // englishTitle := strings.ReplaceAll(media.GetRomajiTitleSafe(), ":", "") romajiTitle := media.GetRomajiTitleSafe() englishTitle := media.GetEnglishTitleSafe() providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider) if !ok { return nil, fmt.Errorf("provider extension '%s' not found", provider) } mId := media.ID var matchId string // +---------------------+ // | Database | // +---------------------+ // Search for the mapping in the database mapping, found := r.db.GetOnlinestreamMapping(provider, mId) if found { r.logger.Debug().Str("animeId", mapping.AnimeID).Msg("onlinestream: Using manual mapping") matchId = mapping.AnimeID } if matchId == "" { // +---------------------+ // | Search | // +---------------------+ // Get search results. var searchResults []*hibikeonlinestream.SearchResult queryMedia := hibikeonlinestream.Media{ ID: media.ID, IDMal: media.GetIDMal(), Status: string(*media.GetStatus()), Format: string(*media.GetFormat()), EnglishTitle: media.GetTitle().GetEnglish(), RomajiTitle: media.GetRomajiTitleSafe(), EpisodeCount: media.GetTotalEpisodeCount(), Synonyms: media.GetSynonymsContainingSeason(), IsAdult: *media.GetIsAdult(), StartDate: &hibikeonlinestream.FuzzyDate{ Year: *media.GetStartDate().GetYear(), Month: media.GetStartDate().GetMonth(), Day: media.GetStartDate().GetDay(), }, } added := make(map[string]struct{}) if romajiTitle != "" { // Search by romaji title res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{ Media: queryMedia, Query: romajiTitle, Dub: dubbed, Year: year, }) if err == nil && len(res) > 0 { searchResults = append(searchResults, res...) for _, r := range res { added[r.ID] = struct{}{} } } if err != nil { r.logger.Error().Err(err).Msg("onlinestream: Failed to search for romaji title") } r.logger.Debug(). Int("romajiTitleResults", len(res)). Msg("onlinestream: Found results for romaji title") } if englishTitle != "" { // Search by english title res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{ Media: queryMedia, Query: englishTitle, Dub: dubbed, Year: year, }) if err == nil && len(res) > 0 { for _, r := range res { if _, ok := added[r.ID]; !ok { searchResults = append(searchResults, r) } } } if err != nil { r.logger.Error().Err(err).Msg("onlinestream: Failed to search for english title") } r.logger.Debug(). Int("englishTitleResults", len(res)). Msg("onlinestream: Found results for english title") } if len(searchResults) == 0 { return nil, fmt.Errorf("automatic matching returned no results") } bestResult, found := GetBestSearchResult(searchResults, media.GetAllTitles()) if !found { return nil, ErrNoAnimeFound } matchId = bestResult.ID } // Fetch episodes. ret, err := providerExtension.GetProvider().FindEpisodes(matchId) if err != nil { return nil, fmt.Errorf("provider returned an error: %w", err) } if len(ret) == 0 { return nil, fmt.Errorf("provider returned no episodes") } return ret, nil } func GetBestSearchResult(searchResults []*hibikeonlinestream.SearchResult, titles []*string) (*hibikeonlinestream.SearchResult, bool) { // Filter results to get the best match. compBestResults := make([]*comparison.LevenshteinResult, 0, len(searchResults)) for _, r := range searchResults { // Compare search result title with all titles. compBestResult, found := comparison.FindBestMatchWithLevenshtein(&r.Title, titles) if found { compBestResults = append(compBestResults, compBestResult) } } if len(compBestResults) == 0 { return nil, false } compBestResult := compBestResults[0] for _, r := range compBestResults { if r.Distance < compBestResult.Distance { compBestResult = r } } // Get most accurate search result. var bestResult *hibikeonlinestream.SearchResult for _, r := range searchResults { if r.Title == *compBestResult.OriginalValue { bestResult = r break } } return bestResult, true }