diff --git a/internal/providers/anilist.go b/internal/providers/anilist.go new file mode 100644 index 0000000..7228164 --- /dev/null +++ b/internal/providers/anilist.go @@ -0,0 +1,143 @@ +package providers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "html" + "net/http" + "strconv" + "strings" + "time" +) + +// AniList matches adult anime against AniList, a free GraphQL catalogue that +// needs no key. Its isAdult filter is what makes it the right source for the +// hentai kind: the flag is applied server-side, so the caller never sees +// anything else from this provider. +type AniList struct { + BaseURL string + Client *http.Client +} + +// NewAniList builds the provider. +func NewAniList() *AniList { + return &AniList{ + BaseURL: "https://graphql.anilist.co", + Client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (p *AniList) Name() string { return "anilist" } + +func (p *AniList) Kinds() []string { return []string{"hentai"} } + +// anilistQuery is the GraphQL document. perPage is fixed at 5: a provider is a +// shortlist, not a browse. +const anilistQuery = `query ($search: String, $isAdult: Boolean, $year: String) { + Page(page: 1, perPage: 5) { + media(search: $search, type: ANIME, isAdult: $isAdult, startDate_like: $year) { + id + title { romaji english native } + description(asHtml: false) + coverImage { large } + startDate { year } + } + } +}` + +// anilistResponse is the subset of the GraphQL envelope we read. A failed +// request comes back as errors instead of data, so both are decoded. +type anilistResponse struct { + Data struct { + Page struct { + Media []struct { + ID int `json:"id"` + Title struct { + Romaji string `json:"romaji"` + English string `json:"english"` + Native string `json:"native"` + } `json:"title"` + Description string `json:"description"` + CoverImage struct { + Large string `json:"large"` + } `json:"coverImage"` + StartDate struct { + Year int `json:"year"` + } `json:"startDate"` + } `json:"media"` + } `json:"page"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +func (p *AniList) Search(ctx context.Context, q Query) ([]Candidate, error) { + title := strings.TrimSpace(q.Title) + if title == "" { + return nil, nil + } + + // startDate_like is a wildcard match, so a bare year matches nothing: the + // year is sent as "YYYY%" and only when the caller has one. + vars := map[string]any{"search": title, "isAdult": true} + if q.Year > 0 { + vars["year"] = strconv.Itoa(q.Year) + "%" + } + payload, err := json.Marshal(map[string]any{"query": anilistQuery, "variables": vars}) + if err != nil { + return nil, fmt.Errorf("anilist: encode: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.BaseURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("anilist: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("anilist: status %d", resp.StatusCode) + } + + var parsed anilistResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("anilist: decode: %w", err) + } + if len(parsed.Errors) > 0 { + return nil, fmt.Errorf("anilist: %s", parsed.Errors[0].Message) + } + + out := make([]Candidate, 0, len(parsed.Data.Page.Media)) + for _, m := range parsed.Data.Page.Media { + // English is often empty on AniList; romaji is the reliable title and + // native the last resort. + name := m.Title.English + if name == "" { + name = m.Title.Romaji + } + if name == "" { + name = m.Title.Native + } + if name == "" { + continue + } + out = append(out, Candidate{ + Provider: p.Name(), + // Namespaced so AniList ids cannot collide with another + // provider's in provider_ids. + ExternalID: "anime/" + strconv.Itoa(m.ID), + Title: name, + Year: m.StartDate.Year, + // asHtml:false still leaves
and , and entity escapes. + Description: html.UnescapeString(stripHTML(m.Description)), + CoverURL: m.CoverImage.Large, + }) + } + return out, nil +} diff --git a/internal/providers/anilist_test.go b/internal/providers/anilist_test.go new file mode 100644 index 0000000..845f981 --- /dev/null +++ b/internal/providers/anilist_test.go @@ -0,0 +1,143 @@ +package providers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestAniListMapsMatch(t *testing.T) { + var gotMethod string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"Page":{"media":[ + {"id": 12345, + "title": {"romaji": "Romaji Name", "english": "", "native": "Native Name"}, + "description": "A bold start
and a line & more.", + "coverImage": {"large": "https://img/anime.jpg"}, + "startDate": {"year": 2001}}, + {"id": 6789, + "title": {"romaji": "", "english": "", "native": "Native Only"}, + "description": "Native.", + "coverImage": {"large": "https://img/native.jpg"}, + "startDate": {"year": 2004}} + ]}}}`)) + })) + t.Cleanup(srv.Close) + + p := NewAniList() + p.BaseURL = srv.URL + + cands, err := p.Search(context.Background(), Query{Title: " something ", Year: 2001}) + if err != nil { + t.Fatalf("Search: %v", err) + } + want := []Candidate{ + { + Provider: "anilist", + ExternalID: "anime/12345", + Title: "Romaji Name", // english empty, falls back to romaji + Year: 2001, + Description: "A bold startand a line & more.", // tags stripped, entity decoded + CoverURL: "https://img/anime.jpg", + }, + { + Provider: "anilist", + ExternalID: "anime/6789", + Title: "Native Only", // both english and romaji empty + Year: 2004, + Description: "Native.", + CoverURL: "https://img/native.jpg", + }, + } + if !reflect.DeepEqual(cands, want) { + t.Errorf("candidates = %+v, want %+v", cands, want) + } + + if gotMethod != http.MethodPost { + t.Errorf("method = %s, want POST", gotMethod) + } + var body struct { + Query string `json:"query"` + Variables struct { + Search string `json:"search"` + IsAdult bool `json:"isAdult"` + Year string `json:"year"` + } `json:"variables"` + } + if err := json.Unmarshal(gotBody, &body); err != nil { + t.Fatalf("request body: %v", err) + } + if body.Variables.Search != "something" { + t.Errorf("search = %q, want trimmed title", body.Variables.Search) + } + if !body.Variables.IsAdult { + t.Errorf("isAdult = false, want true") + } + if body.Variables.Year != "2001%" { + t.Errorf("year = %q, want 2001%%", body.Variables.Year) + } +} + +func TestAniListEmptyResultIsNotError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"Page":{"media":[]}}}`)) + })) + t.Cleanup(srv.Close) + + p := NewAniList() + p.BaseURL = srv.URL + + cands, err := p.Search(context.Background(), Query{Title: "nothing"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(cands) != 0 { + t.Errorf("got %d candidates, want 0", len(cands)) + } +} + +func TestAniListBlankTitleSkipsRequest(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + })) + t.Cleanup(srv.Close) + + p := NewAniList() + p.BaseURL = srv.URL + + cands, err := p.Search(context.Background(), Query{Title: " "}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if cands != nil { + t.Errorf("got %v, want nil", cands) + } + if calls != 0 { + t.Errorf("server got %d requests, want 0", calls) + } +} + +func TestAniListErrorsArrayIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errors":[{"message":"Not Found."}]}`)) + })) + t.Cleanup(srv.Close) + + p := NewAniList() + p.BaseURL = srv.URL + + if _, err := p.Search(context.Background(), Query{Title: "x"}); err == nil { + t.Fatal("Search: got nil error, want error") + } +}