diff --git a/.gitignore b/.gitignore
index 20cf7b3..1f21b4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,6 @@ main
# Generated with `just openapi`
/openapi.json
/openapi-3.0.json
+
+# Local cover images (COVERS_PATH)
+/covers/
diff --git a/Dockerfile b/Dockerfile
index 8cd6e35..713391e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -36,6 +36,8 @@ VOLUME /sorted
VOLUME /characters
# Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH
+# Covers (optional): COVERS_PATH, a writable folder such as a /covers volume, turns covers on.
+# STEAMGRIDDB_API_KEY also turns on the automatic search (SteamGridDB, then AniList).
ENV PORT=8080
CMD ["./main"]
diff --git a/cmd/web/assets/css/styles.css b/cmd/web/assets/css/styles.css
index 12027fe..e65043a 100644
--- a/cmd/web/assets/css/styles.css
+++ b/cmd/web/assets/css/styles.css
@@ -135,6 +135,12 @@ main {
margin-top: 1.5rem;
}
+.matched-name {
+ margin: 0.25rem 0 0;
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+}
+
/* Responsive adjustments */
@media (max-width: 768px) {
#search_term {
diff --git a/cmd/web/search.go b/cmd/web/search.go
index 820cc45..9073096 100644
--- a/cmd/web/search.go
+++ b/cmd/web/search.go
@@ -33,15 +33,33 @@ func NewSearchHandler(cache *backend.SearchCache) http.Handler {
})
}
+// SearchResult is a soundtrack found by search. MatchedName is the alternative name that
+// matched, or empty when the soundtrack's own name matched.
+type SearchResult struct {
+ Name string
+ MatchedName string
+}
+
// search returns the soundtracks matching searchText: exact matches first, then matches
-// ignoring spaces and special characters, then matches with the letters in order.
-func search(searchText string, soundtrackData []backend.SoundtrackData) []string {
- var results []string
+// ignoring spaces and special characters, then matches with the letters in order. Each
+// step tries the soundtrack's own name before its alternative names.
+func search(searchText string, soundtrackData []backend.SoundtrackData) []SearchResult {
+ var results []SearchResult
seen := make(map[string]bool)
- add := func(match func(backend.SoundtrackData) bool) {
+ add := func(match func(backend.SearchName) bool) {
for _, data := range soundtrackData {
- if !seen[data.Original] && match(data) {
- results = append(results, data.Original)
+ if seen[data.Original] {
+ continue
+ }
+ matched, ok := "", match(data.SearchName)
+ for _, alternative := range data.Alternatives {
+ if ok {
+ break
+ }
+ matched, ok = alternative.Original, match(alternative)
+ }
+ if ok {
+ results = append(results, SearchResult{Name: data.Original, MatchedName: matched})
seen[data.Original] = true
}
}
@@ -50,8 +68,8 @@ func search(searchText string, soundtrackData []backend.SoundtrackData) []string
lowercase := strings.ToLower(searchText)
cleaned := backend.CleanSearchTerm(searchText)
- add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) })
- add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) })
- add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) })
+ add(func(n backend.SearchName) bool { return strings.Contains(n.Lowercase, lowercase) })
+ add(func(n backend.SearchName) bool { return strings.Contains(n.Cleaned, cleaned) })
+ add(func(n backend.SearchName) bool { return backend.MatchesSubsequence(cleaned, n.Cleaned) })
return results
}
diff --git a/cmd/web/search.templ b/cmd/web/search.templ
index 34dd3be..1b185ef 100644
--- a/cmd/web/search.templ
+++ b/cmd/web/search.templ
@@ -40,10 +40,13 @@ templ SearchForm() {
}
}
-templ FoundSoundtracks(soundtracks []string) {
+templ FoundSoundtracks(soundtracks []SearchResult) {
for _, soundtrack := range soundtracks {
-
{ soundtrack }
+
{ soundtrack.Name }
+ if soundtrack.MatchedName != "" {
+
Also known as { soundtrack.MatchedName }
+ }
}
}
\ No newline at end of file
diff --git a/cmd/web/search_test.go b/cmd/web/search_test.go
index 0a3c428..e8d55ba 100644
--- a/cmd/web/search_test.go
+++ b/cmd/web/search_test.go
@@ -28,8 +28,24 @@ var soundtracks = []string{
"Ys III - Wanderers from Ys (+The Oath in Felghana)",
}
-func newCache(names []string) *backend.SearchCache {
- return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil })
+func newCache(names []string, alternatives ...map[string][]string) *backend.SearchCache {
+ soundtracks := make([]backend.SearchSoundtrack, len(names))
+ for i, name := range names {
+ soundtracks[i] = backend.SearchSoundtrack{Name: name}
+ for _, a := range alternatives {
+ soundtracks[i].AlternativeNames = a[name]
+ }
+ }
+ return backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return soundtracks, nil })
+}
+
+// names returns the soundtrack names of results.
+func names(results []SearchResult) []string {
+ var n []string
+ for _, r := range results {
+ n = append(n, r.Name)
+ }
+ return n
}
func searchData(t *testing.T) []backend.SoundtrackData {
@@ -62,23 +78,50 @@ func TestSearch(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) {
- assert.Equal(t, tt.want, search(tt.query, data))
+ assert.Equal(t, tt.want, names(search(tt.query, data)))
})
}
}
func TestSearchEmptyReturnsEverything(t *testing.T) {
- assert.Equal(t, soundtracks, search("", searchData(t)))
+ assert.Equal(t, soundtracks, names(search("", searchData(t))))
}
func TestSearchOrdering(t *testing.T) {
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order.
- results := search("mg", searchData(t))
+ results := names(search("mg", searchData(t)))
require.GreaterOrEqual(t, len(results), 2)
assert.Equal(t, "MG", results[0])
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots")
}
+func TestSearchAlternativeNames(t *testing.T) {
+ data, err := newCache(soundtracks, map[string][]string{
+ "Final Fantasy VII": {"FF7", "ファイナルファンタジーVII"},
+ "Kingdom Hearts": {"KH"},
+ }).Get(context.Background())
+ require.NoError(t, err)
+
+ assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "FF7"}}, search("ff7", data))
+ assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "ファイナルファンタジーVII"}}, search("ファイナル", data))
+ assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII"}}, search("fantasy", data),
+ "no matched name when the soundtrack's own name matches")
+ // An exact match on an alternative name comes before a letters-in-order match on a name.
+ results := search("kh", data)
+ require.NotEmpty(t, results)
+ assert.Equal(t, SearchResult{Name: "Kingdom Hearts", MatchedName: "KH"}, results[0])
+}
+
+func TestFoundSoundtracksShowsMatchedName(t *testing.T) {
+ var out strings.Builder
+ require.NoError(t, FoundSoundtracks([]SearchResult{
+ {Name: "Final Fantasy VII", MatchedName: "FF7"}, {Name: "Kingdom Hearts"},
+ }).Render(context.Background(), &out))
+ assert.Contains(t, out.String(), "Final Fantasy VII")
+ assert.Contains(t, out.String(), "Also known as FF7")
+ assert.Equal(t, 1, strings.Count(out.String(), "Also known as"))
+}
+
func TestSearchHandler(t *testing.T) {
handler := NewSearchHandler(newCache(soundtracks))
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode()))
@@ -93,7 +136,7 @@ func TestSearchHandler(t *testing.T) {
}
func TestSearchHandlerLoadError(t *testing.T) {
- cache := backend.NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") })
+ cache := backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return nil, errors.New("db down") })
rec := httptest.NewRecorder()
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
assert.Equal(t, http.StatusInternalServerError, rec.Code)
diff --git a/internal/backend/cover_sources.go b/internal/backend/cover_sources.go
new file mode 100644
index 0000000..d2d2894
--- /dev/null
+++ b/internal/backend/cover_sources.go
@@ -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()
+}
diff --git a/internal/backend/covers.go b/internal/backend/covers.go
new file mode 100644
index 0000000..4d9a1ed
--- /dev/null
+++ b/internal/backend/covers.go
@@ -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
+}
diff --git a/internal/backend/covers_integration_test.go b/internal/backend/covers_integration_test.go
new file mode 100644
index 0000000..d41e497
--- /dev/null
+++ b/internal/backend/covers_integration_test.go
@@ -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}
+}
diff --git a/internal/backend/covers_test.go b/internal/backend/covers_test.go
new file mode 100644
index 0000000..f1b44ea
--- /dev/null
+++ b/internal/backend/covers_test.go
@@ -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("")), 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)
+ }
+}
diff --git a/internal/backend/errors.go b/internal/backend/errors.go
index 16572cf..c7d7d06 100644
--- a/internal/backend/errors.go
+++ b/internal/backend/errors.go
@@ -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")
)
diff --git a/internal/backend/library.go b/internal/backend/library.go
index 8b2a38d..cc8a85f 100644
--- a/internal/backend/library.go
+++ b/internal/backend/library.go
@@ -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
diff --git a/internal/backend/library_test.go b/internal/backend/library_test.go
index a707ebf..ec035d8 100644
--- a/internal/backend/library_test.go
+++ b/internal/backend/library_test.go
@@ -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")
diff --git a/internal/backend/music.go b/internal/backend/music.go
index 591f998..39e4e31 100644
--- a/internal/backend/music.go
+++ b/internal/backend/music.go
@@ -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)
diff --git a/internal/backend/search_cache.go b/internal/backend/search_cache.go
index b948416..b054580 100644
--- a/internal/backend/search_cache.go
+++ b/internal/backend/search_cache.go
@@ -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
diff --git a/internal/backend/search_cache_test.go b/internal/backend/search_cache_test.go
index bd2c9af..96e1be2 100644
--- a/internal/backend/search_cache_test.go
+++ b/internal/backend/search_cache_test.go
@@ -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)
}
diff --git a/internal/backend/statistics.go b/internal/backend/statistics.go
index f61301f..d0b402a 100644
--- a/internal/backend/statistics.go
+++ b/internal/backend/statistics.go
@@ -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),
})
}
diff --git a/internal/backend/sync_test.go b/internal/backend/sync_test.go
index c94b622..090252d 100644
--- a/internal/backend/sync_test.go
+++ b/internal/backend/sync_test.go
@@ -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)
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 64db90c..2da6c2c 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -31,6 +31,14 @@ type Config struct {
CharactersPath string
SoundCheckPath string
FrontendPath string
+ // CoversPath is the folder for soundtrack covers. Empty turns covers off.
+ CoversPath string
+
+ // SteamGridDBAPIKey turns on the background search for covers.
+ SteamGridDBAPIKey string
+ // SteamGridDBURL and AniListURL replace the real cover APIs; only tests set them.
+ SteamGridDBURL string
+ AniListURL string
LogLevel string
LogJSON bool
@@ -65,6 +73,8 @@ func load(getenv func(string) string) (Config, error) {
CharactersPath: getenv("CHARACTERS_PATH"),
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
+ CoversPath: getenv("COVERS_PATH"),
+ SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"),
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
LogJSON: getenv("LOG_JSON") == "true",
APIPassword: getenv("API_PASSWORD"),
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index bcc34c8..7ccf7a2 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -33,6 +33,18 @@ func TestLoadDefaults(t *testing.T) {
assert.Empty(t, cfg.TrustedProxies)
assert.Empty(t, cfg.CORSAllowedOrigins)
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort)
+ assert.Empty(t, cfg.CoversPath, "covers are off by default")
+ assert.Empty(t, cfg.SteamGridDBAPIKey)
+}
+
+func TestLoadCovers(t *testing.T) {
+ values := validEnv()
+ values["COVERS_PATH"] = "/covers"
+ values["STEAMGRIDDB_API_KEY"] = "key"
+ cfg, err := load(env(values))
+ require.NoError(t, err)
+ assert.Equal(t, "/covers", cfg.CoversPath)
+ assert.Equal(t, "key", cfg.SteamGridDBAPIKey)
}
func TestLoadMetricsPort(t *testing.T) {
diff --git a/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql b/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql
index 6b58d09..2dd622f 100644
--- a/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql
+++ b/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql
@@ -1,3 +1,11 @@
+ALTER TABLE soundtrack
+ DROP COLUMN alternative_names,
+ DROP COLUMN cover_file,
+ DROP COLUMN cover_source,
+ DROP COLUMN cover_external_id,
+ DROP COLUMN cover_manual,
+ DROP COLUMN cover_modified,
+ DROP COLUMN cover_checked;
DROP TABLE queue_song;
DROP TABLE queue;
ALTER TABLE soundtrack DROP COLUMN fingerprint;
diff --git a/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql b/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql
index 13bf09e..e696486 100644
--- a/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql
+++ b/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql
@@ -27,3 +27,17 @@ CREATE TABLE queue_song (
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (queue_uuid, song_no)
);
+
+-- Other names a soundtrack is known by, for search and the cover search.
+ALTER TABLE soundtrack ADD COLUMN alternative_names TEXT[] NOT NULL DEFAULT '{}';
+
+-- Cover art. cover_file is the image's file name in COVERS_PATH, NULL when there is no
+-- cover. Manual covers (uploaded or picked) are never replaced by the background job.
+-- cover_checked is when the job last searched, so soundtracks without a match are not
+-- searched again for a while.
+ALTER TABLE soundtrack ADD COLUMN cover_file TEXT NULL;
+ALTER TABLE soundtrack ADD COLUMN cover_source VARCHAR(20) NULL;
+ALTER TABLE soundtrack ADD COLUMN cover_external_id TEXT NULL;
+ALTER TABLE soundtrack ADD COLUMN cover_manual BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE soundtrack ADD COLUMN cover_modified TIMESTAMPTZ NULL;
+ALTER TABLE soundtrack ADD COLUMN cover_checked TIMESTAMPTZ NULL;
diff --git a/internal/db/queries/soundtrack.sql b/internal/db/queries/soundtrack.sql
index 1d6a103..ffd8fb9 100644
--- a/internal/db/queries/soundtrack.sql
+++ b/internal/db/queries/soundtrack.sql
@@ -65,3 +65,44 @@ SELECT uuid FROM soundtrack WHERE id = $1;
-- name: AddSoundtrackPlayedByUUID :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1;
+
+-- name: SetAlternativeNames :one
+-- Replaces the alternative names. A soundtrack without a cover is searched again with them.
+UPDATE soundtrack
+SET alternative_names = sqlc.arg(names)::text[],
+ cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
+WHERE uuid = sqlc.arg(uuid) AND deleted IS NULL
+RETURNING *;
+
+-- name: ListSoundtracksNeedingCover :many
+-- Active soundtracks without a cover that the job has not searched since checked_before.
+SELECT uuid, soundtrack_name, alternative_names
+FROM soundtrack
+WHERE deleted IS NULL
+ AND cover_file IS NULL
+ AND NOT cover_manual
+ AND (cover_checked IS NULL OR cover_checked < sqlc.arg(checked_before))
+ORDER BY soundtrack_name;
+
+-- name: SetCover :exec
+UPDATE soundtrack
+SET cover_file = sqlc.arg(file),
+ cover_source = sqlc.arg(source),
+ cover_external_id = sqlc.arg(external_id),
+ cover_manual = sqlc.arg(manual),
+ cover_modified = now(),
+ cover_checked = now()
+WHERE uuid = sqlc.arg(uuid);
+
+-- name: MarkCoverChecked :exec
+UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1;
+
+-- name: ClearCover :exec
+-- Removes the cover, so the job searches for one again.
+UPDATE soundtrack
+SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
+ cover_modified = NULL, cover_checked = NULL
+WHERE uuid = $1;
+
+-- name: ListCoverFiles :many
+SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL;
diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql
index e2ac549..1e9b995 100644
--- a/internal/db/queries/statistics.sql
+++ b/internal/db/queries/statistics.sql
@@ -5,6 +5,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -33,6 +35,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -95,6 +99,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.added,
COALESCE(
json_agg(
@@ -121,6 +127,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -148,6 +156,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -188,6 +198,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go
index 4ae9c33..bfe9859 100644
--- a/internal/db/repository/models.go
+++ b/internal/db/repository/models.go
@@ -59,18 +59,25 @@ type SongList struct {
}
type Soundtrack struct {
- ID int32 `json:"id"`
- SoundtrackName string `json:"soundtrack_name"`
- Added time.Time `json:"added"`
- Deleted *time.Time `json:"deleted"`
- LastChanged *time.Time `json:"last_changed"`
- Path string `json:"path"`
- TimesPlayed int32 `json:"times_played"`
- LastPlayed *time.Time `json:"last_played"`
- NumberOfSongs int32 `json:"number_of_songs"`
- Hash string `json:"hash"`
- Uuid uuid.UUID `json:"uuid"`
- Fingerprint string `json:"fingerprint"`
+ ID int32 `json:"id"`
+ SoundtrackName string `json:"soundtrack_name"`
+ Added time.Time `json:"added"`
+ Deleted *time.Time `json:"deleted"`
+ LastChanged *time.Time `json:"last_changed"`
+ Path string `json:"path"`
+ TimesPlayed int32 `json:"times_played"`
+ LastPlayed *time.Time `json:"last_played"`
+ NumberOfSongs int32 `json:"number_of_songs"`
+ Hash string `json:"hash"`
+ Uuid uuid.UUID `json:"uuid"`
+ Fingerprint string `json:"fingerprint"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverFile *string `json:"cover_file"`
+ CoverSource *string `json:"cover_source"`
+ CoverExternalID *string `json:"cover_external_id"`
+ CoverManual bool `json:"cover_manual"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ CoverChecked pgtype.Timestamptz `json:"cover_checked"`
}
type Vgmq struct {
diff --git a/internal/db/repository/querier.go b/internal/db/repository/querier.go
index a8dd494..d77acf9 100644
--- a/internal/db/repository/querier.go
+++ b/internal/db/repository/querier.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
@@ -23,6 +24,8 @@ type Querier interface {
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
+ // Removes the cover, so the job searches for one again.
+ ClearCover(ctx context.Context, argUuid uuid.UUID) error
ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error
@@ -73,14 +76,21 @@ type Querier interface {
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
+ ListCoverFiles(ctx context.Context) ([]string, error)
// The song columns are NULL when the song has been removed from the library since.
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error)
ListSessions(ctx context.Context) ([]Session, error)
+ // Active soundtracks without a cover that the job has not searched since checked_before.
+ ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error)
+ MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
+ // Replaces the alternative names. A soundtrack without a cover is searched again with them.
+ SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error)
+ SetCover(ctx context.Context, arg SetCoverParams) error
SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
SetSoundtrackDeletionDate(ctx context.Context) error
diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go
index c6062fe..283f7eb 100644
--- a/internal/db/repository/soundtrack.sql.go
+++ b/internal/db/repository/soundtrack.sql.go
@@ -9,6 +9,7 @@ import (
"context"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
)
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -29,6 +30,19 @@ func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UU
return err
}
+const clearCover = `-- name: ClearCover :exec
+UPDATE soundtrack
+SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false,
+ cover_modified = NULL, cover_checked = NULL
+WHERE uuid = $1
+`
+
+// Removes the cover, so the job searches for one again.
+func (q *Queries) ClearCover(ctx context.Context, argUuid uuid.UUID) error {
+ _, err := q.db.Exec(ctx, clearCover, argUuid)
+ return err
+}
+
const clearSoundtracks = `-- name: ClearSoundtracks :exec
DELETE FROM soundtrack
`
@@ -39,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
}
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack
WHERE deleted IS NULL
ORDER BY soundtrack_name
@@ -67,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.Hash,
&i.Uuid,
&i.Fingerprint,
+ &i.AlternativeNames,
+ &i.CoverFile,
+ &i.CoverSource,
+ &i.CoverExternalID,
+ &i.CoverManual,
+ &i.CoverModified,
+ &i.CoverChecked,
); err != nil {
return nil, err
}
@@ -79,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
}
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack
ORDER BY soundtrack_name
`
@@ -106,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.Hash,
&i.Uuid,
&i.Fingerprint,
+ &i.AlternativeNames,
+ &i.CoverFile,
+ &i.CoverSource,
+ &i.CoverExternalID,
+ &i.CoverManual,
+ &i.CoverModified,
+ &i.CoverChecked,
); err != nil {
return nil, err
}
@@ -129,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
}
const getSoundtrackById = `-- name: GetSoundtrackById :one
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack
WHERE id = $1
AND deleted IS NULL
@@ -151,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
+ &i.AlternativeNames,
+ &i.CoverFile,
+ &i.CoverSource,
+ &i.CoverExternalID,
+ &i.CoverManual,
+ &i.CoverModified,
+ &i.CoverChecked,
)
return i, err
}
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
-SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
+SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
FROM soundtrack
WHERE uuid = $1
AND deleted IS NULL
@@ -178,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
&i.Hash,
&i.Uuid,
&i.Fingerprint,
+ &i.AlternativeNames,
+ &i.CoverFile,
+ &i.CoverSource,
+ &i.CoverExternalID,
+ &i.CoverManual,
+ &i.CoverModified,
+ &i.CoverChecked,
)
return i, err
}
@@ -253,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
return err
}
+const listCoverFiles = `-- name: ListCoverFiles :many
+SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL
+`
+
+func (q *Queries) ListCoverFiles(ctx context.Context) ([]string, error) {
+ rows, err := q.db.Query(ctx, listCoverFiles)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []string
+ for rows.Next() {
+ var cover_file string
+ if err := rows.Scan(&cover_file); err != nil {
+ return nil, err
+ }
+ items = append(items, cover_file)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listSoundtracksNeedingCover = `-- name: ListSoundtracksNeedingCover :many
+SELECT uuid, soundtrack_name, alternative_names
+FROM soundtrack
+WHERE deleted IS NULL
+ AND cover_file IS NULL
+ AND NOT cover_manual
+ AND (cover_checked IS NULL OR cover_checked < $1)
+ORDER BY soundtrack_name
+`
+
+type ListSoundtracksNeedingCoverRow struct {
+ Uuid uuid.UUID `json:"uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ AlternativeNames []string `json:"alternative_names"`
+}
+
+// Active soundtracks without a cover that the job has not searched since checked_before.
+func (q *Queries) ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error) {
+ rows, err := q.db.Query(ctx, listSoundtracksNeedingCover, checkedBefore)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []ListSoundtracksNeedingCoverRow
+ for rows.Next() {
+ var i ListSoundtracksNeedingCoverRow
+ if err := rows.Scan(&i.Uuid, &i.SoundtrackName, &i.AlternativeNames); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const markCoverChecked = `-- name: MarkCoverChecked :exec
+UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1
+`
+
+func (q *Queries) MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error {
+ _, err := q.db.Exec(ctx, markCoverChecked, argUuid)
+ return err
+}
+
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
`
@@ -282,6 +394,77 @@ func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) {
return setval, err
}
+const setAlternativeNames = `-- name: SetAlternativeNames :one
+UPDATE soundtrack
+SET alternative_names = $1::text[],
+ cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END
+WHERE uuid = $2 AND deleted IS NULL
+RETURNING id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked
+`
+
+type SetAlternativeNamesParams struct {
+ Names []string `json:"names"`
+ Uuid uuid.UUID `json:"uuid"`
+}
+
+// Replaces the alternative names. A soundtrack without a cover is searched again with them.
+func (q *Queries) SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error) {
+ row := q.db.QueryRow(ctx, setAlternativeNames, arg.Names, arg.Uuid)
+ var i Soundtrack
+ err := row.Scan(
+ &i.ID,
+ &i.SoundtrackName,
+ &i.Added,
+ &i.Deleted,
+ &i.LastChanged,
+ &i.Path,
+ &i.TimesPlayed,
+ &i.LastPlayed,
+ &i.NumberOfSongs,
+ &i.Hash,
+ &i.Uuid,
+ &i.Fingerprint,
+ &i.AlternativeNames,
+ &i.CoverFile,
+ &i.CoverSource,
+ &i.CoverExternalID,
+ &i.CoverManual,
+ &i.CoverModified,
+ &i.CoverChecked,
+ )
+ return i, err
+}
+
+const setCover = `-- name: SetCover :exec
+UPDATE soundtrack
+SET cover_file = $1,
+ cover_source = $2,
+ cover_external_id = $3,
+ cover_manual = $4,
+ cover_modified = now(),
+ cover_checked = now()
+WHERE uuid = $5
+`
+
+type SetCoverParams struct {
+ File *string `json:"file"`
+ Source *string `json:"source"`
+ ExternalID *string `json:"external_id"`
+ Manual bool `json:"manual"`
+ Uuid uuid.UUID `json:"uuid"`
+}
+
+func (q *Queries) SetCover(ctx context.Context, arg SetCoverParams) error {
+ _, err := q.db.Exec(ctx, setCover,
+ arg.File,
+ arg.Source,
+ arg.ExternalID,
+ arg.Manual,
+ arg.Uuid,
+ )
+ return err
+}
+
const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
`
diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go
index 89880af..36ed49b 100644
--- a/internal/db/repository/statistics.sql.go
+++ b/internal/db/repository/statistics.sql.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
)
const countLibrary = `-- name: CountLibrary :one
@@ -37,6 +38,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -59,12 +62,14 @@ ORDER BY g.soundtrack_name
`
type GetAllSoundtracksWithSongsRow struct {
- SoundtrackID int32 `json:"soundtrack_id"`
- SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
- SoundtrackName string `json:"soundtrack_name"`
- SoundtrackPlayed int32 `json:"soundtrack_played"`
- SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
- Songs []byte `json:"songs"`
+ SoundtrackID int32 `json:"soundtrack_id"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ SoundtrackPlayed int32 `json:"soundtrack_played"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
+ Songs []byte `json:"songs"`
}
// Every soundtrack with its songs, by name
@@ -82,6 +87,8 @@ func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSound
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
+ &i.AlternativeNames,
+ &i.CoverModified,
&i.SoundtrackLastPlayed,
&i.Songs,
); err != nil {
@@ -101,6 +108,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -123,12 +132,14 @@ LIMIT $1
`
type GetLastPlayedSoundtracksRow struct {
- SoundtrackID int32 `json:"soundtrack_id"`
- SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
- SoundtrackName string `json:"soundtrack_name"`
- SoundtrackPlayed int32 `json:"soundtrack_played"`
- SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
- Songs []byte `json:"songs"`
+ SoundtrackID int32 `json:"soundtrack_id"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ SoundtrackPlayed int32 `json:"soundtrack_played"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
+ Songs []byte `json:"songs"`
}
// Last played soundtracks (most recently played)
@@ -146,6 +157,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
+ &i.AlternativeNames,
+ &i.CoverModified,
&i.SoundtrackLastPlayed,
&i.Songs,
); err != nil {
@@ -223,6 +236,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -246,12 +261,14 @@ LIMIT $1
`
type GetLeastPlayedSoundtracksWithSongsRow struct {
- SoundtrackID int32 `json:"soundtrack_id"`
- SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
- SoundtrackName string `json:"soundtrack_name"`
- SoundtrackPlayed int32 `json:"soundtrack_played"`
- SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
- Songs []byte `json:"songs"`
+ SoundtrackID int32 `json:"soundtrack_id"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ SoundtrackPlayed int32 `json:"soundtrack_played"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
+ Songs []byte `json:"songs"`
}
// Least played soundtracks with their songs
@@ -269,6 +286,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
+ &i.AlternativeNames,
+ &i.CoverModified,
&i.SoundtrackLastPlayed,
&i.Songs,
); err != nil {
@@ -346,6 +365,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -369,12 +390,14 @@ LIMIT $1
`
type GetMostPlayedSoundtracksWithSongsRow struct {
- SoundtrackID int32 `json:"soundtrack_id"`
- SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
- SoundtrackName string `json:"soundtrack_name"`
- SoundtrackPlayed int32 `json:"soundtrack_played"`
- SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
- Songs []byte `json:"songs"`
+ SoundtrackID int32 `json:"soundtrack_id"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ SoundtrackPlayed int32 `json:"soundtrack_played"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
+ Songs []byte `json:"songs"`
}
// Most played soundtracks with their songs
@@ -392,6 +415,8 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
+ &i.AlternativeNames,
+ &i.CoverModified,
&i.SoundtrackLastPlayed,
&i.Songs,
); err != nil {
@@ -411,6 +436,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.added,
COALESCE(
json_agg(
@@ -432,12 +459,14 @@ ORDER BY g.soundtrack_name
`
type GetNeverPlayedSoundtracksRow struct {
- SoundtrackID int32 `json:"soundtrack_id"`
- SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
- SoundtrackName string `json:"soundtrack_name"`
- SoundtrackPlayed int32 `json:"soundtrack_played"`
- Added time.Time `json:"added"`
- Songs []byte `json:"songs"`
+ SoundtrackID int32 `json:"soundtrack_id"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ SoundtrackPlayed int32 `json:"soundtrack_played"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ Added time.Time `json:"added"`
+ Songs []byte `json:"songs"`
}
// Soundtracks that have never been played (times_played = 0)
@@ -455,6 +484,8 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
+ &i.AlternativeNames,
+ &i.CoverModified,
&i.Added,
&i.Songs,
); err != nil {
@@ -474,6 +505,8 @@ SELECT
g.uuid as soundtrack_uuid,
g.soundtrack_name,
g.times_played as soundtrack_played,
+ g.alternative_names,
+ g.cover_modified,
g.last_played as soundtrack_last_played,
COALESCE(
json_agg(
@@ -496,12 +529,14 @@ LIMIT $1
`
type GetOldestPlayedSoundtracksRow struct {
- SoundtrackID int32 `json:"soundtrack_id"`
- SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
- SoundtrackName string `json:"soundtrack_name"`
- SoundtrackPlayed int32 `json:"soundtrack_played"`
- SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
- Songs []byte `json:"songs"`
+ SoundtrackID int32 `json:"soundtrack_id"`
+ SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
+ SoundtrackName string `json:"soundtrack_name"`
+ SoundtrackPlayed int32 `json:"soundtrack_played"`
+ AlternativeNames []string `json:"alternative_names"`
+ CoverModified pgtype.Timestamptz `json:"cover_modified"`
+ SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
+ Songs []byte `json:"songs"`
}
// Oldest played soundtracks (least recently played, but has been played at least once)
@@ -519,6 +554,8 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) (
&i.SoundtrackUuid,
&i.SoundtrackName,
&i.SoundtrackPlayed,
+ &i.AlternativeNames,
+ &i.CoverModified,
&i.SoundtrackLastPlayed,
&i.Songs,
); err != nil {
diff --git a/internal/server/api.go b/internal/server/api.go
index 0bc1279..5609412 100644
--- a/internal/server/api.go
+++ b/internal/server/api.go
@@ -146,8 +146,10 @@ func apiError(err error) error {
switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return huma.Error404NotFound(err.Error())
- case errors.Is(err, backend.ErrInvalidName):
+ case errors.Is(err, backend.ErrInvalidName), errors.Is(err, backend.ErrInvalidImage):
return huma.Error400BadRequest(err.Error())
+ case errors.Is(err, backend.ErrCoversDisabled):
+ return huma.Error503ServiceUnavailable(err.Error())
case errors.Is(err, backend.ErrUpstream):
return huma.Error502BadGateway(err.Error())
default:
diff --git a/internal/server/cover_handler.go b/internal/server/cover_handler.go
new file mode 100644
index 0000000..995d186
--- /dev/null
+++ b/internal/server/cover_handler.go
@@ -0,0 +1,114 @@
+package server
+
+import (
+ "context"
+ "net/http"
+
+ "music-server/internal/backend"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/google/uuid"
+)
+
+type coverUploadInput struct {
+ UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
+ RawBody []byte `contentType:"image/*" doc:"The image: JPEG, PNG or WebP, at most 10 MB"`
+}
+
+// CoverSource is the body of PUT /soundtracks/{uuid}/cover/source.
+type CoverSource struct {
+ Source string `json:"source" enum:"steamgriddb,anilist" doc:"Where the cover comes from"`
+ ID string `json:"id" minLength:"1" maxLength:"50" doc:"The game's ID on SteamGridDB, or the anime's ID on AniList"`
+}
+
+type coverSourceInput struct {
+ UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
+ Body CoverSource
+}
+
+func (s *Server) registerCovers(a huma.API) {
+ op := func(id, method, path, summary, description string, errs ...int) huma.Operation {
+ return huma.Operation{
+ OperationID: id,
+ Method: method,
+ Path: path,
+ Summary: summary,
+ Description: description,
+ Tags: []string{"covers"},
+ Errors: errs,
+ }
+ }
+ // covers returns the cover service, or ErrCoversDisabled when COVERS_PATH is not set.
+ covers := func() (*backend.CoverService, error) {
+ if s.covers == nil {
+ return nil, apiError(backend.ErrCoversDisabled)
+ }
+ return s.covers, nil
+ }
+
+ get := op("get-soundtrack-cover", http.MethodGet, "/soundtracks/{uuid}/cover", "Soundtrack cover",
+ "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
+ http.StatusNotFound)
+ get.Responses = fileResponses("The cover image", "image/jpeg", "image/png", "image/webp")
+ register(s, a, route{op: get},
+ func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
+ if s.covers == nil {
+ return nil, huma.Error404NotFound("Not Found")
+ }
+ path, err := s.covers.Path(ctx, in.id())
+ if err != nil {
+ return nil, apiError(err)
+ }
+ return streamFile(path, "", false)
+ })
+
+ upload := op("upload-soundtrack-cover", http.MethodPut, "/soundtracks/{uuid}/cover", "Upload a cover",
+ "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
+ http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)
+ upload.MaxBodyBytes = backend.MaxCoverBytes
+ upload.DefaultStatus = http.StatusNoContent
+ register(s, a, route{op: upload},
+ func(ctx context.Context, in *coverUploadInput) (*struct{}, error) {
+ c, err := covers()
+ if err != nil {
+ return nil, err
+ }
+ if err := c.Upload(ctx, uuid.MustParse(in.UUID), in.RawBody); err != nil {
+ return nil, apiError(err)
+ }
+ return nil, nil
+ })
+
+ source := op("set-soundtrack-cover-source", http.MethodPut, "/soundtracks/{uuid}/cover/source", "Pick a cover",
+ "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. "+
+ "The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
+ http.StatusBadRequest, http.StatusNotFound, http.StatusBadGateway, http.StatusServiceUnavailable)
+ source.DefaultStatus = http.StatusNoContent
+ register(s, a, route{op: source},
+ func(ctx context.Context, in *coverSourceInput) (*struct{}, error) {
+ c, err := covers()
+ if err != nil {
+ return nil, err
+ }
+ if err := c.FromSource(ctx, uuid.MustParse(in.UUID), in.Body.Source, in.Body.ID); err != nil {
+ return nil, apiError(err)
+ }
+ return nil, nil
+ })
+
+ remove := op("delete-soundtrack-cover", http.MethodDelete, "/soundtracks/{uuid}/cover", "Remove the cover",
+ "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
+ http.StatusNotFound, http.StatusServiceUnavailable)
+ remove.DefaultStatus = http.StatusNoContent
+ register(s, a, route{op: remove},
+ func(ctx context.Context, in *uuidInput) (*struct{}, error) {
+ c, err := covers()
+ if err != nil {
+ return nil, err
+ }
+ if err := c.Remove(ctx, in.id()); err != nil {
+ return nil, apiError(err)
+ }
+ return nil, nil
+ })
+}
diff --git a/internal/server/cover_handler_test.go b/internal/server/cover_handler_test.go
new file mode 100644
index 0000000..a633f68
--- /dev/null
+++ b/internal/server/cover_handler_test.go
@@ -0,0 +1,143 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "image"
+ "image/png"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "music-server/internal/backend"
+ "music-server/internal/config"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func pngImage(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()
+}
+
+// newFakeAniList answers AniList lookups by ID: ID 42 has a cover, others do not.
+func newFakeAniList(t *testing.T) string {
+ img := pngImage(t)
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ _, _ = w.Write(img)
+ return
+ }
+ var req struct {
+ Variables struct {
+ ID int `json:"id"`
+ } `json:"variables"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ media := any(nil)
+ if req.Variables.ID == 42 {
+ media = map[string]any{"id": 42, "coverImage": map[string]any{"extraLarge": server.URL + "/cover.png"}}
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": media}})
+ }))
+ t.Cleanup(server.Close)
+ return server.URL
+}
+
+func withCovers(t *testing.T) func(*config.Config) {
+ aniList := newFakeAniList(t)
+ dir := filepath.Join(t.TempDir(), "covers")
+ return func(c *config.Config) {
+ c.CoversPath = dir
+ c.AniListURL = aniList
+ }
+}
+
+func (e *testEnv) put(path, contentType string, body []byte) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(body))
+ req.Header.Set("Content-Type", contentType)
+ rec := httptest.NewRecorder()
+ e.handler.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestCoverUploadAndGet(t *testing.T) {
+ e := newTestEnv(t, withCovers(t))
+ song := e.addSong("Game A", "Song", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
+ assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, "no cover yet")
+
+ img := pngImage(t)
+ rec := e.put(path, "image/png", img)
+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
+
+ rec = e.do(http.MethodGet, path)
+ assert.Equal(t, http.StatusOK, rec.Code)
+ assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
+ assert.Equal(t, img, rec.Body.Bytes())
+ assert.NotEmpty(t, rec.Header().Get("Last-Modified"))
+
+ var soundtrack backend.Soundtrack
+ require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()).Body.Bytes(), &soundtrack))
+ assert.NotNil(t, soundtrack.CoverModified)
+
+ assert.Equal(t, http.StatusBadRequest, e.put(path, "image/png", []byte("not an image")).Code)
+ big := append(pngImage(t), make([]byte, backend.MaxCoverBytes)...)
+ assert.Equal(t, http.StatusRequestEntityTooLarge, e.put(path, "image/png", big).Code)
+ assert.Equal(t, http.StatusNotFound,
+ e.put("/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/cover", "image/png", img).Code)
+}
+
+func TestCoverPickFromSource(t *testing.T) {
+ e := newTestEnv(t, withCovers(t))
+ song := e.addSong("Game A", "Song", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
+
+ rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`)
+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
+ assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code)
+
+ assert.Equal(t, http.StatusNotFound, e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"7"}`).Code)
+ assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPut, path+"/source", `{"source":"steamgriddb","id":"7"}`).Code,
+ "SteamGridDB needs an API key")
+ assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path+"/source", `{"source":"igdb","id":"7"}`).Code)
+}
+
+func TestCoverDelete(t *testing.T) {
+ e := newTestEnv(t, withCovers(t))
+ song := e.addSong("Game A", "Song", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
+ require.Equal(t, http.StatusNoContent, e.put(path, "image/png", pngImage(t)).Code)
+
+ assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, path).Code)
+ assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code)
+}
+
+func TestCoversDisabled(t *testing.T) {
+ e := newTestEnv(t)
+ song := e.addSong("Game A", "Song", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
+
+ assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code)
+ assert.Equal(t, http.StatusServiceUnavailable, e.put(path, "image/png", pngImage(t)).Code)
+ assert.Equal(t, http.StatusServiceUnavailable, e.do(http.MethodDelete, path).Code)
+ rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`)
+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
+ body, _ := io.ReadAll(rec.Body)
+ assert.Contains(t, string(body), "not enabled")
+}
+
+func TestCoverWritesRequireToken(t *testing.T) {
+ e := newTestEnv(t, withAuth("secret"), withCovers(t))
+ song := e.addSong("Game A", "Song", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
+ assert.Equal(t, http.StatusUnauthorized, e.put(path, "image/png", pngImage(t)).Code)
+ assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, path).Code)
+ assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, path).Code)
+}
diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go
index e5f8488..bcefcac 100644
--- a/internal/server/handlers_test.go
+++ b/internal/server/handlers_test.go
@@ -334,6 +334,8 @@ func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.Ge
return []repository.GetAllSoundtracksWithSongsRow{{
SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A",
+ AlternativeNames: []string{"GA"},
+ CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true},
SoundtrackPlayed: 4,
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`),
}, {
@@ -375,6 +377,11 @@ func TestStatisticsEndpoints(t *testing.T) {
assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed)
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list")
+ assert.Equal(t, []string{"GA"}, soundtracks[0].AlternativeNames)
+ require.NotNil(t, soundtracks[0].CoverModified)
+ assert.Equal(t, int64(1730000000), *soundtracks[0].CoverModified)
+ assert.Equal(t, []string{}, soundtracks[1].AlternativeNames, "no names is an empty list")
+ assert.Nil(t, soundtracks[1].CoverModified)
var summary backend.StatisticsSummary
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
diff --git a/internal/server/library_handler.go b/internal/server/library_handler.go
index c84a016..6b530a2 100644
--- a/internal/server/library_handler.go
+++ b/internal/server/library_handler.go
@@ -2,6 +2,7 @@ package server
import (
"context"
+ "fmt"
"net/http"
"music-server/internal/backend"
@@ -19,6 +20,20 @@ func (in *uuidInput) id() uuid.UUID {
return uuid.MustParse(in.UUID)
}
+// AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names.
+type AlternativeNames struct {
+ Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."`
+}
+
+type alternativeNamesInput struct {
+ UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"`
+ Body AlternativeNames
+}
+
+func (in *alternativeNamesInput) id() uuid.UUID {
+ return uuid.MustParse(in.UUID)
+}
+
func (s *Server) registerLibrary(a huma.API) {
l := s.library
op := func(id, path, summary, description string, errs ...int) huma.Operation {
@@ -49,6 +64,27 @@ func (s *Server) registerLibrary(a huma.API) {
return ok(l.Song(ctx, in.id()))
})
+ names := op("set-alternative-names", "/soundtracks/{uuid}/alternative-names", "Set alternative names",
+ "Replaces the soundtrack's alternative names, which search and the cover search also use. "+
+ "Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
+ http.StatusNotFound)
+ names.Method = http.MethodPut
+ register(s, a, route{op: names},
+ func(ctx context.Context, in *alternativeNamesInput) (*jsonBody[backend.Soundtrack], error) {
+ for _, name := range in.Body.Names {
+ if len(name) > backend.MaxNameLength {
+ return nil, huma.Error422UnprocessableEntity(
+ fmt.Sprintf("names can be at most %d bytes long", backend.MaxNameLength))
+ }
+ }
+ soundtrack, err := l.SetAlternativeNames(ctx, in.id(), in.Body.Names)
+ if err != nil {
+ return nil, apiError(err)
+ }
+ s.soundtracksChanged(ctx)
+ return ok(soundtrack, nil)
+ })
+
file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
"Returns the audio file of a song.", http.StatusNotFound)
file.Responses = fileResponses("The song (MP3)", audioMPEG)
diff --git a/internal/server/library_handler_test.go b/internal/server/library_handler_test.go
index bd7c920..5f88389 100644
--- a/internal/server/library_handler_test.go
+++ b/internal/server/library_handler_test.go
@@ -3,6 +3,8 @@ package server
import (
"encoding/json"
"net/http"
+ "net/http/httptest"
+ "strings"
"testing"
"music-server/internal/backend"
@@ -84,3 +86,47 @@ func TestQueueResponsesIncludeUUIDs(t *testing.T) {
require.Len(t, list, 1)
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"])
}
+
+func TestSetAlternativeNames(t *testing.T) {
+ e := newTestEnv(t)
+ song := e.addSong("Final Fantasy VII", "Prelude", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
+
+ rec := e.doBody(http.MethodPut, path, `{"names":[" FF7 ","ff7","Final Fantasy VII"]}`)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ var soundtrack backend.Soundtrack
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack))
+ assert.Equal(t, []string{"FF7"}, soundtrack.AlternativeNames)
+
+ rec = e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String())
+ assert.Contains(t, rec.Body.String(), `"alternative_names":["FF7"]`)
+
+ // The search page finds it by the new name right away.
+ req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader("search_term=ff7"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec = httptest.NewRecorder()
+ e.handler.ServeHTTP(rec, req)
+ assert.Contains(t, rec.Body.String(), "Final Fantasy VII")
+ assert.Contains(t, rec.Body.String(), "Also known as FF7")
+}
+
+func TestSetAlternativeNamesValidation(t *testing.T) {
+ e := newTestEnv(t)
+ song := e.addSong("Game A", "Song", "x")
+ path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names"
+
+ long := strings.Repeat("a", backend.MaxNameLength+1)
+ assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{"names":["`+long+`"]}`).Code)
+ many := `{"names":["` + strings.Repeat(`x","`, 50) + `x"]}`
+ assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, many).Code, "at most 50 names")
+ assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{}`).Code, "names is required")
+ assert.Equal(t, http.StatusUnprocessableEntity,
+ e.doBody(http.MethodPut, "/api/v1/soundtracks/not-a-uuid/alternative-names", `{"names":[]}`).Code)
+}
+
+func TestSetAlternativeNamesRequiresToken(t *testing.T) {
+ e := newTestEnv(t, withAuth("secret"))
+ song := e.addSong("Game A", "Song", "x")
+ rec := e.doBody(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names", `{"names":["A"]}`)
+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
+}
diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go
index d37b7ba..bbf3137 100644
--- a/internal/server/openapi_test.go
+++ b/internal/server/openapi_test.go
@@ -74,6 +74,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
}
slices.Sort(operations)
assert.Equal(t, []string{
+ "DELETE /api/v1/soundtracks/{uuid}/cover",
"DELETE /api/v1/token",
"GET /api/v1/character",
"GET /api/v1/character/details",
@@ -102,6 +103,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"GET /api/v1/songs/{uuid}/file",
"GET /api/v1/soundtracks",
"GET /api/v1/soundtracks/{uuid}",
+ "GET /api/v1/soundtracks/{uuid}/cover",
"GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-played",
"GET /api/v1/statistics/soundtracks",
@@ -119,6 +121,9 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"POST /api/v1/token",
"POST /api/v1/token/cleanup",
"PUT /api/v1/music/played",
+ "PUT /api/v1/soundtracks/{uuid}/alternative-names",
+ "PUT /api/v1/soundtracks/{uuid}/cover",
+ "PUT /api/v1/soundtracks/{uuid}/cover/source",
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
@@ -169,7 +174,7 @@ func (v *specValidator) check(method, path, body string, wantStatus int, headers
func TestResponsesMatchOpenAPI(t *testing.T) {
gitea := newGitea(t, http.StatusOK)
- e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea })
+ e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }, withCovers(t))
v := newSpecValidator(t, e)
song := e.addSong("Game A", "Song", "0123456789")
songURL := "/api/v1/songs/" + song.Uuid.String()
@@ -202,6 +207,20 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
// Library.
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
+ v.check(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names",
+ `{"names":["Game Alpha"]}`, http.StatusOK)
+ v.check(http.MethodPut, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/alternative-names",
+ `{"names":[]}`, http.StatusNotFound)
+
+ // Covers.
+ coverURL := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover"
+ v.check(http.MethodGet, coverURL, "", http.StatusNotFound)
+ v.check(http.MethodPut, coverURL, string(pngImage(t)), http.StatusNoContent, "Content-Type", "image/png")
+ v.check(http.MethodPut, coverURL, "plain text", http.StatusBadRequest, "Content-Type", "image/png")
+ v.check(http.MethodGet, coverURL, "", http.StatusOK)
+ v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"42"}`, http.StatusNoContent)
+ v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"7"}`, http.StatusNotFound)
+ v.check(http.MethodDelete, coverURL, "", http.StatusNoContent)
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
v.check(http.MethodGet, songURL, "", http.StatusOK)
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 6134c44..3b91c56 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -63,6 +63,7 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
s.registerSync(a)
s.registerMusic(a)
s.registerLibrary(a)
+ s.registerCovers(a)
s.registerStatistics(a)
// Older links to the spec and docs.
diff --git a/internal/server/server.go b/internal/server/server.go
index 055477e..46f959d 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -35,6 +35,11 @@ type Server struct {
releases *backend.ReleaseService
stats *backend.StatisticsService
search *backend.SearchCache
+ // covers is nil when COVERS_PATH is not set.
+ covers *backend.CoverService
+ // stopCovers ends the background cover search; coversDone is closed when it has ended.
+ stopCovers context.CancelFunc
+ coversDone chan struct{}
httpServer *http.Server
// metrics serves /metrics on its own port; nil when metrics are off.
metrics *metrics.Server
@@ -57,9 +62,19 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q),
}
- s.search = backend.NewSearchCache(s.music.SoundtrackNames)
+ s.search = backend.NewSearchCache(s.music.SearchSoundtracks)
s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync)
+ if cfg.CoversPath != "" {
+ s.covers, err = backend.NewCoverService(q, cfg.CoversPath, backend.CoverOptions{
+ SteamGridDBKey: cfg.SteamGridDBAPIKey,
+ SteamGridDBURL: cfg.SteamGridDBURL,
+ AniListURL: cfg.AniListURL,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("covers path: %w", err)
+ }
+ }
if cfg.MetricsPort > 0 {
s.metrics = metrics.NewServer(cfg.MetricsPort)
}
@@ -73,6 +88,36 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
return s, nil
}
+// startCoverSearch runs the background cover search until Shutdown. It does nothing when
+// covers are off or there is no SteamGridDB API key.
+func (s *Server) startCoverSearch() {
+ if s.covers == nil || !s.covers.SearchesAutomatically() {
+ logging.GetLogger().Info("Automatic cover search is off",
+ zap.Bool("covers_path_set", s.covers != nil))
+ return
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ s.stopCovers, s.coversDone = cancel, make(chan struct{})
+ go func() {
+ defer close(s.coversDone)
+ s.covers.Run(ctx)
+ }()
+}
+
+// soundtracksChanged reloads the cached soundtrack data after soundtracks were edited, and
+// lets the cover search try the new names.
+func (s *Server) soundtracksChanged(ctx context.Context) {
+ if err := s.music.ReloadSoundtracks(ctx); err != nil {
+ logging.GetLogger().Error("Failed to reload soundtracks", zap.Error(err))
+ }
+ if err := s.search.Reload(ctx); err != nil {
+ logging.GetLogger().Error("Failed to reload search cache", zap.Error(err))
+ }
+ if s.covers != nil {
+ s.covers.Trigger()
+ }
+}
+
// afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) {
if err := s.music.EndAllQueues(ctx); err != nil {
@@ -84,6 +129,9 @@ func (s *Server) afterSync(ctx context.Context) {
if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
}
+ if s.covers != nil {
+ s.covers.Trigger()
+ }
}
// NewServerInstance loads the configuration, connects to and migrates the database and creates the Server.
@@ -133,6 +181,7 @@ func (s *Server) ListenAndServe() error {
logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err))
}
}
+ s.startCoverSearch()
logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
@@ -146,6 +195,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
if s.metrics != nil {
err = errors.Join(err, s.metrics.Shutdown(ctx))
}
+ if s.stopCovers != nil {
+ s.stopCovers()
+ <-s.coversDone
+ }
s.sync.Wait()
s.characters.Close()
s.db.Close()
diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go
index 5545192..15fc902 100644
--- a/internal/testutil/fakedb/fakedb.go
+++ b/internal/testutil/fakedb/fakedb.go
@@ -193,6 +193,93 @@ func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository
return repository.Soundtrack{}, pgx.ErrNoRows
}
+func (s *Store) soundtrackByUUID(id uuid.UUID) int {
+ return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.Uuid == id })
+}
+
+func (s *Store) updateSoundtrackByUUID(id uuid.UUID, f func(*repository.Soundtrack)) error {
+ defer s.lock()()
+ if s.Err != nil {
+ return s.Err
+ }
+ if i := s.soundtrackByUUID(id); i >= 0 {
+ f(&s.Soundtracks[i])
+ }
+ return nil
+}
+
+func (s *Store) SetAlternativeNames(_ context.Context, arg repository.SetAlternativeNamesParams) (repository.Soundtrack, error) {
+ defer s.lock()()
+ if s.Err != nil {
+ return repository.Soundtrack{}, s.Err
+ }
+ i := s.soundtrackByUUID(arg.Uuid)
+ if i < 0 || s.Soundtracks[i].Deleted != nil {
+ return repository.Soundtrack{}, pgx.ErrNoRows
+ }
+ st := &s.Soundtracks[i]
+ st.AlternativeNames = slices.Clone(arg.Names)
+ if st.CoverFile == nil {
+ st.CoverChecked = pgtype.Timestamptz{}
+ }
+ return *st, nil
+}
+
+func (s *Store) ListSoundtracksNeedingCover(
+ _ context.Context, checkedBefore pgtype.Timestamptz,
+) ([]repository.ListSoundtracksNeedingCoverRow, error) {
+ defer s.lock()()
+ if s.Err != nil {
+ return nil, s.Err
+ }
+ var rows []repository.ListSoundtracksNeedingCoverRow
+ for _, st := range sortedByName(slices.Clone(s.Soundtracks)) {
+ if st.Deleted != nil || st.CoverFile != nil || st.CoverManual ||
+ (st.CoverChecked.Valid && !st.CoverChecked.Time.Before(checkedBefore.Time)) {
+ continue
+ }
+ rows = append(rows, repository.ListSoundtracksNeedingCoverRow{
+ Uuid: st.Uuid, SoundtrackName: st.SoundtrackName, AlternativeNames: st.AlternativeNames,
+ })
+ }
+ return rows, nil
+}
+
+func (s *Store) SetCover(_ context.Context, arg repository.SetCoverParams) error {
+ return s.updateSoundtrackByUUID(arg.Uuid, func(st *repository.Soundtrack) {
+ now := pgtype.Timestamptz{Time: time.Now(), Valid: true}
+ st.CoverFile, st.CoverSource, st.CoverExternalID = arg.File, arg.Source, arg.ExternalID
+ st.CoverManual, st.CoverModified, st.CoverChecked = arg.Manual, now, now
+ })
+}
+
+func (s *Store) MarkCoverChecked(_ context.Context, id uuid.UUID) error {
+ return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) {
+ st.CoverChecked = pgtype.Timestamptz{Time: time.Now(), Valid: true}
+ })
+}
+
+func (s *Store) ClearCover(_ context.Context, id uuid.UUID) error {
+ return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) {
+ st.CoverFile, st.CoverSource, st.CoverExternalID, st.CoverManual = nil, nil, nil, false
+ st.CoverModified, st.CoverChecked = pgtype.Timestamptz{}, pgtype.Timestamptz{}
+ })
+}
+
+func (s *Store) ListCoverFiles(context.Context) ([]string, error) {
+ defer s.lock()()
+ if s.Err != nil {
+ return nil, s.Err
+ }
+ var files []string
+ for _, st := range s.Soundtracks {
+ if st.CoverFile != nil {
+ files = append(files, *st.CoverFile)
+ }
+ }
+ return files, nil
+}
+
func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
defer s.lock()()
if s.Err != nil {
diff --git a/sqlc.yaml b/sqlc.yaml
index 2ad7112..088caaa 100644
--- a/sqlc.yaml
+++ b/sqlc.yaml
@@ -21,6 +21,11 @@ sql:
pointer: true
import: "time"
type: "Time"
+ - db_type: "text"
+ nullable: true
+ go_type:
+ pointer: true
+ type: "string"
- db_type: "pg_catalog.varchar"
nullable: true
go_type: