Add alternative names and cover art for soundtracks
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>
This commit is contained in:
1 parent
f79f51ea4c
commit
bb8de5548d
39 files changed
+2418
-99
No files matched your search
@@ -0,0 +1,289 @@
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user