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
}