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()
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user