parent
ec6c1cc0e0
commit
cdd7b67003
@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const anilistQuery = `
|
||||
query (
|
||||
$page: Int
|
||||
$type: MediaType
|
||||
$isAdult: Boolean
|
||||
$search: String
|
||||
$format: [MediaFormat]
|
||||
$status: MediaStatus
|
||||
$countryOfOrigin: CountryCode
|
||||
$season: MediaSeason
|
||||
$seasonYear: Int
|
||||
$year: String
|
||||
$onList: Boolean
|
||||
$yearLesser: FuzzyDateInt
|
||||
$yearGreater: FuzzyDateInt
|
||||
$averageScoreGreater: Int
|
||||
$averageScoreLesser: Int
|
||||
$genres: [String]
|
||||
$excludedGenres: [String]
|
||||
$tags: [String]
|
||||
$excludedTags: [String]
|
||||
$minimumTagRank: Int
|
||||
$sort: [MediaSort]
|
||||
) {
|
||||
Page(page: $page, perPage: 20) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
type: $type
|
||||
season: $season
|
||||
format_in: $format
|
||||
status: $status
|
||||
countryOfOrigin: $countryOfOrigin
|
||||
search: $search
|
||||
onList: $onList
|
||||
seasonYear: $seasonYear
|
||||
startDate_like: $year
|
||||
startDate_lesser: $yearLesser
|
||||
startDate_greater: $yearGreater
|
||||
averageScore_greater: $averageScoreGreater
|
||||
averageScore_lesser: $averageScoreLesser
|
||||
genre_in: $genres
|
||||
genre_not_in: $excludedGenres
|
||||
tag_in: $tags
|
||||
tag_not_in: $excludedTags
|
||||
minimumTagRank: $minimumTagRank
|
||||
sort: $sort
|
||||
isAdult: $isAdult
|
||||
) {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type AnilistPageInfo struct {
|
||||
HasNextPage bool `json:"hasNextPage"`
|
||||
}
|
||||
type AnilistMediaItem struct {
|
||||
Id int `json:"id"`
|
||||
Title AnilistTitle `json:"title"`
|
||||
}
|
||||
type AnilistTitle struct {
|
||||
Romaji string `json:"romaji"`
|
||||
English string `json:"english"`
|
||||
}
|
||||
type AnilistResponsePage struct {
|
||||
PageInfo AnilistPageInfo `json:"pageInfo"`
|
||||
Media []AnilistMediaItem `json:"media"`
|
||||
}
|
||||
type AnilistResponseData struct {
|
||||
Page AnilistResponsePage `json:"Page"`
|
||||
}
|
||||
type AnilistApiResponse struct {
|
||||
Data AnilistResponseData `json:"data"`
|
||||
}
|
||||
|
||||
func handleAnilistAnimeSearch(idMap *ConcurrentMap, permaSkipIds []string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
search, err := getAnilistAnimeSearch(idMap, permaSkipIds, r)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(err.Error()))
|
||||
} else {
|
||||
w.Write(search)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getAnilistAnimeSearch(idMap *ConcurrentMap, permaSkipAnilistIds []string, r *http.Request) ([]byte, error) {
|
||||
q := r.URL.Query()
|
||||
|
||||
// set default params
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil {
|
||||
return nil, errors.New(" Required parameter \"limit\" not specified")
|
||||
}
|
||||
q.Set("type", "ANIME")
|
||||
|
||||
// dont include limit in the Anilist api call as its already hard coded at 20 per page
|
||||
q.Del("limit")
|
||||
|
||||
skipDedup := parseBoolParam(q, "allowDuplicates")
|
||||
|
||||
hasNextPage := true
|
||||
page := 0
|
||||
resp := []ResponseItem{}
|
||||
count := 0
|
||||
usedIds := make(map[int]bool, 0)
|
||||
for hasNextPage {
|
||||
page++
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
result, err := makeAnilistApiCall(q)
|
||||
if err != nil {
|
||||
log.Println("Error sending request to Anilist: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// map the data
|
||||
for _, item := range result.Data.Page.Media {
|
||||
if idMap.GetByAnilistId(item.Id) == 0 {
|
||||
log.Printf("Anilist ID %d (%s) has no associated TVDB ID, skipping...\n", item.Id, FullAnimeTitle(item.Title.Romaji, item.Title.English))
|
||||
continue
|
||||
}
|
||||
if usedIds[item.Id] && !skipDedup {
|
||||
log.Printf("Anilist ID %d (%s) is a duplicate, skipping...\n", item.Id, FullAnimeTitle(item.Title.Romaji, item.Title.English))
|
||||
continue
|
||||
}
|
||||
if slices.Contains(permaSkipAnilistIds, strconv.Itoa(item.Id)) {
|
||||
log.Printf("Anilist ID %d (%s) is set to always skip, skipping...\n", item.Id, FullAnimeTitle(item.Title.Romaji, item.Title.English))
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > limit {
|
||||
break
|
||||
}
|
||||
resp = append(resp,
|
||||
ResponseItem{
|
||||
item.Title.Romaji,
|
||||
item.Title.English,
|
||||
0,
|
||||
item.Id,
|
||||
idMap.GetByAnilistId(item.Id),
|
||||
})
|
||||
usedIds[item.Id] = true
|
||||
}
|
||||
hasNextPage = result.Data.Page.PageInfo.HasNextPage
|
||||
if count > limit {
|
||||
break
|
||||
}
|
||||
if hasNextPage {
|
||||
time.Sleep(500 * time.Millisecond) // sleep between requests for new page to try and avoid rate limits
|
||||
}
|
||||
}
|
||||
|
||||
respJson, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
log.Println("Error marshalling response: ", err)
|
||||
return nil, err
|
||||
}
|
||||
return respJson, nil
|
||||
}
|
||||
|
||||
func makeAnilistApiCall(q url.Values) (*AnilistApiResponse, error) {
|
||||
// Build the GraphQL request body
|
||||
variables := BuildGraphQLVariables(q)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": anilistQuery,
|
||||
"variables": variables,
|
||||
}
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make the POST request
|
||||
resp, err := http.Post("https://graphql.anilist.co", "application/json", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respData := new(AnilistApiResponse)
|
||||
err = json.NewDecoder(resp.Body).Decode(respData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respData, nil
|
||||
}
|
||||
|
||||
// BuildGraphQLVariables converts URL query parameters into a GraphQL variables map.
|
||||
func BuildGraphQLVariables(params url.Values) map[string]interface{} {
|
||||
vars := make(map[string]interface{})
|
||||
|
||||
// Helper to convert comma-separated strings into slices
|
||||
parseList := func(key string) []string {
|
||||
if val := params.Get(key); val != "" {
|
||||
return strings.Split(val, ",")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to convert integer parameters
|
||||
parseInt := func(key string) *int {
|
||||
if val := params.Get(key); val != "" {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return &i
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper to convert boolean parameters
|
||||
parseBool := func(key string) *bool {
|
||||
if val := params.Get(key); val != "" {
|
||||
if b, err := strconv.ParseBool(val); err == nil {
|
||||
return &b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic int and bool params
|
||||
if v := parseInt("page"); v != nil {
|
||||
vars["page"] = *v
|
||||
}
|
||||
if v := parseInt("seasonYear"); v != nil {
|
||||
vars["seasonYear"] = *v
|
||||
}
|
||||
if v := parseInt("yearLesser"); v != nil {
|
||||
vars["yearLesser"] = *v
|
||||
}
|
||||
if v := parseInt("yearGreater"); v != nil {
|
||||
vars["yearGreater"] = *v
|
||||
}
|
||||
if v := parseInt("averageScoreGreater"); v != nil {
|
||||
vars["averageScoreGreater"] = *v
|
||||
}
|
||||
if v := parseInt("averageScoreLesser"); v != nil {
|
||||
vars["averageScoreLesser"] = *v
|
||||
}
|
||||
if v := parseInt("minimumTagRank"); v != nil {
|
||||
vars["minimumTagRank"] = *v
|
||||
}
|
||||
if v := parseBool("onList"); v != nil {
|
||||
vars["onList"] = *v
|
||||
}
|
||||
if v := parseBool("isAdult"); v != nil {
|
||||
vars["isAdult"] = *v
|
||||
}
|
||||
|
||||
// Simple string params
|
||||
for _, key := range []string{"type", "search", "status", "countryOfOrigin", "season", "year"} {
|
||||
if val := params.Get(key); val != "" {
|
||||
vars[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
// List-type string params
|
||||
for _, key := range []string{"format", "genres", "excludedGenres", "tags", "excludedTags", "sort"} {
|
||||
if list := parseList(key); list != nil {
|
||||
vars[key] = list
|
||||
}
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// parses the boolean param "name" from url.Values "values"
|
||||
func parseBoolParam(values url.Values, name string) bool {
|
||||
param := values.Get(name)
|
||||
|
||||
if param != "" {
|
||||
val, err := strconv.ParseBool(param)
|
||||
if err == nil {
|
||||
return val
|
||||
}
|
||||
} else if _, exists := values[name]; exists {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// just the title, or "title a.k.a. english title" if both exist
|
||||
func FullAnimeTitle(title, engtitle string) string {
|
||||
if engtitle != "" {
|
||||
return title + " a.k.a. " + engtitle
|
||||
} else {
|
||||
return title
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/darenliang/jikan-go"
|
||||
)
|
||||
|
||||
func handleMalAnimeSearch(idMap *ConcurrentMap, permaSkipMalIds []string) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
search, err := getJikanAnimeSearch(idMap, permaSkipMalIds, r)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
w.Write([]byte(err.Error()))
|
||||
} else {
|
||||
w.Write([]byte(search))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func getJikanAnimeSearch(idMap *ConcurrentMap, permaSkipMalIds []string, r *http.Request) (string, error) {
|
||||
q := r.URL.Query()
|
||||
|
||||
limit, err := strconv.Atoi(q.Get("limit"))
|
||||
if err != nil {
|
||||
return "", errors.New(" Required parameter \"limit\" not specified")
|
||||
}
|
||||
|
||||
skipDedup := parseBoolParam(q, "allow_duplicates")
|
||||
|
||||
// for some reason Jikan responds with 400 Bad Request for any limit >25
|
||||
// so instead, we just limit when mapping the data and remove the limit from the Jikan request
|
||||
q.Del("limit")
|
||||
|
||||
hasNextPage := true
|
||||
page := 0
|
||||
resp := []ResponseItem{}
|
||||
count := 0
|
||||
usedIds := make(map[int]bool, 0)
|
||||
for hasNextPage {
|
||||
page++
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
result, err := jikan.GetAnimeSearch(q)
|
||||
if err != nil {
|
||||
log.Println("Error sending request to Jikan: ", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// map the data
|
||||
for _, item := range result.Data {
|
||||
if idMap.GetByMalId(item.MalId) == 0 {
|
||||
log.Printf("MyAnimeList ID %d (%s) has no associated TVDB ID, skipping...\n", item.MalId, FullAnimeTitle(item.Title, item.TitleEnglish))
|
||||
continue
|
||||
}
|
||||
if usedIds[item.MalId] && !skipDedup {
|
||||
log.Printf("MyAnimeList ID %d (%s) is a duplicate, skipping...\n", item.MalId, FullAnimeTitle(item.Title, item.TitleEnglish))
|
||||
continue
|
||||
}
|
||||
if slices.Contains(permaSkipMalIds, strconv.Itoa(item.MalId)) {
|
||||
log.Printf("MyAnimeList ID %d (%s) is set to always skip, skipping...\n", item.MalId, FullAnimeTitle(item.Title, item.TitleEnglish))
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > limit {
|
||||
break
|
||||
}
|
||||
resp = append(resp,
|
||||
ResponseItem{
|
||||
item.Title,
|
||||
item.TitleEnglish,
|
||||
item.MalId,
|
||||
0,
|
||||
idMap.GetByMalId(item.MalId),
|
||||
})
|
||||
usedIds[item.MalId] = true
|
||||
}
|
||||
hasNextPage = result.Pagination.HasNextPage
|
||||
if count > limit {
|
||||
break
|
||||
}
|
||||
if hasNextPage {
|
||||
time.Sleep(500 * time.Millisecond) // sleep between requests for new page to try and avoid rate limits
|
||||
}
|
||||
}
|
||||
|
||||
respJson, err := json.MarshalIndent(resp, "", " ")
|
||||
if err != nil {
|
||||
log.Println("Error marshalling response: ", err)
|
||||
return "", err
|
||||
}
|
||||
return string(respJson), nil
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newRebuildStaleIdMapMiddleware(idMap *ConcurrentMap) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if time.Since(lastBuiltAnimeIdList) > 24*time.Hour {
|
||||
log.Println("Anime ID association table expired, building new table...")
|
||||
buildIdMap(idMap)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loggerMiddleware(next http.Handler) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("%s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in new issue