Alternative names (#32): - PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's alternative names (trimmed, deduplicated, without the soundtrack's own name). - alternative_names is in the soundtrack and statistics responses. - /find matches them too and shows which name matched. Covers (#12), stored in COVERS_PATH and off when it is not set: - GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID. Uploaded and picked covers are never replaced automatically. - With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then AniList, by name, alternative names and the name without bracketed parts, at startup, after a sync and after names change. Search results are ranked by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV". - cover_modified (Unix seconds) is in the soundtrack and statistics responses, so clients can cache covers. The new columns are added to migration 000006, which is not released yet. Closes #12, closes #32 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
8.2 KiB
Go
290 lines
8.2 KiB
Go
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()
|
|
}
|