diff --git a/internal/anime/simulcast.go b/internal/anime/simulcast.go new file mode 100644 index 00000000..5a2e2e0f --- /dev/null +++ b/internal/anime/simulcast.go @@ -0,0 +1,148 @@ +package anime + +import ( + "context" + "mal/integrations/jikan" + "mal/internal/domain" + "strconv" + "strings" +) + +type animeSeason struct { + Season string + Year int +} + +type seasonOption struct { + Season string + Year int + Label string +} + +var seasons = []string{"winter", "spring", "summer", "fall"} + +func seasonOptions(firstYear int, latest animeSeason) []seasonOption { + options := make([]seasonOption, 0, (latest.Year-firstYear+1)*len(seasons)) + for year := latest.Year; year >= firstYear; year-- { + start := len(seasons) - 1 + if year == latest.Year { + start = seasonIndex(latest.Season) + } + for i := start; i >= 0; i-- { + season := seasons[i] + options = append(options, seasonOption{ + Season: season, + Year: year, + Label: strings.ToUpper(season[:1]) + season[1:] + " " + strconv.Itoa(year), + }) + } + } + return options +} + +func seasonIndex(season string) int { + for i, candidate := range seasons { + if strings.EqualFold(candidate, season) { + return i + } + } + return 0 +} + +func calendarSeason(year, month int) animeSeason { + index := (month - 1) / 3 + if index < 0 || index >= len(seasons) { + index = 0 + } + return animeSeason{Season: seasons[index], Year: year} +} + +func adjacentSeason(season string, year, direction int) animeSeason { + index := 0 + for i, candidate := range seasons { + if strings.EqualFold(candidate, season) { + index = i + break + } + } + index += direction + if index < 0 { + index = len(seasons) - 1 + year-- + } else if index >= len(seasons) { + index = 0 + year++ + } + return animeSeason{Season: seasons[index], Year: year} +} + +func seasonNavigation(selected animeSeason, firstYear int, latest animeSeason) (*animeSeason, *animeSeason) { + var previous *animeSeason + var next *animeSeason + if selected.Year > firstYear || selected.Season != "winter" { + value := adjacentSeason(selected.Season, selected.Year, -1) + previous = &value + } + value := adjacentSeason(selected.Season, selected.Year, 1) + if value.Year < latest.Year || value.Year == latest.Year && seasonIndex(value.Season) <= seasonIndex(latest.Season) { + next = &value + } + return previous, next +} + +func seasonSelection(rawSeason, rawYear string, current, latest animeSeason) animeSeason { + season := strings.ToLower(strings.TrimSpace(rawSeason)) + validSeason := false + for _, candidate := range seasons { + if season == candidate { + validSeason = true + break + } + } + year, err := strconv.Atoi(rawYear) + if !validSeason || err != nil || year < 2000 || year > latest.Year || year == latest.Year && seasonIndex(season) > seasonIndex(latest.Season) { + return current + } + return animeSeason{Season: season, Year: year} +} + +func (s *SeasonDiscoveryService) LatestAvailableSeason(ctx context.Context, current animeSeason) animeSeason { + next := adjacentSeason(current.Season, current.Year, 1) + shows, err := s.provider.SeasonalShows(ctx, next.Season, next.Year) + if err == nil && len(shows) > 0 { + return next + } + return current +} + +type SimulcastData struct { + Animes []domain.Anime + Season string + Year int +} + +func (s *SeasonDiscoveryService) GetSimulcast(ctx context.Context, selected animeSeason) (SimulcastData, error) { + shows, err := s.provider.SeasonalShows(ctx, selected.Season, selected.Year) + if err != nil { + return SimulcastData{}, err + } + animes := make([]domain.Anime, 0, len(shows)) + for _, show := range shows { + anime := jikan.Anime{ + MalID: show.MalID, + Title: show.Name, + TitleEnglish: show.EnglishName, + Status: show.Status, + Type: show.Type, + Year: show.Year, + Episodes: show.EpisodeCount, + } + anime.Images.Webp.LargeImageURL = show.Thumbnail + animes = append(animes, domain.Anime{Anime: anime}) + } + return SimulcastData{ + Animes: animes, + Season: selected.Season, + Year: selected.Year, + }, nil +} diff --git a/internal/anime/simulcast_test.go b/internal/anime/simulcast_test.go new file mode 100644 index 00000000..5e722162 --- /dev/null +++ b/internal/anime/simulcast_test.go @@ -0,0 +1,88 @@ +package anime + +import ( + "context" + "fmt" + "mal/integrations/playback/allanime" + "testing" + "time" +) + +type seasonalProviderStub struct { + shows map[string][]allanime.ProviderShow +} + +func (p seasonalProviderStub) SeasonalShows(_ context.Context, season string, year int) ([]allanime.ProviderShow, error) { + return p.shows[fmt.Sprintf("%s-%d", season, year)], nil +} + +func TestAdjacentSeasonCrossesYearBoundary(t *testing.T) { + previous := adjacentSeason("winter", 2026, -1) + if previous.Season != "fall" || previous.Year != 2025 { + t.Fatalf("previous = %+v", previous) + } + + next := adjacentSeason("fall", 2026, 1) + if next.Season != "winter" || next.Year != 2027 { + t.Fatalf("next = %+v", next) + } +} + +func TestSeasonSelectionRejectsInvalidValues(t *testing.T) { + now := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC) + current := calendarSeason(now.Year(), int(now.Month())) + got := seasonSelection("monsoon", "nope", current, current) + if got.Season != "summer" || got.Year != 2026 { + t.Fatalf("seasonSelection = %+v", got) + } +} + +func TestSeasonSelectionDefaultsToCalendarSeasonWhenNewerSeasonIsAvailable(t *testing.T) { + current := animeSeason{Season: "spring", Year: 2026} + latest := animeSeason{Season: "summer", Year: 2026} + got := seasonSelection("", "", current, latest) + if got != current { + t.Fatalf("seasonSelection = %+v, want %+v", got, current) + } +} + +func TestLatestAvailableSeasonIncludesPlayableNextSeason(t *testing.T) { + provider := seasonalProviderStub{shows: map[string][]allanime.ProviderShow{ + "summer-2026": {{MalID: 1}}, + }} + svc := newSeasonDiscoveryService(provider) + got := svc.LatestAvailableSeason(context.Background(), animeSeason{Season: "spring", Year: 2026}) + if got.Season != "summer" || got.Year != 2026 { + t.Fatalf("LatestAvailableSeason = %+v", got) + } +} + +func TestSeasonOptionsRunNewestFirstWithoutFutureSeasons(t *testing.T) { + got := seasonOptions(2025, animeSeason{Season: "summer", Year: 2026}) + want := []string{"Summer 2026", "Spring 2026", "Winter 2026", "Fall 2025", "Summer 2025", "Spring 2025", "Winter 2025"} + if len(got) != len(want) { + t.Fatalf("len(seasonOptions) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i].Label != want[i] { + t.Fatalf("seasonOptions[%d] = %q, want %q", i, got[i].Label, want[i]) + } + } +} + +func TestSeasonNavigationStopsAtSupportedRange(t *testing.T) { + current := animeSeason{Season: "summer", Year: 2026} + if previous, _ := seasonNavigation(animeSeason{Season: "winter", Year: 2018}, 2018, current); previous != nil { + t.Fatalf("previous = %+v, want nil", previous) + } + if _, next := seasonNavigation(current, 2018, current); next != nil { + t.Fatalf("next = %+v, want nil", next) + } +} + +func TestParseSeasonDefaultsToCalendarSeason(t *testing.T) { + got := calendarSeason(2026, 7) + if got.Season != "summer" || got.Year != 2026 { + t.Fatalf("calendarSeason = %+v", got) + } +}