diff --git a/integrations/tvmaze/client.go b/integrations/tvmaze/client.go index e7a2ece1..db5ff5b0 100644 --- a/integrations/tvmaze/client.go +++ b/integrations/tvmaze/client.go @@ -8,10 +8,19 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" "strings" "time" "unicode" + + "mal/internal/domain" +) + +var ( + seasonNumberPattern = regexp.MustCompile(`(?i)(?:\bseason\s+(\d+)|\b(\d+)(?:st|nd|rd|th)\s+season)\b`) + seasonSuffixPattern = regexp.MustCompile(`(?i)\s+(?:(?:season\s+\d+)|(?:\d+(?:st|nd|rd|th)\s+season))(?:\s+part\s+\d+)?\s*$`) + partNumberPattern = regexp.MustCompile(`(?i)\bpart\s+(\d+)\b`) ) const baseURL = "https://api.tvmaze.com" @@ -50,7 +59,7 @@ func (c *Client) Name() string { func (c *Client) ResolveEpisodeProviderID(ctx context.Context, _ int, titleCandidates []string) (string, error) { matches := map[int]struct{}{} - for _, candidate := range uniqueTitleCandidates(titleCandidates, 4) { + for _, candidate := range titleSearchCandidates(titleCandidates, 6) { results, err := c.search(ctx, candidate) if err != nil { return "", err @@ -101,11 +110,15 @@ func addExactMatches(matches map[int]struct{}, results []searchResult, normalize } } -func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string) (map[int]string, error) { +func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime domain.Anime, episodeCount int) (map[int]string, error) { var episodes []episode if err := c.getJSON(ctx, "/shows/"+url.PathEscape(providerID)+"/episodes", &episodes); err != nil { return nil, err } + episodes, err := episodesForAnimeSeason(episodes, anime, episodeCount) + if err != nil { + return nil, err + } titles := make(map[int]string, len(episodes)) for i, item := range episodes { @@ -120,6 +133,99 @@ func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID st return titles, nil } +func titleSearchCandidates(candidates []string, limit int) []string { + expanded := make([]string, 0, len(candidates)*2) + for _, candidate := range candidates { + parent := strings.TrimSpace(seasonSuffixPattern.ReplaceAllString(candidate, "")) + if parent != "" && parent != strings.TrimSpace(candidate) { + expanded = append(expanded, parent) + } + expanded = append(expanded, candidate) + } + return uniqueTitleCandidates(expanded, limit) +} + +func episodesForAnimeSeason(episodes []episode, anime domain.Anime, episodeCount int) ([]episode, error) { + seasons := groupEpisodesBySeason(episodes) + season := animeSeasonNumber(anime) + if season == 0 { + season = seasonForYear(seasons, anime.Year) + } + if season == 0 { + season = 1 + } + + selected := seasons[season] + if len(selected) == 0 { + return nil, fmt.Errorf("tvmaze: season %d has no episodes", season) + } + if episodeCount <= 0 || episodeCount >= len(selected) { + return selected, nil + } + if animePartNumber(anime) > 1 { + return selected[len(selected)-episodeCount:], nil + } + return selected[:episodeCount], nil +} + +func groupEpisodesBySeason(episodes []episode) map[int][]episode { + seasons := map[int][]episode{} + for _, item := range episodes { + if item.Season > 0 { + seasons[item.Season] = append(seasons[item.Season], item) + } + } + return seasons +} + +func animeSeasonNumber(anime domain.Anime) int { + return titleNumber(animeTitles(anime), seasonNumberPattern) +} + +func animePartNumber(anime domain.Anime) int { + return titleNumber(animeTitles(anime), partNumberPattern) +} + +func titleNumber(titles []string, pattern *regexp.Regexp) int { + for _, title := range titles { + match := pattern.FindStringSubmatch(title) + if len(match) <= 1 { + continue + } + for _, value := range match[1:] { + number, err := strconv.Atoi(value) + if err == nil && number > 0 { + return number + } + } + } + return 0 +} + +func animeTitles(anime domain.Anime) []string { + titles := make([]string, 0, 3+len(anime.TitleSynonyms)) + titles = append(titles, anime.Title, anime.TitleEnglish, anime.TitleJapanese) + titles = append(titles, anime.TitleSynonyms...) + return titles +} + +func seasonForYear(seasons map[int][]episode, year int) int { + if year <= 0 { + return 0 + } + matchedSeason := 0 + for season, episodes := range seasons { + if len(episodes) == 0 || !strings.HasPrefix(episodes[0].Airdate, strconv.Itoa(year)+"-") { + continue + } + if matchedSeason != 0 { + return 0 + } + matchedSeason = season + } + return matchedSeason +} + func (c *Client) search(ctx context.Context, title string) ([]searchResult, error) { var results []searchResult err := c.getJSON(ctx, "/search/shows?q="+url.QueryEscape(title), &results)