Files
MusicServer/internal/backend/covers_test.go
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

426 lines
16 KiB
Go

package backend
import (
"bytes"
"context"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pngBytes(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
return buf.Bytes()
}
// coverAPIs fakes SteamGridDB, AniList and the image host.
type coverAPIs struct {
t *testing.T
server *httptest.Server
// games maps search terms to SteamGridDB game IDs; 404 has no grids.
games map[string]int
// anime maps search terms to AniList titles; the ID is always 42.
anime map[string]string
// failSteamGridDB makes SteamGridDB answer 500.
failSteamGridDB bool
mu sync.Mutex
searches []string
}
func newCoverAPIs(t *testing.T) *coverAPIs {
a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}}
image := pngBytes(t)
mux := http.NewServeMux()
reply := func(w http.ResponseWriter, data any) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
}
mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) {
a.record("sgdb:" + r.PathValue("term"))
if a.failSteamGridDB {
http.Error(w, "down", http.StatusInternalServerError)
return
}
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
if id, ok := a.games[r.PathValue("term")]; ok {
reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}})
return
}
reply(w, []any{})
})
mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "600x900", r.URL.Query().Get("dimensions"))
if r.PathValue("id") == "404" {
reply(w, []any{})
return
}
reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}})
})
mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Variables struct {
Search string `json:"search"`
ID int `json:"id"`
} `json:"variables"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
a.record("anilist:" + req.Variables.Search)
title, ok := a.anime[req.Variables.Search]
if req.Variables.ID == 42 {
title, ok = "By ID", true
}
if !ok {
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{
"id": 42, "title": map[string]any{"romaji": title},
"coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"},
}}})
})
mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("name") == "text.png" {
_, _ = w.Write([]byte("not an image"))
return
}
_, _ = w.Write(image)
})
a.server = httptest.NewServer(mux)
t.Cleanup(a.server.Close)
return a
}
func (a *coverAPIs) record(search string) {
a.mu.Lock()
defer a.mu.Unlock()
a.searches = append(a.searches, search)
}
func (a *coverAPIs) searched() []string {
a.mu.Lock()
defer a.mu.Unlock()
return append([]string(nil), a.searches...)
}
func (a *coverAPIs) options(withKey bool) CoverOptions {
opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"}
if withKey {
opts.SteamGridDBKey = "key"
}
return opts
}
func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService {
t.Helper()
c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts)
require.NoError(t, err)
c.interval = 0
return c
}
func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack {
t.Helper()
st, ok := store.Soundtrack(name)
require.True(t, ok)
return st
}
func TestCoverSearchFindsCovers(t *testing.T) {
apis := newCoverAPIs(t)
apis.games["Chrono Trigger"] = 1
apis.games["FF7"] = 7
apis.games["Lemmings"] = 3
apis.anime["Cowboy Bebop"] = "Cowboy Bebop"
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.searchMissing(context.Background()))
chrono := coverOf(t, store, "Chrono Trigger")
require.NotNil(t, chrono.CoverFile)
assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile)
assert.Equal(t, "steamgriddb", *chrono.CoverSource)
assert.Equal(t, "1", *chrono.CoverExternalID)
assert.False(t, chrono.CoverManual)
assert.True(t, chrono.CoverModified.Valid)
assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile))
assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name")
assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets")
bebop := coverOf(t, store, "Cowboy Bebop")
assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing")
unknown := coverOf(t, store, "Unknown")
assert.Nil(t, unknown.CoverFile)
assert.True(t, unknown.CoverChecked.Valid, "marked as searched")
assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first")
_ = ff
// Searched soundtracks are not searched again until the recheck time.
before := len(apis.searched())
require.NoError(t, c.searchMissing(context.Background()))
assert.Len(t, apis.searched(), before)
}
func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) {
apis := newCoverAPIs(t)
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.searchMissing(context.Background()))
assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used")
}
func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) {
apis := newCoverAPIs(t)
apis.failSteamGridDB = true
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"})
c := newCovers(t, store, apis.options(true))
err := c.searchMissing(context.Background())
assert.ErrorIs(t, err, ErrUpstream)
assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried")
assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time")
}
func TestCoverSearchSkipsManualCovers(t *testing.T) {
apis := newCoverAPIs(t)
apis.games["Chrono Trigger"] = 1
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op
require.NoError(t, c.searchMissing(context.Background()))
assert.Empty(t, apis.searched())
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
// A cover set by hand while the job was searching is kept.
stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1")
require.NoError(t, err)
assert.False(t, stored)
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
}
func TestCoverSearchAgainAfterRecheckTime(t *testing.T) {
apis := newCoverAPIs(t)
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old",
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent",
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.searchMissing(context.Background()))
assert.Contains(t, apis.searched(), "sgdb:Old")
assert.NotContains(t, apis.searched(), "sgdb:Recent")
}
func TestCoverUpload(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, CoverOptions{})
ctx := context.Background()
jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...)
require.NoError(t, c.Upload(ctx, st.Uuid, jpeg))
got := coverOf(t, store, "Game")
assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile)
assert.True(t, got.CoverManual)
assert.Nil(t, got.CoverExternalID)
path, err := c.Path(ctx, st.Uuid)
require.NoError(t, err)
data, _ := os.ReadFile(path)
assert.Equal(t, jpeg, data)
// A PNG replaces the JPEG, and its old file is removed.
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile)
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg"))
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("<svg></svg>")), ErrInvalidImage)
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage)
assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound)
entries, _ := os.ReadDir(c.dir)
assert.Len(t, entries, 1, "no temporary files are left behind")
}
func TestCoverFromSource(t *testing.T) {
apis := newCoverAPIs(t)
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
ctx := context.Background()
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5"))
got := coverOf(t, store, "Game")
assert.Equal(t, "steamgriddb", *got.CoverSource)
assert.Equal(t, "5", *got.CoverExternalID)
assert.True(t, got.CoverManual, "a picked cover is never replaced by the job")
require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42"))
assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource)
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids")
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound)
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound)
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName)
assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound)
withoutKey := newCovers(t, store, apis.options(false))
assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName)
require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key")
}
func TestCoverDownloadMustBeAnImage(t *testing.T) {
apis := newCoverAPIs(t)
c := newCovers(t, fakedb.New(), apis.options(true))
_, err := c.download(context.Background(), apis.server.URL+"/img/text.png")
assert.ErrorIs(t, err, ErrUpstream)
}
func TestCoverRemove(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, CoverOptions{})
ctx := context.Background()
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
require.NoError(t, c.Remove(ctx, st.Uuid))
got := coverOf(t, store, "Game")
assert.Nil(t, got.CoverFile)
assert.False(t, got.CoverManual)
assert.False(t, got.CoverChecked.Valid, "the job searches for it again")
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png"))
_, err := c.Path(ctx, st.Uuid)
assert.ErrorIs(t, err, ErrNotFound)
assert.Len(t, c.trigger, 1, "the job is asked to search")
}
func TestCoverRemoveOrphans(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, CoverOptions{})
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset")
c.removeOrphans(context.Background())
entries, _ := os.ReadDir(c.dir)
require.Len(t, entries, 1)
assert.Equal(t, st.Uuid.String()+".png", entries[0].Name())
}
func TestCoverRunSearchesUntilStopped(t *testing.T) {
apis := newCoverAPIs(t)
apis.games["Game"] = 1
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, apis.options(true))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond,
"searches when started")
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack
c.Trigger()
assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 },
time.Second, 10*time.Millisecond, "searches again when triggered")
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not stop")
}
}
func TestCoverRunWithoutKeyDoesNothing(t *testing.T) {
c := newCovers(t, fakedb.New(), CoverOptions{})
assert.False(t, c.SearchesAutomatically())
done := make(chan struct{})
go func() { c.Run(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run should return at once without a SteamGridDB key")
}
}
func TestCoverSearchNames(t *testing.T) {
assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"},
coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"}))
assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil))
assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil))
}
func TestNormalizeTitle(t *testing.T) {
assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie"))
assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII"))
}
func TestBestGame(t *testing.T) {
games := func(names ...string) []steamGridDBGame {
result := make([]steamGridDBGame, len(names))
for i, name := range names {
result[i] = steamGridDBGame{ID: i + 1, Name: name}
}
return result
}
tests := []struct {
search string
games []steamGridDBGame
want string
}{
// Real SteamGridDB results, in their order.
{"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV",
"Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"},
{"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"},
{"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"},
{"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"},
{"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"},
// No result has every word: SteamGridDB's first result.
{"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"},
{"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"},
{"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search)
}
}