diff --git a/integrations/playback/allanime/sequels.go b/integrations/playback/allanime/sequels.go new file mode 100644 index 00000000..304e649d --- /dev/null +++ b/integrations/playback/allanime/sequels.go @@ -0,0 +1,133 @@ +package allanime + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" +) + +type ProviderShow struct { + ID string + Name string + EnglishName string + MalID int + Status string + Thumbnail string + Type string + Year int + EpisodeCount int + SubEpisodes []int + DubEpisodes []int +} + +func (c *AllAnimeProvider) DirectSequels(ctx context.Context, show ProviderShow) ([]string, error) { + query := `query($showId: String!) { show(_id: $showId) { relatedShows } }` + result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": show.ID}) + if err != nil { + return nil, err + } + data, _ := result["data"].(map[string]any) + rawShow, _ := data["show"].(map[string]any) + relations, _ := rawShow["relatedShows"].([]any) + ids := make([]string, 0, len(relations)) + for _, raw := range relations { + relation, _ := raw.(map[string]any) + id, _ := relation["showId"].(string) + if relation["relation"] == "sequel" && id != "" { + ids = append(ids, id) + } + } + return ids, nil +} + +func (c *AllAnimeProvider) GetProviderShow(ctx context.Context, showID string) (ProviderShow, error) { + query := `query($showId: String!) { show(_id: $showId) { _id name englishName malId status thumbnail type season episodeCount availableEpisodesDetail } }` + result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": showID}) + if err != nil { + return ProviderShow{}, err + } + data, _ := result["data"].(map[string]any) + raw, _ := data["show"].(map[string]any) + if raw == nil { + return ProviderShow{}, fmt.Errorf("allanime: show %s not found", showID) + } + return providerShowFrom(raw), nil +} + +func (c *AllAnimeProvider) SeasonalShows(ctx context.Context, season string, year int) ([]ProviderShow, error) { + const pageSize = 40 + query := `query($search: SearchInput, $page: Int) { shows(search: $search, limit: 40, page: $page, countryOrigin: ALL) { edges { _id name englishName malId status thumbnail type season episodeCount availableEpisodesDetail } } }` + if season == "" { + return nil, errors.New("allanime: season is required") + } + search := map[string]any{ + "allowAdult": false, + "allowUnknown": false, + "season": strings.ToUpper(season[:1]) + strings.ToLower(season[1:]), + "year": year, + "types": []string{"TV"}, + "includeTypes": true, + } + out := make([]ProviderShow, 0) + seen := make(map[int]bool) + for page := 1; page <= 20; page++ { + result, err := c.graphqlRequest(ctx, query, map[string]any{"search": search, "page": page}) + if err != nil { + return nil, err + } + data, _ := result["data"].(map[string]any) + shows, _ := data["shows"].(map[string]any) + edges, _ := shows["edges"].([]any) + for _, edge := range edges { + raw, _ := edge.(map[string]any) + show := providerShowFrom(raw) + if show.MalID <= 0 || seen[show.MalID] || show.Type != "TV" || max(len(show.SubEpisodes), len(show.DubEpisodes)) == 0 { + continue + } + seen[show.MalID] = true + out = append(out, show) + } + if len(edges) < pageSize { + break + } + } + return out, nil +} + +func providerShowFrom(raw map[string]any) ProviderShow { + detail, _ := raw["availableEpisodesDetail"].(map[string]any) + season, _ := raw["season"].(map[string]any) + return ProviderShow{ + ID: stringValue(raw["_id"]), + Name: stringValue(raw["name"]), + EnglishName: stringValue(raw["englishName"]), + MalID: intValue(raw["malId"]), + Status: stringValue(raw["status"]), + Thumbnail: stringValue(raw["thumbnail"]), + Type: stringValue(raw["type"]), + Year: numericInt(season["year"]), + EpisodeCount: intValue(raw["episodeCount"]), + SubEpisodes: episodeNums(stringsFrom(detail["sub"])), + DubEpisodes: episodeNums(stringsFrom(detail["dub"])), + } +} + +func stringValue(value any) string { + valueString, _ := value.(string) + return valueString +} + +func intValue(value any) int { + n, _ := strconv.Atoi(stringValue(value)) + return n +} + +func numericInt(value any) int { + if n := intValue(value); n > 0 { + return n + } + n, _ := value.(float64) + return int(n) +} diff --git a/integrations/playback/allanime/sequels_test.go b/integrations/playback/allanime/sequels_test.go new file mode 100644 index 00000000..56468580 --- /dev/null +++ b/integrations/playback/allanime/sequels_test.go @@ -0,0 +1,90 @@ +package allanime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDirectSequelsReturnsOnlyPlayableSequels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"show":{"relatedShows":[ + {"relation":"sequel","showId":"season-2"}, + {"relation":"side story","showId":"ova"} + ]}}}`)) + })) + defer server.Close() + + provider := NewAllAnimeProvider() + provider.httpClient = server.Client() + provider.baseURL = server.URL + providerShow := ProviderShow{ID: "season-1"} + + got, err := provider.DirectSequels(context.Background(), providerShow) + if err != nil { + t.Fatalf("DirectSequels: %v", err) + } + if len(got) != 1 || got[0] != "season-2" { + t.Fatalf("DirectSequels = %v, want [season-2]", got) + } +} + +func TestGetProviderShowKeepsOnlyPositiveIntegerEpisodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"show":{"_id":"season-2","name":"Example Season 2","englishName":"Example Season 2","malId":"42","status":"Finished","episodeCount":"2","availableEpisodesDetail":{"sub":["2","1","0","1.5"],"dub":["1"],"raw":[]}}}}`)) + })) + defer server.Close() + + provider := NewAllAnimeProvider() + provider.httpClient = server.Client() + provider.baseURL = server.URL + + got, err := provider.GetProviderShow(context.Background(), "season-2") + if err != nil { + t.Fatalf("GetProviderShow: %v", err) + } + if got.MalID != 42 || len(got.SubEpisodes) != 2 || len(got.DubEpisodes) != 1 { + t.Fatalf("GetProviderShow = %+v", got) + } +} + +func TestSeasonalShowsReturnsPlayableTVAnime(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + var request struct { + Variables struct { + Page int `json:"page"` + } `json:"variables"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + edges := make([]map[string]any, 0) + switch request.Variables.Page { + case 1: + for i := 0; i < 40; i++ { + edges = append(edges, map[string]any{"_id": "empty", "malId": "12", "type": "TV", "availableEpisodesDetail": map[string]any{"sub": []string{}, "dub": []string{}}}) + } + case 2: + edges = append(edges, map[string]any{"_id": "tv", "name": "Summer Show", "malId": "10", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": 2026}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"shows": map[string]any{"edges": edges}}}) + })) + defer server.Close() + + provider := NewAllAnimeProvider() + provider.httpClient = server.Client() + provider.baseURL = server.URL + + got, err := provider.SeasonalShows(context.Background(), "summer", 2026) + if err != nil { + t.Fatalf("SeasonalShows: %v", err) + } + if len(got) != 1 || got[0].MalID != 10 { + t.Fatalf("SeasonalShows = %+v", got) + } +}