Add an AniList provider for the hentai kind
AniList is free, needs no key, and carries an isAdult filter, which is what makes it the right source for this: the same catalogue holds mainstream anime and the filter is how a query says which side it wants. It fills the gap left by the adult kinds having no provider at all. The API facts here were tested against the live service rather than read off the docs, and one of them would have been wrong otherwise: the year filter is startDate_like and it needs a wildcard. "2001" returns zero results; "2001%" returns the right one. A bare year looks correct and silently finds nothing. Descriptions come back with HTML in them even with asHtml:false, so they are tag-stripped and entity-decoded before being stored. That reuses the existing stripHTML from the ComicVine provider rather than adding a second one. Verified against the live API, not only against a mock: a real search for "Bible Black" returns id anime/368, year 2001, the correct cover URL, and a description with no tags in it. Kinds() is ["hentai"] only. AniList would serve mainstream anime too, but that is a library kind nobody has asked for yet, and claiming tv here would mean two providers racing for the same items.
This commit is contained in:
parent
1fcc7344cb
commit
fc28ec8efd
2 changed files with 286 additions and 0 deletions
143
internal/providers/anilist.go
Normal file
143
internal/providers/anilist.go
Normal file
|
|
@ -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 <br> and <i>, and entity escapes.
|
||||||
|
Description: html.UnescapeString(stripHTML(m.Description)),
|
||||||
|
CoverURL: m.CoverImage.Large,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
143
internal/providers/anilist_test.go
Normal file
143
internal/providers/anilist_test.go
Normal file
|
|
@ -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 <i>bold</i> start<br>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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue