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")
+ }
+}