feat: add season navigation helpers
This commit is contained in:
148
internal/anime/simulcast.go
Normal file
148
internal/anime/simulcast.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
88
internal/anime/simulcast_test.go
Normal file
88
internal/anime/simulcast_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user