Files
MusicServer/internal/backend/cover_sources.go
T
SansanandClaude Opus 5.5 bb8de5548d
CI / docker (push) Failing after 22s
CI / integration (push) Successful in 26s
CI / test (push) Successful in 26s
CI / publish (push) Skipped
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>
2026-10-13 21:57:18 +02:00

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()
}