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 }