package backend import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "slices" "strconv" "strings" "unicode" ) // coverMatch is a cover found by a source. type coverMatch struct { ExternalID string ImageURL string } // coverSource finds cover images in an external database. type coverSource interface { // Name is stored as the cover's source, e.g. "steamgriddb". Name() string // Search returns the cover for the first of names that the source knows, or nil when // none matched. An error means the source could not be asked. Search(ctx context.Context, names []string) (*coverMatch, error) // ByID returns the cover of the entry with the given ID, or ErrNotFound. ByID(ctx context.Context, id string) (*coverMatch, error) } // getJSON sends req and decodes a JSON response into v. A 404 gives ErrNotFound, other // failures ErrUpstream. func getJSON(client *http.Client, req *http.Request, v any) error { resp, err := client.Do(req) if err != nil { return fmt.Errorf("%w: %v", ErrUpstream, err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return ErrNotFound } if resp.StatusCode != http.StatusOK { return fmt.Errorf("%w: %s answered %s", ErrUpstream, req.URL.Host, resp.Status) } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(v); err != nil { return fmt.Errorf("%w: invalid response from %s: %v", ErrUpstream, req.URL.Host, err) } return nil } // steamGridDB finds game box art ("grids") on SteamGridDB. It needs an API key. type steamGridDB struct { baseURL string key string client *http.Client } const steamGridDBURL = "https://www.steamgriddb.com/api/v2" func (s *steamGridDB) Name() string { return "steamgriddb" } func (s *steamGridDB) get(ctx context.Context, path string, query url.Values, v any) error { u := s.baseURL + path if len(query) > 0 { u += "?" + query.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+s.key) var wrapper struct { Success bool `json:"success"` Data json.RawMessage `json:"data"` } if err := getJSON(s.client, req, &wrapper); err != nil { return err } if !wrapper.Success { return fmt.Errorf("%w: SteamGridDB request failed", ErrUpstream) } return json.Unmarshal(wrapper.Data, v) } type steamGridDBGame struct { ID int `json:"id"` Name string `json:"name"` } func (s *steamGridDB) Search(ctx context.Context, names []string) (*coverMatch, error) { for _, name := range names { var games []steamGridDBGame if err := s.get(ctx, "/search/autocomplete/"+url.PathEscape(name), nil, &games); err != nil { return nil, err } if len(games) == 0 { continue } match, err := s.ByID(ctx, strconv.Itoa(bestGame(name, games).ID)) if errors.Is(err, ErrNotFound) { continue // the game has no box art } return match, err } return nil, nil } func (s *steamGridDB) ByID(ctx context.Context, id string) (*coverMatch, error) { if _, err := strconv.Atoi(id); err != nil { return nil, ErrNotFound } query := url.Values{ "dimensions": {"600x900"}, // portrait box art "types": {"static"}, "mimes": {"image/png,image/jpeg"}, "nsfw": {"false"}, "humor": {"false"}, } var grids []struct { URL string `json:"url"` } if err := s.get(ctx, "/grids/game/"+id, query, &grids); err != nil { return nil, err } if len(grids) == 0 { return nil, ErrNotFound } return &coverMatch{ExternalID: id, ImageURL: grids[0].URL}, nil } // bestGame picks the search result for name: // 1. a result with exactly the words of name, in any order ("Lemmings 3D" → "3D Lemmings"); // 2. else a result that ends with the words of name, as words added in front are usually a // publisher or series ("Civilization IV" → "Sid Meier's Civilization IV"); // 3. else SteamGridDB's own first result. Words added after the name can be a subtitle, a // sequel or an expansion ("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), which only // SteamGridDB's ranking can tell apart. func bestGame(name string, games []steamGridDBGame) steamGridDBGame { want := titleWords(name) for _, game := range games { if sameWords(titleWords(game.Name), want) { return game } } for _, game := range games { if have := titleWords(game.Name); len(have) > len(want) && slices.Equal(have[len(have)-len(want):], want) { return game } } return games[0] } // sameWords reports whether a and b have the same words, in any order. func sameWords(a, b []string) bool { return len(a) == len(b) && slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b))) } // romanNumerals are read as numbers, so "Octopath Traveler 2" matches "Octopath Traveler II". var romanNumerals = map[string]string{ "i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10", } // titleWords returns the words of a title in lower case, without punctuation. func titleWords(title string) []string { words := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) for i, word := range words { if number, ok := romanNumerals[word]; ok { words[i] = number } } return words } // aniList finds anime covers on AniList. It needs no key. Its search is loose, so a // result is only used when one of its titles is the searched name. type aniList struct { url string client *http.Client } const aniListURL = "https://graphql.anilist.co" const aniListFields = `id title { romaji english native } coverImage { extraLarge large }` type aniListMedia struct { ID int `json:"id"` Title struct { Romaji string `json:"romaji"` English string `json:"english"` Native string `json:"native"` } `json:"title"` CoverImage struct { ExtraLarge string `json:"extraLarge"` Large string `json:"large"` } `json:"coverImage"` } func (m aniListMedia) match() *coverMatch { image := m.CoverImage.ExtraLarge if image == "" { image = m.CoverImage.Large } if image == "" { return nil } return &coverMatch{ExternalID: strconv.Itoa(m.ID), ImageURL: image} } func (a *aniList) Name() string { return "anilist" } func (a *aniList) query(ctx context.Context, query string, variables map[string]any) (*aniListMedia, error) { body, err := json.Marshal(map[string]any{"query": query, "variables": variables}) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") var resp struct { Data struct { Media *aniListMedia `json:"Media"` } `json:"data"` } if err := getJSON(a.client, req, &resp); err != nil { return nil, err } if resp.Data.Media == nil { return nil, ErrNotFound } return resp.Data.Media, nil } func (a *aniList) Search(ctx context.Context, names []string) (*coverMatch, error) { for _, name := range names { media, err := a.query(ctx, `query ($search: String) { Media(search: $search, type: ANIME) { `+aniListFields+` } }`, map[string]any{"search": name}) if errors.Is(err, ErrNotFound) { continue } if err != nil { return nil, err } want := normalizeTitle(name) for _, title := range []string{media.Title.Romaji, media.Title.English, media.Title.Native} { if title != "" && normalizeTitle(title) == want { if match := media.match(); match != nil { return match, nil } } } } return nil, nil } func (a *aniList) ByID(ctx context.Context, id string) (*coverMatch, error) { n, err := strconv.Atoi(id) if err != nil { return nil, ErrNotFound } media, err := a.query(ctx, `query ($id: Int) { Media(id: $id) { `+aniListFields+` } }`, map[string]any{"id": n}) if err != nil { return nil, err } if match := media.match(); match != nil { return match, nil } return nil, ErrNotFound } // normalizeTitle keeps only the letters and digits of a title, in lower case, so // "Cowboy Bebop: The Movie" and "cowboy bebop the movie" compare equal. func normalizeTitle(title string) string { var b strings.Builder for _, r := range strings.ToLower(title) { if unicode.IsLetter(r) || unicode.IsDigit(r) { b.WriteRune(r) } } return b.String() }