Add alternative names and cover art for soundtracks
CI / docker (push) Failing after 22s
CI / integration (push) Successful in 26s
CI / test (push) Successful in 26s
CI / publish (push) Skipped

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:
SansanandClaude Opus 5.5 committed 2026-10-13 21:57:18 +02:00
1 parent f79f51ea4c
commit bb8de5548d
39 files changed
+2418 -99

No files matched your search

+289
View File
@@ -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()
}
+410
View File
@@ -0,0 +1,410 @@
package backend
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"go.uber.org/zap"
)
const (
// MaxCoverBytes is the largest cover image that is stored.
MaxCoverBytes = 10 << 20
// coverRecheckAfter is how long a soundtrack without a match waits before the job
// searches for it again. Changing its alternative names makes it search right away.
coverRecheckAfter = 30 * 24 * time.Hour
// coverRequestInterval spaces out requests to the external APIs, to stay well inside
// their rate limits.
coverRequestInterval = time.Second
)
// SourceUpload is the source of a cover that was uploaded.
const SourceUpload = "upload"
// coverTypes are the image types stored as covers, by MIME type, with their file extension.
var coverTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
// CoverService stores cover images for soundtracks in a folder and finds them in external
// databases in the background.
type CoverService struct {
q repository.Querier
dir string
sources []coverSource
client *http.Client
// interval spaces out external requests; tests set it to 0.
interval time.Duration
trigger chan struct{}
// writeMu keeps the job from replacing a cover that is being set by hand.
writeMu sync.Mutex
}
// CoverOptions configures where covers are searched.
type CoverOptions struct {
// SteamGridDBKey turns on SteamGridDB and the background job.
SteamGridDBKey string
// SteamGridDBURL and AniListURL replace the real APIs, for tests.
SteamGridDBURL string
AniListURL string
}
// NewCoverService stores covers in dir. With a SteamGridDB API key the background job
// searches SteamGridDB, then AniList; without one there is no background job, but covers
// can still be uploaded or picked by ID.
func NewCoverService(q repository.Querier, dir string, opts CoverOptions) (*CoverService, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
c := &CoverService{
q: q,
dir: dir,
client: client,
interval: coverRequestInterval,
trigger: make(chan struct{}, 1),
}
if opts.SteamGridDBKey != "" {
c.sources = append(c.sources, &steamGridDB{
baseURL: cmp.Or(opts.SteamGridDBURL, steamGridDBURL), key: opts.SteamGridDBKey, client: client,
})
}
c.sources = append(c.sources, &aniList{url: cmp.Or(opts.AniListURL, aniListURL), client: client})
return c, nil
}
// SearchesAutomatically reports whether the background job runs.
func (c *CoverService) SearchesAutomatically() bool {
return c.source("steamgriddb") != nil
}
func (c *CoverService) source(name string) coverSource {
for _, s := range c.sources {
if s.Name() == name {
return s
}
}
return nil
}
// Path returns the cover image of an active soundtrack, or ErrNotFound.
func (c *CoverService) Path(ctx context.Context, id uuid.UUID) (string, error) {
st, err := c.q.GetSoundtrackByUUID(ctx, id)
if err != nil {
return "", notFound(err)
}
if st.CoverFile == nil {
return "", ErrNotFound
}
return filepath.Join(c.dir, *st.CoverFile), nil
}
// Upload stores data as the soundtrack's cover. It is never replaced by the background job.
func (c *CoverService) Upload(ctx context.Context, id uuid.UUID, data []byte) error {
if _, err := c.activeSoundtrack(ctx, id); err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.store(ctx, id, data, SourceUpload, nil, true)
}
// FromSource stores the cover of entry externalID in source (steamgriddb or anilist) as
// the soundtrack's cover. It is never replaced by the background job.
func (c *CoverService) FromSource(ctx context.Context, id uuid.UUID, source, externalID string) error {
s := c.source(source)
if s == nil {
return fmt.Errorf("%w: unknown or disabled cover source %q", ErrInvalidName, source)
}
if _, err := c.activeSoundtrack(ctx, id); err != nil {
return err
}
match, err := s.ByID(ctx, externalID)
if err != nil {
return err
}
data, err := c.download(ctx, match.ImageURL)
if err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.store(ctx, id, data, source, &match.ExternalID, true)
}
// Remove deletes the soundtrack's cover, so the background job searches for a new one.
func (c *CoverService) Remove(ctx context.Context, id uuid.UUID) error {
st, err := c.activeSoundtrack(ctx, id)
if err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.q.ClearCover(ctx, id); err != nil {
return err
}
if st.CoverFile != nil {
c.removeFile(*st.CoverFile)
}
c.Trigger()
return nil
}
func (c *CoverService) activeSoundtrack(ctx context.Context, id uuid.UUID) (repository.Soundtrack, error) {
st, err := c.q.GetSoundtrackByUUID(ctx, id)
return st, notFound(err)
}
// store writes data as the soundtrack's cover file and records it. Call with writeMu held.
func (c *CoverService) store(ctx context.Context, id uuid.UUID, data []byte, source string, externalID *string, manual bool) error {
ext, err := coverExtension(data)
if err != nil {
return err
}
name := id.String() + ext
tmp, err := os.CreateTemp(c.dir, ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmp.Name(), filepath.Join(c.dir, name)); err != nil {
return err
}
// A cover of another image type leaves a file with another extension behind.
for _, other := range coverTypes {
if other != ext {
c.removeFile(id.String() + other)
}
}
return c.q.SetCover(ctx, repository.SetCoverParams{
Uuid: id, File: &name, Source: &source, ExternalID: externalID, Manual: manual,
})
}
func (c *CoverService) removeFile(name string) {
if err := os.Remove(filepath.Join(c.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
logging.GetLogger().Warn("Failed to remove cover", zap.String("file", name), zap.Error(err))
}
}
// coverExtension checks that data is a supported image and returns its file extension.
func coverExtension(data []byte) (string, error) {
if len(data) > MaxCoverBytes {
return "", fmt.Errorf("%w: the image is larger than %d MB", ErrInvalidImage, MaxCoverBytes>>20)
}
ext, ok := coverTypes[http.DetectContentType(data)]
if !ok {
return "", fmt.Errorf("%w: only JPEG, PNG and WebP images are supported", ErrInvalidImage)
}
return ext, nil
}
// download fetches a cover image.
func (c *CoverService) download(ctx context.Context, imageURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return nil, fmt.Errorf("%w: invalid image URL: %v", ErrUpstream, err)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: image download answered %s", ErrUpstream, resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, MaxCoverBytes+1))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
}
if _, err := coverExtension(data); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
}
return data, nil
}
// Trigger asks the background job to look for missing covers. It never blocks.
func (c *CoverService) Trigger() {
select {
case c.trigger <- struct{}{}:
default: // a pass is already waiting
}
}
// Run searches for missing covers now and each time Trigger is called, until ctx ends.
// It does nothing when automatic search is off.
func (c *CoverService) Run(ctx context.Context) {
if !c.SearchesAutomatically() {
return
}
c.Trigger()
for {
select {
case <-ctx.Done():
return
case <-c.trigger:
if err := c.searchMissing(ctx); err != nil && ctx.Err() == nil {
logging.GetLogger().Warn("Cover search stopped, trying again after the next sync", zap.Error(err))
}
}
}
}
// searchMissing finds covers for active soundtracks without one. It stops at the first
// error from an external API, so an unreachable API is not asked again for every soundtrack.
func (c *CoverService) searchMissing(ctx context.Context) error {
c.removeOrphans(ctx)
rows, err := c.q.ListSoundtracksNeedingCover(ctx,
pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter), Valid: true})
if err != nil {
return err
}
logger := logging.GetLogger()
found := 0
for _, row := range rows {
match, source, err := c.search(ctx, coverSearchNames(row.SoundtrackName, row.AlternativeNames))
if err != nil {
return err
}
if match == nil {
if err := c.q.MarkCoverChecked(ctx, row.Uuid); err != nil {
return err
}
continue
}
if err := c.wait(ctx); err != nil {
return err
}
data, err := c.download(ctx, match.ImageURL)
if err != nil {
return err
}
stored, err := c.storeFound(ctx, row.Uuid, data, source, match.ExternalID)
if err != nil {
return err
}
if stored {
found++
logger.Info("Cover found", zap.String("soundtrack", row.SoundtrackName),
zap.String("source", source), zap.String("external_id", match.ExternalID))
}
}
if len(rows) > 0 {
logger.Info("Cover search finished", zap.Int("searched", len(rows)), zap.Int("found", found))
}
return nil
}
// storeFound stores a cover found by the job, unless one was set by hand meanwhile.
func (c *CoverService) storeFound(ctx context.Context, id uuid.UUID, data []byte, source, externalID string) (bool, error) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
st, err := c.q.GetSoundtrackByUUID(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil // removed meanwhile
}
if err != nil {
return false, err
}
if st.CoverManual || st.CoverFile != nil {
return false, nil
}
return true, c.store(ctx, id, data, source, &externalID, false)
}
// search asks the sources in order and returns the first match.
func (c *CoverService) search(ctx context.Context, names []string) (*coverMatch, string, error) {
for _, s := range c.sources {
if err := c.wait(ctx); err != nil {
return nil, "", err
}
match, err := s.Search(ctx, names)
if err != nil {
return nil, "", fmt.Errorf("%s: %w", s.Name(), err)
}
if match != nil {
return match, s.Name(), nil
}
}
return nil, "", nil
}
func (c *CoverService) wait(ctx context.Context) error {
if c.interval <= 0 {
return ctx.Err()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.interval):
return nil
}
}
// removeOrphans deletes cover files that no soundtrack uses any more, for example after
// the library was reset.
func (c *CoverService) removeOrphans(ctx context.Context) {
files, err := c.q.ListCoverFiles(ctx)
if err != nil {
logging.GetLogger().Warn("Failed to list cover files", zap.Error(err))
return
}
used := make(map[string]bool, len(files))
for _, f := range files {
used[f] = true
}
entries, err := os.ReadDir(c.dir)
if err != nil {
logging.GetLogger().Warn("Failed to read the covers folder", zap.Error(err))
return
}
for _, entry := range entries {
if !entry.IsDir() && !used[entry.Name()] {
c.removeFile(entry.Name())
}
}
}
var bracketed = regexp.MustCompile(`\s*[(\[][^)\]]*[)\]]`)
// coverSearchNames returns the names to search for: the soundtrack's name, its alternative
// names, and the name without parts in brackets ("Lemmings (SNES + Mega Drive)" →
// "Lemmings"), without duplicates.
func coverSearchNames(name string, alternatives []string) []string {
var names []string
seen := map[string]bool{}
for _, n := range append(append([]string{name}, alternatives...), bracketed.ReplaceAllString(name, "")) {
n = strings.TrimSpace(n)
if n != "" && !seen[strings.ToLower(n)] {
seen[strings.ToLower(n)] = true
names = append(names, n)
}
}
return names
}
@@ -0,0 +1,98 @@
//go:build integration
package backend
import (
"context"
"path/filepath"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/pgtest"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAlternativeNamesAndCoversWithPostgres checks the alternative name and cover queries
// against a real database.
func TestAlternativeNamesAndCoversWithPostgres(t *testing.T) {
ctx := context.Background()
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
s := NewSyncService(q, copyTestMusic(t), nil)
require.True(t, s.Start(false))
s.Wait()
library := NewLibraryService(q)
soundtracks, err := library.Soundtracks(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(soundtracks), 2)
first, second := soundtracks[0], soundtracks[1]
assert.Equal(t, []string{}, first.AlternativeNames)
assert.Nil(t, first.CoverModified)
// Alternative names, in every soundtrack response.
updated, err := library.SetAlternativeNames(ctx, first.Uuid, []string{" Alt ", "alt", first.Name, "Другое"})
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, updated.AlternativeNames)
stats, err := NewStatisticsService(q).AllSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, stats[0].AlternativeNames)
never, err := NewStatisticsService(q).NeverPlayedSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, never[0].AlternativeNames)
search, err := NewMusicService(q, t.TempDir()).SearchSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, search[0].AlternativeNames)
// Covers.
c, err := NewCoverService(q, filepath.Join(t.TempDir(), "covers"), CoverOptions{})
require.NoError(t, err)
require.NoError(t, c.Upload(ctx, first.Uuid, pngBytes(t)))
withCover, err := library.Soundtrack(ctx, first.Uuid)
require.NoError(t, err)
require.NotNil(t, withCover.CoverModified)
stats, err = NewStatisticsService(q).AllSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, withCover.CoverModified, stats[0].CoverModified)
needing, err := q.ListSoundtracksNeedingCover(ctx, nowPlus(0))
require.NoError(t, err)
for _, row := range needing {
assert.NotEqual(t, first.Uuid, row.Uuid, "a soundtrack with a cover is not searched")
}
require.NoError(t, q.MarkCoverChecked(ctx, second.Uuid))
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
require.NoError(t, err)
for _, row := range needing {
assert.NotEqual(t, second.Uuid, row.Uuid, "searched recently")
}
// New names make a soundtrack without a cover searched again.
_, err = library.SetAlternativeNames(ctx, second.Uuid, []string{"Other"})
require.NoError(t, err)
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
require.NoError(t, err)
var found bool
for _, row := range needing {
if row.Uuid == second.Uuid {
found = true
assert.Equal(t, []string{"Other"}, row.AlternativeNames)
}
}
assert.True(t, found)
files, err := q.ListCoverFiles(ctx)
require.NoError(t, err)
assert.Equal(t, []string{first.Uuid.String() + ".png"}, files)
require.NoError(t, c.Remove(ctx, first.Uuid))
files, err = q.ListCoverFiles(ctx)
require.NoError(t, err)
assert.Empty(t, files)
}
func nowPlus(seconds int) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: time.Now().Add(time.Duration(seconds) * time.Second), Valid: true}
}
+425
View File
@@ -0,0 +1,425 @@
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)
}
}
+4
View File
@@ -11,6 +11,10 @@ var (
ErrNotFound = errors.New("not found")
// ErrInvalidName is returned when a requested file name is not allowed.
ErrInvalidName = errors.New("invalid name")
// ErrInvalidImage is returned when an image is not a supported type or too large.
ErrInvalidImage = errors.New("invalid image")
// ErrCoversDisabled is returned when covers are used but COVERS_PATH is not set.
ErrCoversDisabled = errors.New("covers are not enabled on this server")
// ErrUpstream is returned when an external service could not be reached.
ErrUpstream = errors.New("upstream service unavailable")
)
+68 -10
View File
@@ -3,23 +3,30 @@ package backend
import (
"context"
"errors"
"strings"
"time"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// Soundtrack is a soundtrack as returned by the API.
type Soundtrack struct {
Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"`
TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played,omitempty"`
Added time.Time `json:"added"`
Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"`
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played,omitempty"`
Added time.Time `json:"added"`
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
}
// MaxNameLength is the longest alternative name that can be stored, in bytes.
const MaxNameLength = 200
// SoundtrackWithSongList is a soundtrack with all its songs.
type SoundtrackWithSongList struct {
Soundtrack
@@ -48,14 +55,65 @@ func NewLibraryService(q repository.Querier) *LibraryService {
func toSoundtrack(s repository.Soundtrack) Soundtrack {
return Soundtrack{
Uuid: s.Uuid,
Name: s.SoundtrackName,
TimesPlayed: s.TimesPlayed,
LastPlayed: s.LastPlayed,
Added: s.Added,
Uuid: s.Uuid,
Name: s.SoundtrackName,
AlternativeNames: nonNil(s.AlternativeNames),
TimesPlayed: s.TimesPlayed,
LastPlayed: s.LastPlayed,
Added: s.Added,
CoverModified: unixSeconds(s.CoverModified),
}
}
// nonNil returns names, or an empty list for nil, so the JSON is [] and never null.
func nonNil(names []string) []string {
if names == nil {
return []string{}
}
return names
}
// unixSeconds returns t in Unix seconds, or nil when it is not set.
func unixSeconds(t pgtype.Timestamptz) *int64 {
if !t.Valid {
return nil
}
seconds := t.Time.Unix()
return &seconds
}
// SetAlternativeNames replaces the alternative names of an active soundtrack. The names
// are trimmed, and empty names, duplicates and names equal to the soundtrack's own name
// (ignoring case) are dropped.
func (l *LibraryService) SetAlternativeNames(ctx context.Context, id uuid.UUID, names []string) (Soundtrack, error) {
row, err := l.q.GetSoundtrackByUUID(ctx, id)
if err != nil {
return Soundtrack{}, notFound(err)
}
row, err = l.q.SetAlternativeNames(ctx, repository.SetAlternativeNamesParams{
Uuid: id, Names: cleanAlternativeNames(row.SoundtrackName, names),
})
if err != nil {
return Soundtrack{}, notFound(err)
}
return toSoundtrack(row), nil
}
func cleanAlternativeNames(name string, names []string) []string {
cleaned := []string{}
seen := map[string]bool{strings.ToLower(strings.TrimSpace(name)): true}
for _, n := range names {
n = strings.TrimSpace(n)
key := strings.ToLower(n)
if n == "" || seen[key] {
continue
}
seen[key] = true
cleaned = append(cleaned, n)
}
return cleaned
}
func notFound(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
+45 -1
View File
@@ -10,6 +10,7 @@ import (
"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"
)
@@ -24,11 +25,54 @@ func TestLibrarySoundtracks(t *testing.T) {
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
require.NoError(t, err)
require.Len(t, soundtracks, 2)
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game"}, soundtracks[0])
assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game", AlternativeNames: []string{}}, soundtracks[0])
assert.Equal(t, b.Uuid, soundtracks[1].Uuid)
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed)
}
func TestLibrarySetAlternativeNames(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
l := NewLibraryService(store)
ctx := context.Background()
soundtrack, err := l.SetAlternativeNames(ctx, st.Uuid,
[]string{" FF7 ", "ff7", "", "final fantasy vii", "ファイナルファンタジーVII", " "})
require.NoError(t, err)
assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, soundtrack.AlternativeNames,
"trimmed, without empty names, duplicates or the soundtrack's own name")
stored, err := l.Soundtrack(ctx, st.Uuid)
require.NoError(t, err)
assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, stored.AlternativeNames)
soundtrack, err = l.SetAlternativeNames(ctx, st.Uuid, nil)
require.NoError(t, err)
assert.Equal(t, []string{}, soundtrack.AlternativeNames, "an empty list removes them")
_, err = l.SetAlternativeNames(ctx, uuid.New(), []string{"x"})
assert.ErrorIs(t, err, ErrNotFound)
}
func TestLibrarySetAlternativeNamesSearchesForACoverAgain(t *testing.T) {
store := fakedb.New()
checked := pgtype.Timestamptz{Time: time.Now(), Valid: true}
without := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "No cover", CoverChecked: checked})
file := "cover.jpg"
with := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cover", CoverFile: &file, CoverChecked: checked})
l := NewLibraryService(store)
_, err := l.SetAlternativeNames(context.Background(), without.Uuid, []string{"x"})
require.NoError(t, err)
_, err = l.SetAlternativeNames(context.Background(), with.Uuid, []string{"y"})
require.NoError(t, err)
st, _ := store.Soundtrack("No cover")
assert.False(t, st.CoverChecked.Valid, "searched again with the new names")
st, _ = store.Soundtrack("Cover")
assert.True(t, st.CoverChecked.Valid, "a soundtrack with a cover keeps it")
}
func TestLibrarySoundtrackWithSongs(t *testing.T) {
store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
+13
View File
@@ -348,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
return names, nil
}
// SearchSoundtracks returns every soundtrack's name and alternative names, for search.
func (m *MusicService) SearchSoundtracks(ctx context.Context) ([]SearchSoundtrack, error) {
soundtracks, err := m.allSoundtracks(ctx)
if err != nil {
return nil, err
}
result := make([]SearchSoundtrack, len(soundtracks))
for i, s := range soundtracks {
result[i] = SearchSoundtrack{Name: s.SoundtrackName, AlternativeNames: s.AlternativeNames}
}
return result, nil
}
// SoundtrackNamesRandom returns all soundtrack names in random order.
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
names, err := m.SoundtrackNames(ctx)
+26 -10
View File
@@ -6,23 +6,40 @@ import (
"sync"
)
// SoundtrackData holds precomputed search data for a soundtrack
type SoundtrackData struct {
// SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search.
type SearchSoundtrack struct {
Name string
AlternativeNames []string
}
// SearchName holds a name prepared for matching.
type SearchName struct {
Original string
Lowercase string
Cleaned string
}
// SearchCache holds precomputed search data for all soundtrack names.
func newSearchName(name string) SearchName {
return SearchName{Original: name, Lowercase: strings.ToLower(name), Cleaned: CleanSearchTerm(name)}
}
// SoundtrackData holds precomputed search data for a soundtrack: its name and its
// alternative names.
type SoundtrackData struct {
SearchName
Alternatives []SearchName
}
// SearchCache holds precomputed search data for all soundtracks.
type SearchCache struct {
load func(context.Context) ([]string, error)
load func(context.Context) ([]SearchSoundtrack, error)
mu sync.Mutex
cache []SoundtrackData
}
// NewSearchCache creates a SearchCache that gets the soundtrack names from load.
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache {
// NewSearchCache creates a SearchCache that gets the soundtracks from load.
func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache {
return &SearchCache{load: load}
}
@@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error {
}
cache := make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks {
cache[i] = SoundtrackData{
Original: soundtrack,
Lowercase: strings.ToLower(soundtrack),
Cleaned: CleanSearchTerm(soundtrack),
cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)}
for _, name := range soundtrack.AlternativeNames {
cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name))
}
}
s.cache = cache
+9 -6
View File
@@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) {
}
func TestSearchCache(t *testing.T) {
names := []string{"Final Fantasy VII"}
soundtracks := []SearchSoundtrack{{Name: "Final Fantasy VII", AlternativeNames: []string{"FF 7"}}}
calls := 0
cache := NewSearchCache(func(context.Context) ([]string, error) {
cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) {
calls++
return names, nil
return soundtracks, nil
})
ctx := context.Background()
data, err := cache.Get(ctx)
require.NoError(t, err)
assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data)
assert.Equal(t, []SoundtrackData{{
SearchName: SearchName{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"},
Alternatives: []SearchName{{Original: "FF 7", Lowercase: "ff 7", Cleaned: "ff7"}},
}}, data)
_, _ = cache.Get(ctx)
assert.Equal(t, 1, calls, "data is cached")
names = []string{"Chrono Trigger"}
soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}}
require.NoError(t, cache.Reload(ctx))
data, _ = cache.Get(ctx)
assert.Equal(t, "Chrono Trigger", data[0].Original)
}
func TestSearchCacheLoadError(t *testing.T) {
cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) { return nil, errors.New("db down") })
_, err := cache.Get(context.Background())
assert.Error(t, err)
}
+6
View File
@@ -15,8 +15,10 @@ type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"`
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"`
CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."`
Songs []SongInfoForStats `json:"songs"`
}
@@ -74,8 +76,10 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
AlternativeNames: nonNil(row.AlternativeNames),
SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed,
CoverModified: unixSeconds(row.CoverModified),
Songs: parseSongs(row.Songs),
})
}
@@ -144,7 +148,9 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName,
AlternativeNames: nonNil(row.AlternativeNames),
SoundtrackPlayed: row.SoundtrackPlayed,
CoverModified: unixSeconds(row.CoverModified),
Songs: parseSongs(row.Songs),
})
}
+31
View File
@@ -403,3 +403,34 @@ func TestResetDB(t *testing.T) {
func TestFormatDuration(t *testing.T) {
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second))
}
func TestSyncKeepsAlternativeNamesAndCover(t *testing.T) {
f := newSyncFixture(t)
f.write("Final Fantasy 7/01.mp3", "a1")
f.run(false)
st := f.soundtrack("Final Fantasy 7")
_, err := f.store.SetAlternativeNames(context.Background(),
repository.SetAlternativeNamesParams{Uuid: st.Uuid, Names: []string{"FF7"}})
require.NoError(t, err)
file, source := "cover.jpg", "upload"
require.NoError(t, f.store.SetCover(context.Background(),
repository.SetCoverParams{Uuid: st.Uuid, File: &file, Source: &source, Manual: true}))
// Renamed: the same row is kept.
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy 7"), filepath.Join(f.dir, "Final Fantasy VII")))
f.run(false)
renamed := f.soundtrack("Final Fantasy VII")
assert.Equal(t, st.Uuid, renamed.Uuid)
assert.Equal(t, []string{"FF7"}, renamed.AlternativeNames)
assert.Equal(t, &file, renamed.CoverFile)
// Removed and added again.
require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy VII"), filepath.Join(t.TempDir(), "away")))
f.run(false)
require.NoError(t, os.MkdirAll(filepath.Join(f.dir, "Final Fantasy VII"), 0o755))
f.write("Final Fantasy VII/01.mp3", "a1")
f.run(false)
readded := f.soundtrack("Final Fantasy VII")
assert.Nil(t, readded.Deleted)
assert.Equal(t, []string{"FF7"}, readded.AlternativeNames)
}