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

Alternative names (#32):
- PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's
  alternative names (trimmed, deduplicated, without the soundtrack's own name).
- alternative_names is in the soundtrack and statistics responses.
- /find matches them too and shows which name matched.

Covers (#12), stored in COVERS_PATH and off when it is not set:
- GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and
  PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID.
  Uploaded and picked covers are never replaced automatically.
- With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then
  AniList, by name, alternative names and the name without bracketed parts,
  at startup, after a sync and after names change. Search results are ranked
  by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV".
- cover_modified (Unix seconds) is in the soundtrack and statistics responses,
  so clients can cache covers.

The new columns are added to migration 000006, which is not released yet.

Closes #12, closes #32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-13 21:57:18 +02:00
1 parent f79f51ea4c
commit bb8de5548d
39 files changed
+2418 -99

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ main
# Generated with `just openapi` # Generated with `just openapi`
/openapi.json /openapi.json
/openapi-3.0.json /openapi-3.0.json
# Local cover images (COVERS_PATH)
/covers/
+2
View File
@@ -36,6 +36,8 @@ VOLUME /sorted
VOLUME /characters VOLUME /characters
# Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH # 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 ENV PORT=8080
CMD ["./main"] CMD ["./main"]
+6
View File
@@ -135,6 +135,12 @@ main {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.matched-name {
margin: 0.25rem 0 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
#search_term { #search_term {
+27 -9
View File
@@ -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 // search returns the soundtracks matching searchText: exact matches first, then matches
// ignoring spaces and special characters, then matches with the letters in order. // ignoring spaces and special characters, then matches with the letters in order. Each
func search(searchText string, soundtrackData []backend.SoundtrackData) []string { // step tries the soundtrack's own name before its alternative names.
var results []string func search(searchText string, soundtrackData []backend.SoundtrackData) []SearchResult {
var results []SearchResult
seen := make(map[string]bool) seen := make(map[string]bool)
add := func(match func(backend.SoundtrackData) bool) { add := func(match func(backend.SearchName) bool) {
for _, data := range soundtrackData { for _, data := range soundtrackData {
if !seen[data.Original] && match(data) { if seen[data.Original] {
results = append(results, 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 seen[data.Original] = true
} }
} }
@@ -50,8 +68,8 @@ func search(searchText string, soundtrackData []backend.SoundtrackData) []string
lowercase := strings.ToLower(searchText) lowercase := strings.ToLower(searchText)
cleaned := backend.CleanSearchTerm(searchText) cleaned := backend.CleanSearchTerm(searchText)
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) }) add(func(n backend.SearchName) bool { return strings.Contains(n.Lowercase, lowercase) })
add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) }) add(func(n backend.SearchName) bool { return strings.Contains(n.Cleaned, cleaned) })
add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) }) add(func(n backend.SearchName) bool { return backend.MatchesSubsequence(cleaned, n.Cleaned) })
return results return results
} }
+5 -2
View File
@@ -40,10 +40,13 @@ templ SearchForm() {
} }
} }
templ FoundSoundtracks(soundtracks []string) { templ FoundSoundtracks(soundtracks []SearchResult) {
for _, soundtrack := range soundtracks { for _, soundtrack := range soundtracks {
<div class="bg-green-100 p-4 shadow-md rounded-lg mt-6"> <div class="bg-green-100 p-4 shadow-md rounded-lg mt-6">
<p class="soundtrack-text">{ soundtrack }</p> <p class="soundtrack-text">{ soundtrack.Name }</p>
if soundtrack.MatchedName != "" {
<p class="matched-name">Also known as { soundtrack.MatchedName }</p>
}
</div> </div>
} }
} }
+49 -6
View File
@@ -28,8 +28,24 @@ var soundtracks = []string{
"Ys III - Wanderers from Ys (+The Oath in Felghana)", "Ys III - Wanderers from Ys (+The Oath in Felghana)",
} }
func newCache(names []string) *backend.SearchCache { func newCache(names []string, alternatives ...map[string][]string) *backend.SearchCache {
return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil }) 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 { func searchData(t *testing.T) []backend.SoundtrackData {
@@ -62,23 +78,50 @@ func TestSearch(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.query, func(t *testing.T) { 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) { 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) { func TestSearchOrdering(t *testing.T) {
// "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order. // "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) require.GreaterOrEqual(t, len(results), 2)
assert.Equal(t, "MG", results[0]) assert.Equal(t, "MG", results[0])
assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots") 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) { func TestSearchHandler(t *testing.T) {
handler := NewSearchHandler(newCache(soundtracks)) handler := NewSearchHandler(newCache(soundtracks))
req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode())) 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) { 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() rec := httptest.NewRecorder()
NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil)) NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil))
assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Equal(t, http.StatusInternalServerError, rec.Code)
+289
View File
@@ -0,0 +1,289 @@
package backend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"unicode"
)
// coverMatch is a cover found by a source.
type coverMatch struct {
ExternalID string
ImageURL string
}
// coverSource finds cover images in an external database.
type coverSource interface {
// Name is stored as the cover's source, e.g. "steamgriddb".
Name() string
// Search returns the cover for the first of names that the source knows, or nil when
// none matched. An error means the source could not be asked.
Search(ctx context.Context, names []string) (*coverMatch, error)
// ByID returns the cover of the entry with the given ID, or ErrNotFound.
ByID(ctx context.Context, id string) (*coverMatch, error)
}
// getJSON sends req and decodes a JSON response into v. A 404 gives ErrNotFound, other
// failures ErrUpstream.
func getJSON(client *http.Client, req *http.Request, v any) error {
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrNotFound
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %s answered %s", ErrUpstream, req.URL.Host, resp.Status)
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(v); err != nil {
return fmt.Errorf("%w: invalid response from %s: %v", ErrUpstream, req.URL.Host, err)
}
return nil
}
// steamGridDB finds game box art ("grids") on SteamGridDB. It needs an API key.
type steamGridDB struct {
baseURL string
key string
client *http.Client
}
const steamGridDBURL = "https://www.steamgriddb.com/api/v2"
func (s *steamGridDB) Name() string { return "steamgriddb" }
func (s *steamGridDB) get(ctx context.Context, path string, query url.Values, v any) error {
u := s.baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+s.key)
var wrapper struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
if err := getJSON(s.client, req, &wrapper); err != nil {
return err
}
if !wrapper.Success {
return fmt.Errorf("%w: SteamGridDB request failed", ErrUpstream)
}
return json.Unmarshal(wrapper.Data, v)
}
type steamGridDBGame struct {
ID int `json:"id"`
Name string `json:"name"`
}
func (s *steamGridDB) Search(ctx context.Context, names []string) (*coverMatch, error) {
for _, name := range names {
var games []steamGridDBGame
if err := s.get(ctx, "/search/autocomplete/"+url.PathEscape(name), nil, &games); err != nil {
return nil, err
}
if len(games) == 0 {
continue
}
match, err := s.ByID(ctx, strconv.Itoa(bestGame(name, games).ID))
if errors.Is(err, ErrNotFound) {
continue // the game has no box art
}
return match, err
}
return nil, nil
}
func (s *steamGridDB) ByID(ctx context.Context, id string) (*coverMatch, error) {
if _, err := strconv.Atoi(id); err != nil {
return nil, ErrNotFound
}
query := url.Values{
"dimensions": {"600x900"}, // portrait box art
"types": {"static"},
"mimes": {"image/png,image/jpeg"},
"nsfw": {"false"},
"humor": {"false"},
}
var grids []struct {
URL string `json:"url"`
}
if err := s.get(ctx, "/grids/game/"+id, query, &grids); err != nil {
return nil, err
}
if len(grids) == 0 {
return nil, ErrNotFound
}
return &coverMatch{ExternalID: id, ImageURL: grids[0].URL}, nil
}
// bestGame picks the search result for name:
// 1. a result with exactly the words of name, in any order ("Lemmings 3D" → "3D Lemmings");
// 2. else a result that ends with the words of name, as words added in front are usually a
// publisher or series ("Civilization IV" → "Sid Meier's Civilization IV");
// 3. else SteamGridDB's own first result. Words added after the name can be a subtitle, a
// sequel or an expansion ("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), which only
// SteamGridDB's ranking can tell apart.
func bestGame(name string, games []steamGridDBGame) steamGridDBGame {
want := titleWords(name)
for _, game := range games {
if sameWords(titleWords(game.Name), want) {
return game
}
}
for _, game := range games {
if have := titleWords(game.Name); len(have) > len(want) && slices.Equal(have[len(have)-len(want):], want) {
return game
}
}
return games[0]
}
// sameWords reports whether a and b have the same words, in any order.
func sameWords(a, b []string) bool {
return len(a) == len(b) && slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b)))
}
// romanNumerals are read as numbers, so "Octopath Traveler 2" matches "Octopath Traveler II".
var romanNumerals = map[string]string{
"i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10",
}
// titleWords returns the words of a title in lower case, without punctuation.
func titleWords(title string) []string {
words := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
for i, word := range words {
if number, ok := romanNumerals[word]; ok {
words[i] = number
}
}
return words
}
// aniList finds anime covers on AniList. It needs no key. Its search is loose, so a
// result is only used when one of its titles is the searched name.
type aniList struct {
url string
client *http.Client
}
const aniListURL = "https://graphql.anilist.co"
const aniListFields = `id title { romaji english native } coverImage { extraLarge large }`
type aniListMedia struct {
ID int `json:"id"`
Title struct {
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
} `json:"title"`
CoverImage struct {
ExtraLarge string `json:"extraLarge"`
Large string `json:"large"`
} `json:"coverImage"`
}
func (m aniListMedia) match() *coverMatch {
image := m.CoverImage.ExtraLarge
if image == "" {
image = m.CoverImage.Large
}
if image == "" {
return nil
}
return &coverMatch{ExternalID: strconv.Itoa(m.ID), ImageURL: image}
}
func (a *aniList) Name() string { return "anilist" }
func (a *aniList) query(ctx context.Context, query string, variables map[string]any) (*aniListMedia, error) {
body, err := json.Marshal(map[string]any{"query": query, "variables": variables})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
var resp struct {
Data struct {
Media *aniListMedia `json:"Media"`
} `json:"data"`
}
if err := getJSON(a.client, req, &resp); err != nil {
return nil, err
}
if resp.Data.Media == nil {
return nil, ErrNotFound
}
return resp.Data.Media, nil
}
func (a *aniList) Search(ctx context.Context, names []string) (*coverMatch, error) {
for _, name := range names {
media, err := a.query(ctx, `query ($search: String) { Media(search: $search, type: ANIME) { `+aniListFields+` } }`,
map[string]any{"search": name})
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
want := normalizeTitle(name)
for _, title := range []string{media.Title.Romaji, media.Title.English, media.Title.Native} {
if title != "" && normalizeTitle(title) == want {
if match := media.match(); match != nil {
return match, nil
}
}
}
}
return nil, nil
}
func (a *aniList) ByID(ctx context.Context, id string) (*coverMatch, error) {
n, err := strconv.Atoi(id)
if err != nil {
return nil, ErrNotFound
}
media, err := a.query(ctx, `query ($id: Int) { Media(id: $id) { `+aniListFields+` } }`, map[string]any{"id": n})
if err != nil {
return nil, err
}
if match := media.match(); match != nil {
return match, nil
}
return nil, ErrNotFound
}
// normalizeTitle keeps only the letters and digits of a title, in lower case, so
// "Cowboy Bebop: The Movie" and "cowboy bebop the movie" compare equal.
func normalizeTitle(title string) string {
var b strings.Builder
for _, r := range strings.ToLower(title) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
+410
View File
@@ -0,0 +1,410 @@
package backend
import (
"cmp"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"go.uber.org/zap"
)
const (
// MaxCoverBytes is the largest cover image that is stored.
MaxCoverBytes = 10 << 20
// coverRecheckAfter is how long a soundtrack without a match waits before the job
// searches for it again. Changing its alternative names makes it search right away.
coverRecheckAfter = 30 * 24 * time.Hour
// coverRequestInterval spaces out requests to the external APIs, to stay well inside
// their rate limits.
coverRequestInterval = time.Second
)
// SourceUpload is the source of a cover that was uploaded.
const SourceUpload = "upload"
// coverTypes are the image types stored as covers, by MIME type, with their file extension.
var coverTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
// CoverService stores cover images for soundtracks in a folder and finds them in external
// databases in the background.
type CoverService struct {
q repository.Querier
dir string
sources []coverSource
client *http.Client
// interval spaces out external requests; tests set it to 0.
interval time.Duration
trigger chan struct{}
// writeMu keeps the job from replacing a cover that is being set by hand.
writeMu sync.Mutex
}
// CoverOptions configures where covers are searched.
type CoverOptions struct {
// SteamGridDBKey turns on SteamGridDB and the background job.
SteamGridDBKey string
// SteamGridDBURL and AniListURL replace the real APIs, for tests.
SteamGridDBURL string
AniListURL string
}
// NewCoverService stores covers in dir. With a SteamGridDB API key the background job
// searches SteamGridDB, then AniList; without one there is no background job, but covers
// can still be uploaded or picked by ID.
func NewCoverService(q repository.Querier, dir string, opts CoverOptions) (*CoverService, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
c := &CoverService{
q: q,
dir: dir,
client: client,
interval: coverRequestInterval,
trigger: make(chan struct{}, 1),
}
if opts.SteamGridDBKey != "" {
c.sources = append(c.sources, &steamGridDB{
baseURL: cmp.Or(opts.SteamGridDBURL, steamGridDBURL), key: opts.SteamGridDBKey, client: client,
})
}
c.sources = append(c.sources, &aniList{url: cmp.Or(opts.AniListURL, aniListURL), client: client})
return c, nil
}
// SearchesAutomatically reports whether the background job runs.
func (c *CoverService) SearchesAutomatically() bool {
return c.source("steamgriddb") != nil
}
func (c *CoverService) source(name string) coverSource {
for _, s := range c.sources {
if s.Name() == name {
return s
}
}
return nil
}
// Path returns the cover image of an active soundtrack, or ErrNotFound.
func (c *CoverService) Path(ctx context.Context, id uuid.UUID) (string, error) {
st, err := c.q.GetSoundtrackByUUID(ctx, id)
if err != nil {
return "", notFound(err)
}
if st.CoverFile == nil {
return "", ErrNotFound
}
return filepath.Join(c.dir, *st.CoverFile), nil
}
// Upload stores data as the soundtrack's cover. It is never replaced by the background job.
func (c *CoverService) Upload(ctx context.Context, id uuid.UUID, data []byte) error {
if _, err := c.activeSoundtrack(ctx, id); err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.store(ctx, id, data, SourceUpload, nil, true)
}
// FromSource stores the cover of entry externalID in source (steamgriddb or anilist) as
// the soundtrack's cover. It is never replaced by the background job.
func (c *CoverService) FromSource(ctx context.Context, id uuid.UUID, source, externalID string) error {
s := c.source(source)
if s == nil {
return fmt.Errorf("%w: unknown or disabled cover source %q", ErrInvalidName, source)
}
if _, err := c.activeSoundtrack(ctx, id); err != nil {
return err
}
match, err := s.ByID(ctx, externalID)
if err != nil {
return err
}
data, err := c.download(ctx, match.ImageURL)
if err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.store(ctx, id, data, source, &match.ExternalID, true)
}
// Remove deletes the soundtrack's cover, so the background job searches for a new one.
func (c *CoverService) Remove(ctx context.Context, id uuid.UUID) error {
st, err := c.activeSoundtrack(ctx, id)
if err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := c.q.ClearCover(ctx, id); err != nil {
return err
}
if st.CoverFile != nil {
c.removeFile(*st.CoverFile)
}
c.Trigger()
return nil
}
func (c *CoverService) activeSoundtrack(ctx context.Context, id uuid.UUID) (repository.Soundtrack, error) {
st, err := c.q.GetSoundtrackByUUID(ctx, id)
return st, notFound(err)
}
// store writes data as the soundtrack's cover file and records it. Call with writeMu held.
func (c *CoverService) store(ctx context.Context, id uuid.UUID, data []byte, source string, externalID *string, manual bool) error {
ext, err := coverExtension(data)
if err != nil {
return err
}
name := id.String() + ext
tmp, err := os.CreateTemp(c.dir, ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmp.Name(), filepath.Join(c.dir, name)); err != nil {
return err
}
// A cover of another image type leaves a file with another extension behind.
for _, other := range coverTypes {
if other != ext {
c.removeFile(id.String() + other)
}
}
return c.q.SetCover(ctx, repository.SetCoverParams{
Uuid: id, File: &name, Source: &source, ExternalID: externalID, Manual: manual,
})
}
func (c *CoverService) removeFile(name string) {
if err := os.Remove(filepath.Join(c.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
logging.GetLogger().Warn("Failed to remove cover", zap.String("file", name), zap.Error(err))
}
}
// coverExtension checks that data is a supported image and returns its file extension.
func coverExtension(data []byte) (string, error) {
if len(data) > MaxCoverBytes {
return "", fmt.Errorf("%w: the image is larger than %d MB", ErrInvalidImage, MaxCoverBytes>>20)
}
ext, ok := coverTypes[http.DetectContentType(data)]
if !ok {
return "", fmt.Errorf("%w: only JPEG, PNG and WebP images are supported", ErrInvalidImage)
}
return ext, nil
}
// download fetches a cover image.
func (c *CoverService) download(ctx context.Context, imageURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return nil, fmt.Errorf("%w: invalid image URL: %v", ErrUpstream, err)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: image download answered %s", ErrUpstream, resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, MaxCoverBytes+1))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
}
if _, err := coverExtension(data); err != nil {
return nil, fmt.Errorf("%w: %v", ErrUpstream, err)
}
return data, nil
}
// Trigger asks the background job to look for missing covers. It never blocks.
func (c *CoverService) Trigger() {
select {
case c.trigger <- struct{}{}:
default: // a pass is already waiting
}
}
// Run searches for missing covers now and each time Trigger is called, until ctx ends.
// It does nothing when automatic search is off.
func (c *CoverService) Run(ctx context.Context) {
if !c.SearchesAutomatically() {
return
}
c.Trigger()
for {
select {
case <-ctx.Done():
return
case <-c.trigger:
if err := c.searchMissing(ctx); err != nil && ctx.Err() == nil {
logging.GetLogger().Warn("Cover search stopped, trying again after the next sync", zap.Error(err))
}
}
}
}
// searchMissing finds covers for active soundtracks without one. It stops at the first
// error from an external API, so an unreachable API is not asked again for every soundtrack.
func (c *CoverService) searchMissing(ctx context.Context) error {
c.removeOrphans(ctx)
rows, err := c.q.ListSoundtracksNeedingCover(ctx,
pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter), Valid: true})
if err != nil {
return err
}
logger := logging.GetLogger()
found := 0
for _, row := range rows {
match, source, err := c.search(ctx, coverSearchNames(row.SoundtrackName, row.AlternativeNames))
if err != nil {
return err
}
if match == nil {
if err := c.q.MarkCoverChecked(ctx, row.Uuid); err != nil {
return err
}
continue
}
if err := c.wait(ctx); err != nil {
return err
}
data, err := c.download(ctx, match.ImageURL)
if err != nil {
return err
}
stored, err := c.storeFound(ctx, row.Uuid, data, source, match.ExternalID)
if err != nil {
return err
}
if stored {
found++
logger.Info("Cover found", zap.String("soundtrack", row.SoundtrackName),
zap.String("source", source), zap.String("external_id", match.ExternalID))
}
}
if len(rows) > 0 {
logger.Info("Cover search finished", zap.Int("searched", len(rows)), zap.Int("found", found))
}
return nil
}
// storeFound stores a cover found by the job, unless one was set by hand meanwhile.
func (c *CoverService) storeFound(ctx context.Context, id uuid.UUID, data []byte, source, externalID string) (bool, error) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
st, err := c.q.GetSoundtrackByUUID(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil // removed meanwhile
}
if err != nil {
return false, err
}
if st.CoverManual || st.CoverFile != nil {
return false, nil
}
return true, c.store(ctx, id, data, source, &externalID, false)
}
// search asks the sources in order and returns the first match.
func (c *CoverService) search(ctx context.Context, names []string) (*coverMatch, string, error) {
for _, s := range c.sources {
if err := c.wait(ctx); err != nil {
return nil, "", err
}
match, err := s.Search(ctx, names)
if err != nil {
return nil, "", fmt.Errorf("%s: %w", s.Name(), err)
}
if match != nil {
return match, s.Name(), nil
}
}
return nil, "", nil
}
func (c *CoverService) wait(ctx context.Context) error {
if c.interval <= 0 {
return ctx.Err()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.interval):
return nil
}
}
// removeOrphans deletes cover files that no soundtrack uses any more, for example after
// the library was reset.
func (c *CoverService) removeOrphans(ctx context.Context) {
files, err := c.q.ListCoverFiles(ctx)
if err != nil {
logging.GetLogger().Warn("Failed to list cover files", zap.Error(err))
return
}
used := make(map[string]bool, len(files))
for _, f := range files {
used[f] = true
}
entries, err := os.ReadDir(c.dir)
if err != nil {
logging.GetLogger().Warn("Failed to read the covers folder", zap.Error(err))
return
}
for _, entry := range entries {
if !entry.IsDir() && !used[entry.Name()] {
c.removeFile(entry.Name())
}
}
}
var bracketed = regexp.MustCompile(`\s*[(\[][^)\]]*[)\]]`)
// coverSearchNames returns the names to search for: the soundtrack's name, its alternative
// names, and the name without parts in brackets ("Lemmings (SNES + Mega Drive)" →
// "Lemmings"), without duplicates.
func coverSearchNames(name string, alternatives []string) []string {
var names []string
seen := map[string]bool{}
for _, n := range append(append([]string{name}, alternatives...), bracketed.ReplaceAllString(name, "")) {
n = strings.TrimSpace(n)
if n != "" && !seen[strings.ToLower(n)] {
seen[strings.ToLower(n)] = true
names = append(names, n)
}
}
return names
}
@@ -0,0 +1,98 @@
//go:build integration
package backend
import (
"context"
"path/filepath"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/pgtest"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAlternativeNamesAndCoversWithPostgres checks the alternative name and cover queries
// against a real database.
func TestAlternativeNamesAndCoversWithPostgres(t *testing.T) {
ctx := context.Background()
q := repository.New(pgtest.NewMigratedDatabase(t).Pool)
s := NewSyncService(q, copyTestMusic(t), nil)
require.True(t, s.Start(false))
s.Wait()
library := NewLibraryService(q)
soundtracks, err := library.Soundtracks(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(soundtracks), 2)
first, second := soundtracks[0], soundtracks[1]
assert.Equal(t, []string{}, first.AlternativeNames)
assert.Nil(t, first.CoverModified)
// Alternative names, in every soundtrack response.
updated, err := library.SetAlternativeNames(ctx, first.Uuid, []string{" Alt ", "alt", first.Name, "Другое"})
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, updated.AlternativeNames)
stats, err := NewStatisticsService(q).AllSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, stats[0].AlternativeNames)
never, err := NewStatisticsService(q).NeverPlayedSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, never[0].AlternativeNames)
search, err := NewMusicService(q, t.TempDir()).SearchSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"Alt", "Другое"}, search[0].AlternativeNames)
// Covers.
c, err := NewCoverService(q, filepath.Join(t.TempDir(), "covers"), CoverOptions{})
require.NoError(t, err)
require.NoError(t, c.Upload(ctx, first.Uuid, pngBytes(t)))
withCover, err := library.Soundtrack(ctx, first.Uuid)
require.NoError(t, err)
require.NotNil(t, withCover.CoverModified)
stats, err = NewStatisticsService(q).AllSoundtracks(ctx)
require.NoError(t, err)
assert.Equal(t, withCover.CoverModified, stats[0].CoverModified)
needing, err := q.ListSoundtracksNeedingCover(ctx, nowPlus(0))
require.NoError(t, err)
for _, row := range needing {
assert.NotEqual(t, first.Uuid, row.Uuid, "a soundtrack with a cover is not searched")
}
require.NoError(t, q.MarkCoverChecked(ctx, second.Uuid))
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
require.NoError(t, err)
for _, row := range needing {
assert.NotEqual(t, second.Uuid, row.Uuid, "searched recently")
}
// New names make a soundtrack without a cover searched again.
_, err = library.SetAlternativeNames(ctx, second.Uuid, []string{"Other"})
require.NoError(t, err)
needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60))
require.NoError(t, err)
var found bool
for _, row := range needing {
if row.Uuid == second.Uuid {
found = true
assert.Equal(t, []string{"Other"}, row.AlternativeNames)
}
}
assert.True(t, found)
files, err := q.ListCoverFiles(ctx)
require.NoError(t, err)
assert.Equal(t, []string{first.Uuid.String() + ".png"}, files)
require.NoError(t, c.Remove(ctx, first.Uuid))
files, err = q.ListCoverFiles(ctx)
require.NoError(t, err)
assert.Empty(t, files)
}
func nowPlus(seconds int) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: time.Now().Add(time.Duration(seconds) * time.Second), Valid: true}
}
+425
View File
@@ -0,0 +1,425 @@
package backend
import (
"bytes"
"context"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"music-server/internal/db/repository"
"music-server/internal/testutil/fakedb"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func pngBytes(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3))))
return buf.Bytes()
}
// coverAPIs fakes SteamGridDB, AniList and the image host.
type coverAPIs struct {
t *testing.T
server *httptest.Server
// games maps search terms to SteamGridDB game IDs; 404 has no grids.
games map[string]int
// anime maps search terms to AniList titles; the ID is always 42.
anime map[string]string
// failSteamGridDB makes SteamGridDB answer 500.
failSteamGridDB bool
mu sync.Mutex
searches []string
}
func newCoverAPIs(t *testing.T) *coverAPIs {
a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}}
image := pngBytes(t)
mux := http.NewServeMux()
reply := func(w http.ResponseWriter, data any) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
}
mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) {
a.record("sgdb:" + r.PathValue("term"))
if a.failSteamGridDB {
http.Error(w, "down", http.StatusInternalServerError)
return
}
assert.Equal(t, "Bearer key", r.Header.Get("Authorization"))
if id, ok := a.games[r.PathValue("term")]; ok {
reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}})
return
}
reply(w, []any{})
})
mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "600x900", r.URL.Query().Get("dimensions"))
if r.PathValue("id") == "404" {
reply(w, []any{})
return
}
reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}})
})
mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Variables struct {
Search string `json:"search"`
ID int `json:"id"`
} `json:"variables"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
a.record("anilist:" + req.Variables.Search)
title, ok := a.anime[req.Variables.Search]
if req.Variables.ID == 42 {
title, ok = "By ID", true
}
if !ok {
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{
"id": 42, "title": map[string]any{"romaji": title},
"coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"},
}}})
})
mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("name") == "text.png" {
_, _ = w.Write([]byte("not an image"))
return
}
_, _ = w.Write(image)
})
a.server = httptest.NewServer(mux)
t.Cleanup(a.server.Close)
return a
}
func (a *coverAPIs) record(search string) {
a.mu.Lock()
defer a.mu.Unlock()
a.searches = append(a.searches, search)
}
func (a *coverAPIs) searched() []string {
a.mu.Lock()
defer a.mu.Unlock()
return append([]string(nil), a.searches...)
}
func (a *coverAPIs) options(withKey bool) CoverOptions {
opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"}
if withKey {
opts.SteamGridDBKey = "key"
}
return opts
}
func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService {
t.Helper()
c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts)
require.NoError(t, err)
c.interval = 0
return c
}
func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack {
t.Helper()
st, ok := store.Soundtrack(name)
require.True(t, ok)
return st
}
func TestCoverSearchFindsCovers(t *testing.T) {
apis := newCoverAPIs(t)
apis.games["Chrono Trigger"] = 1
apis.games["FF7"] = 7
apis.games["Lemmings"] = 3
apis.anime["Cowboy Bebop"] = "Cowboy Bebop"
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.searchMissing(context.Background()))
chrono := coverOf(t, store, "Chrono Trigger")
require.NotNil(t, chrono.CoverFile)
assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile)
assert.Equal(t, "steamgriddb", *chrono.CoverSource)
assert.Equal(t, "1", *chrono.CoverExternalID)
assert.False(t, chrono.CoverManual)
assert.True(t, chrono.CoverModified.Valid)
assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile))
assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name")
assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets")
bebop := coverOf(t, store, "Cowboy Bebop")
assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing")
unknown := coverOf(t, store, "Unknown")
assert.Nil(t, unknown.CoverFile)
assert.True(t, unknown.CoverChecked.Valid, "marked as searched")
assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first")
_ = ff
// Searched soundtracks are not searched again until the recheck time.
before := len(apis.searched())
require.NoError(t, c.searchMissing(context.Background()))
assert.Len(t, apis.searched(), before)
}
func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) {
apis := newCoverAPIs(t)
apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children"
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.searchMissing(context.Background()))
assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used")
}
func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) {
apis := newCoverAPIs(t)
apis.failSteamGridDB = true
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"})
c := newCovers(t, store, apis.options(true))
err := c.searchMissing(context.Background())
assert.ErrorIs(t, err, ErrUpstream)
assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried")
assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time")
}
func TestCoverSearchSkipsManualCovers(t *testing.T) {
apis := newCoverAPIs(t)
apis.games["Chrono Trigger"] = 1
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op
require.NoError(t, c.searchMissing(context.Background()))
assert.Empty(t, apis.searched())
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
// A cover set by hand while the job was searching is kept.
stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1")
require.NoError(t, err)
assert.False(t, stored)
assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource)
}
func TestCoverSearchAgainAfterRecheckTime(t *testing.T) {
apis := newCoverAPIs(t)
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old",
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}})
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent",
CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}})
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.searchMissing(context.Background()))
assert.Contains(t, apis.searched(), "sgdb:Old")
assert.NotContains(t, apis.searched(), "sgdb:Recent")
}
func TestCoverUpload(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, CoverOptions{})
ctx := context.Background()
jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...)
require.NoError(t, c.Upload(ctx, st.Uuid, jpeg))
got := coverOf(t, store, "Game")
assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile)
assert.True(t, got.CoverManual)
assert.Nil(t, got.CoverExternalID)
path, err := c.Path(ctx, st.Uuid)
require.NoError(t, err)
data, _ := os.ReadFile(path)
assert.Equal(t, jpeg, data)
// A PNG replaces the JPEG, and its old file is removed.
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile)
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg"))
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("<svg></svg>")), ErrInvalidImage)
assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage)
assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound)
entries, _ := os.ReadDir(c.dir)
assert.Len(t, entries, 1, "no temporary files are left behind")
}
func TestCoverFromSource(t *testing.T) {
apis := newCoverAPIs(t)
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
ctx := context.Background()
c := newCovers(t, store, apis.options(true))
require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5"))
got := coverOf(t, store, "Game")
assert.Equal(t, "steamgriddb", *got.CoverSource)
assert.Equal(t, "5", *got.CoverExternalID)
assert.True(t, got.CoverManual, "a picked cover is never replaced by the job")
require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42"))
assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource)
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids")
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound)
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound)
assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName)
assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound)
withoutKey := newCovers(t, store, apis.options(false))
assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName)
require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key")
}
func TestCoverDownloadMustBeAnImage(t *testing.T) {
apis := newCoverAPIs(t)
c := newCovers(t, fakedb.New(), apis.options(true))
_, err := c.download(context.Background(), apis.server.URL+"/img/text.png")
assert.ErrorIs(t, err, ErrUpstream)
}
func TestCoverRemove(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, CoverOptions{})
ctx := context.Background()
require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t)))
require.NoError(t, c.Remove(ctx, st.Uuid))
got := coverOf(t, store, "Game")
assert.Nil(t, got.CoverFile)
assert.False(t, got.CoverManual)
assert.False(t, got.CoverChecked.Valid, "the job searches for it again")
assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png"))
_, err := c.Path(ctx, st.Uuid)
assert.ErrorIs(t, err, ErrNotFound)
assert.Len(t, c.trigger, 1, "the job is asked to search")
}
func TestCoverRemoveOrphans(t *testing.T) {
store := fakedb.New()
st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, CoverOptions{})
require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t)))
writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset")
c.removeOrphans(context.Background())
entries, _ := os.ReadDir(c.dir)
require.Len(t, entries, 1)
assert.Equal(t, st.Uuid.String()+".png", entries[0].Name())
}
func TestCoverRunSearchesUntilStopped(t *testing.T) {
apis := newCoverAPIs(t)
apis.games["Game"] = 1
store := fakedb.New()
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"})
c := newCovers(t, store, apis.options(true))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond,
"searches when started")
store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack
c.Trigger()
assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 },
time.Second, 10*time.Millisecond, "searches again when triggered")
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not stop")
}
}
func TestCoverRunWithoutKeyDoesNothing(t *testing.T) {
c := newCovers(t, fakedb.New(), CoverOptions{})
assert.False(t, c.SearchesAutomatically())
done := make(chan struct{})
go func() { c.Run(context.Background()); close(done) }()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run should return at once without a SteamGridDB key")
}
}
func TestCoverSearchNames(t *testing.T) {
assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"},
coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"}))
assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil))
assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil))
}
func TestNormalizeTitle(t *testing.T) {
assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie"))
assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII"))
}
func TestBestGame(t *testing.T) {
games := func(names ...string) []steamGridDBGame {
result := make([]steamGridDBGame, len(names))
for i, name := range names {
result[i] = steamGridDBGame{ID: i + 1, Name: name}
}
return result
}
tests := []struct {
search string
games []steamGridDBGame
want string
}{
// Real SteamGridDB results, in their order.
{"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV",
"Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"},
{"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"},
{"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"},
{"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"},
{"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"},
// No result has every word: SteamGridDB's first result.
{"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"},
{"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"},
{"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search)
}
}
+4
View File
@@ -11,6 +11,10 @@ var (
ErrNotFound = errors.New("not found") ErrNotFound = errors.New("not found")
// ErrInvalidName is returned when a requested file name is not allowed. // ErrInvalidName is returned when a requested file name is not allowed.
ErrInvalidName = errors.New("invalid name") 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 is returned when an external service could not be reached.
ErrUpstream = errors.New("upstream service unavailable") ErrUpstream = errors.New("upstream service unavailable")
) )
+68 -10
View File
@@ -3,23 +3,30 @@ package backend
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"music-server/internal/db/repository" "music-server/internal/db/repository"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
) )
// Soundtrack is a soundtrack as returned by the API. // Soundtrack is a soundtrack as returned by the API.
type Soundtrack struct { type Soundtrack struct {
Uuid uuid.UUID `json:"uuid"` Uuid uuid.UUID `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
TimesPlayed int32 `json:"times_played"` AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
LastPlayed *time.Time `json:"last_played,omitempty"` TimesPlayed int32 `json:"times_played"`
Added time.Time `json:"added"` 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. // SoundtrackWithSongList is a soundtrack with all its songs.
type SoundtrackWithSongList struct { type SoundtrackWithSongList struct {
Soundtrack Soundtrack
@@ -48,14 +55,65 @@ func NewLibraryService(q repository.Querier) *LibraryService {
func toSoundtrack(s repository.Soundtrack) Soundtrack { func toSoundtrack(s repository.Soundtrack) Soundtrack {
return Soundtrack{ return Soundtrack{
Uuid: s.Uuid, Uuid: s.Uuid,
Name: s.SoundtrackName, Name: s.SoundtrackName,
TimesPlayed: s.TimesPlayed, AlternativeNames: nonNil(s.AlternativeNames),
LastPlayed: s.LastPlayed, TimesPlayed: s.TimesPlayed,
Added: s.Added, 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 { func notFound(err error) error {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound return ErrNotFound
+45 -1
View File
@@ -10,6 +10,7 @@ import (
"music-server/internal/testutil/fakedb" "music-server/internal/testutil/fakedb"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -24,11 +25,54 @@ func TestLibrarySoundtracks(t *testing.T) {
soundtracks, err := NewLibraryService(store).Soundtracks(context.Background()) soundtracks, err := NewLibraryService(store).Soundtracks(context.Background())
require.NoError(t, err) require.NoError(t, err)
require.Len(t, soundtracks, 2) 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, b.Uuid, soundtracks[1].Uuid)
assert.Equal(t, int32(3), soundtracks[1].TimesPlayed) 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) { func TestLibrarySoundtrackWithSongs(t *testing.T) {
store := fakedb.New() store := fakedb.New()
st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A") st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A")
+13
View File
@@ -348,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) {
return names, nil 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. // SoundtrackNamesRandom returns all soundtrack names in random order.
func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) { func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) {
names, err := m.SoundtrackNames(ctx) names, err := m.SoundtrackNames(ctx)
+26 -10
View File
@@ -6,23 +6,40 @@ import (
"sync" "sync"
) )
// SoundtrackData holds precomputed search data for a soundtrack // SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search.
type SoundtrackData struct { type SearchSoundtrack struct {
Name string
AlternativeNames []string
}
// SearchName holds a name prepared for matching.
type SearchName struct {
Original string Original string
Lowercase string Lowercase string
Cleaned 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 { type SearchCache struct {
load func(context.Context) ([]string, error) load func(context.Context) ([]SearchSoundtrack, error)
mu sync.Mutex mu sync.Mutex
cache []SoundtrackData cache []SoundtrackData
} }
// NewSearchCache creates a SearchCache that gets the soundtrack names from load. // NewSearchCache creates a SearchCache that gets the soundtracks from load.
func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache { func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache {
return &SearchCache{load: load} return &SearchCache{load: load}
} }
@@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error {
} }
cache := make([]SoundtrackData, len(soundtracks)) cache := make([]SoundtrackData, len(soundtracks))
for i, soundtrack := range soundtracks { for i, soundtrack := range soundtracks {
cache[i] = SoundtrackData{ cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)}
Original: soundtrack, for _, name := range soundtrack.AlternativeNames {
Lowercase: strings.ToLower(soundtrack), cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name))
Cleaned: CleanSearchTerm(soundtrack),
} }
} }
s.cache = cache s.cache = cache
+9 -6
View File
@@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) {
} }
func TestSearchCache(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 calls := 0
cache := NewSearchCache(func(context.Context) ([]string, error) { cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) {
calls++ calls++
return names, nil return soundtracks, nil
}) })
ctx := context.Background() ctx := context.Background()
data, err := cache.Get(ctx) data, err := cache.Get(ctx)
require.NoError(t, err) 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) _, _ = cache.Get(ctx)
assert.Equal(t, 1, calls, "data is cached") assert.Equal(t, 1, calls, "data is cached")
names = []string{"Chrono Trigger"} soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}}
require.NoError(t, cache.Reload(ctx)) require.NoError(t, cache.Reload(ctx))
data, _ = cache.Get(ctx) data, _ = cache.Get(ctx)
assert.Equal(t, "Chrono Trigger", data[0].Original) assert.Equal(t, "Chrono Trigger", data[0].Original)
} }
func TestSearchCacheLoadError(t *testing.T) { 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()) _, err := cache.Get(context.Background())
assert.Error(t, err) assert.Error(t, err)
} }
+6
View File
@@ -15,8 +15,10 @@ type SoundtrackWithSongs struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"` 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"` Songs []SongInfoForStats `json:"songs"`
} }
@@ -74,8 +76,10 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid, SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
AlternativeNames: nonNil(row.AlternativeNames),
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
SoundtrackLastPlayed: row.SoundtrackLastPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed,
CoverModified: unixSeconds(row.CoverModified),
Songs: parseSongs(row.Songs), Songs: parseSongs(row.Songs),
}) })
} }
@@ -144,7 +148,9 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
SoundtrackID: row.SoundtrackID, SoundtrackID: row.SoundtrackID,
SoundtrackUuid: row.SoundtrackUuid, SoundtrackUuid: row.SoundtrackUuid,
SoundtrackName: row.SoundtrackName, SoundtrackName: row.SoundtrackName,
AlternativeNames: nonNil(row.AlternativeNames),
SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackPlayed: row.SoundtrackPlayed,
CoverModified: unixSeconds(row.CoverModified),
Songs: parseSongs(row.Songs), Songs: parseSongs(row.Songs),
}) })
} }
+31
View File
@@ -403,3 +403,34 @@ func TestResetDB(t *testing.T) {
func TestFormatDuration(t *testing.T) { func TestFormatDuration(t *testing.T) {
assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second)) 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)
}
+10
View File
@@ -31,6 +31,14 @@ type Config struct {
CharactersPath string CharactersPath string
SoundCheckPath string SoundCheckPath string
FrontendPath 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 LogLevel string
LogJSON bool LogJSON bool
@@ -65,6 +73,8 @@ func load(getenv func(string) string) (Config, error) {
CharactersPath: getenv("CHARACTERS_PATH"), CharactersPath: getenv("CHARACTERS_PATH"),
SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"), SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"),
FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"), FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"),
CoversPath: getenv("COVERS_PATH"),
SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"),
LogLevel: withDefault(getenv("LOG_LEVEL"), "info"), LogLevel: withDefault(getenv("LOG_LEVEL"), "info"),
LogJSON: getenv("LOG_JSON") == "true", LogJSON: getenv("LOG_JSON") == "true",
APIPassword: getenv("API_PASSWORD"), APIPassword: getenv("API_PASSWORD"),
+12
View File
@@ -33,6 +33,18 @@ func TestLoadDefaults(t *testing.T) {
assert.Empty(t, cfg.TrustedProxies) assert.Empty(t, cfg.TrustedProxies)
assert.Empty(t, cfg.CORSAllowedOrigins) assert.Empty(t, cfg.CORSAllowedOrigins)
assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort) 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) { func TestLoadMetricsPort(t *testing.T) {
@@ -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_song;
DROP TABLE queue; DROP TABLE queue;
ALTER TABLE soundtrack DROP COLUMN fingerprint; ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -27,3 +27,17 @@ CREATE TABLE queue_song (
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (queue_uuid, song_no) 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;
+41
View File
@@ -65,3 +65,44 @@ SELECT uuid FROM soundtrack WHERE id = $1;
-- name: AddSoundtrackPlayedByUUID :exec -- name: AddSoundtrackPlayedByUUID :exec
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1; 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;
+12
View File
@@ -5,6 +5,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -33,6 +35,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -95,6 +99,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.added, g.added,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -121,6 +127,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -148,6 +156,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -188,6 +198,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
+19 -12
View File
@@ -59,18 +59,25 @@ type SongList struct {
} }
type Soundtrack struct { type Soundtrack struct {
ID int32 `json:"id"` ID int32 `json:"id"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
Added time.Time `json:"added"` Added time.Time `json:"added"`
Deleted *time.Time `json:"deleted"` Deleted *time.Time `json:"deleted"`
LastChanged *time.Time `json:"last_changed"` LastChanged *time.Time `json:"last_changed"`
Path string `json:"path"` Path string `json:"path"`
TimesPlayed int32 `json:"times_played"` TimesPlayed int32 `json:"times_played"`
LastPlayed *time.Time `json:"last_played"` LastPlayed *time.Time `json:"last_played"`
NumberOfSongs int32 `json:"number_of_songs"` NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"` Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"` Uuid uuid.UUID `json:"uuid"`
Fingerprint string `json:"fingerprint"` 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 { type Vgmq struct {
+10
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
type Querier interface { type Querier interface {
@@ -23,6 +24,8 @@ type Querier interface {
AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (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 ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error ClearSoundtracks(ctx context.Context) error
@@ -73,14 +76,21 @@ type Querier interface {
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) 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. // The song columns are NULL when the song has been removed from the library since.
ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error)
ListSessions(ctx context.Context) ([]Session, 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 MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, 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 SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error
SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error
SetSoundtrackDeletionDate(ctx context.Context) error SetSoundtrackDeletionDate(ctx context.Context) error
+187 -4
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
@@ -29,6 +30,19 @@ func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UU
return err 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 const clearSoundtracks = `-- name: ClearSoundtracks :exec
DELETE FROM soundtrack DELETE FROM soundtrack
` `
@@ -39,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
} }
const findAllSoundtracks = `-- name: FindAllSoundtracks :many 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 FROM soundtrack
WHERE deleted IS NULL WHERE deleted IS NULL
ORDER BY soundtrack_name ORDER BY soundtrack_name
@@ -67,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -79,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
} }
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many 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 FROM soundtrack
ORDER BY soundtrack_name ORDER BY soundtrack_name
` `
@@ -106,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -129,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
} }
const getSoundtrackById = `-- name: GetSoundtrackById :one 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 FROM soundtrack
WHERE id = $1 WHERE id = $1
AND deleted IS NULL AND deleted IS NULL
@@ -151,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
) )
return i, err return i, err
} }
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one 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 FROM soundtrack
WHERE uuid = $1 WHERE uuid = $1
AND deleted IS NULL AND deleted IS NULL
@@ -178,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
&i.Hash, &i.Hash,
&i.Uuid, &i.Uuid,
&i.Fingerprint, &i.Fingerprint,
&i.AlternativeNames,
&i.CoverFile,
&i.CoverSource,
&i.CoverExternalID,
&i.CoverManual,
&i.CoverModified,
&i.CoverChecked,
) )
return i, err return i, err
} }
@@ -253,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
return err 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 const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[])) 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 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 const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL
` `
+73 -36
View File
@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
const countLibrary = `-- name: CountLibrary :one const countLibrary = `-- name: CountLibrary :one
@@ -37,6 +38,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -59,12 +62,14 @@ ORDER BY g.soundtrack_name
` `
type GetAllSoundtracksWithSongsRow struct { type GetAllSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Every soundtrack with its songs, by name // Every soundtrack with its songs, by name
@@ -82,6 +87,8 @@ func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSound
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -101,6 +108,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -123,12 +132,14 @@ LIMIT $1
` `
type GetLastPlayedSoundtracksRow struct { type GetLastPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Last played soundtracks (most recently played) // Last played soundtracks (most recently played)
@@ -146,6 +157,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -223,6 +236,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -246,12 +261,14 @@ LIMIT $1
` `
type GetLeastPlayedSoundtracksWithSongsRow struct { type GetLeastPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Least played soundtracks with their songs // Least played soundtracks with their songs
@@ -269,6 +286,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -346,6 +365,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -369,12 +390,14 @@ LIMIT $1
` `
type GetMostPlayedSoundtracksWithSongsRow struct { type GetMostPlayedSoundtracksWithSongsRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
Songs []byte `json:"songs"`
} }
// Most played soundtracks with their songs // Most played soundtracks with their songs
@@ -392,6 +415,8 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -411,6 +436,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.added, g.added,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -432,12 +459,14 @@ ORDER BY g.soundtrack_name
` `
type GetNeverPlayedSoundtracksRow struct { type GetNeverPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
Added time.Time `json:"added"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` CoverModified pgtype.Timestamptz `json:"cover_modified"`
Added time.Time `json:"added"`
Songs []byte `json:"songs"`
} }
// Soundtracks that have never been played (times_played = 0) // Soundtracks that have never been played (times_played = 0)
@@ -455,6 +484,8 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay
&i.SoundtrackUuid, &i.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.Added, &i.Added,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
@@ -474,6 +505,8 @@ SELECT
g.uuid as soundtrack_uuid, g.uuid as soundtrack_uuid,
g.soundtrack_name, g.soundtrack_name,
g.times_played as soundtrack_played, g.times_played as soundtrack_played,
g.alternative_names,
g.cover_modified,
g.last_played as soundtrack_last_played, g.last_played as soundtrack_last_played,
COALESCE( COALESCE(
json_agg( json_agg(
@@ -496,12 +529,14 @@ LIMIT $1
` `
type GetOldestPlayedSoundtracksRow struct { type GetOldestPlayedSoundtracksRow struct {
SoundtrackID int32 `json:"soundtrack_id"` SoundtrackID int32 `json:"soundtrack_id"`
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
SoundtrackName string `json:"soundtrack_name"` SoundtrackName string `json:"soundtrack_name"`
SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackPlayed int32 `json:"soundtrack_played"`
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` AlternativeNames []string `json:"alternative_names"`
Songs []byte `json:"songs"` 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) // 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.SoundtrackUuid,
&i.SoundtrackName, &i.SoundtrackName,
&i.SoundtrackPlayed, &i.SoundtrackPlayed,
&i.AlternativeNames,
&i.CoverModified,
&i.SoundtrackLastPlayed, &i.SoundtrackLastPlayed,
&i.Songs, &i.Songs,
); err != nil { ); err != nil {
+3 -1
View File
@@ -146,8 +146,10 @@ func apiError(err error) error {
switch { switch {
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound): case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
return huma.Error404NotFound(err.Error()) 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()) return huma.Error400BadRequest(err.Error())
case errors.Is(err, backend.ErrCoversDisabled):
return huma.Error503ServiceUnavailable(err.Error())
case errors.Is(err, backend.ErrUpstream): case errors.Is(err, backend.ErrUpstream):
return huma.Error502BadGateway(err.Error()) return huma.Error502BadGateway(err.Error())
default: default:
+114
View File
@@ -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
})
}
+143
View File
@@ -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)
}
+7
View File
@@ -334,6 +334,8 @@ func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.Ge
return []repository.GetAllSoundtracksWithSongsRow{{ return []repository.GetAllSoundtracksWithSongsRow{{
SoundtrackUuid: statsSoundtrackUUID, SoundtrackUuid: statsSoundtrackUUID,
SoundtrackName: "Game A", SoundtrackName: "Game A",
AlternativeNames: []string{"GA"},
CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true},
SoundtrackPlayed: 4, SoundtrackPlayed: 4,
Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`), 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, int32(4), soundtracks[0].SoundtrackPlayed)
assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName)
assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list") 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 var summary backend.StatisticsSummary
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary)) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary))
+36
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"music-server/internal/backend" "music-server/internal/backend"
@@ -19,6 +20,20 @@ func (in *uuidInput) id() uuid.UUID {
return uuid.MustParse(in.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) { func (s *Server) registerLibrary(a huma.API) {
l := s.library l := s.library
op := func(id, path, summary, description string, errs ...int) huma.Operation { 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())) 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", file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
"Returns the audio file of a song.", http.StatusNotFound) "Returns the audio file of a song.", http.StatusNotFound)
file.Responses = fileResponses("The song (MP3)", audioMPEG) file.Responses = fileResponses("The song (MP3)", audioMPEG)
+46
View File
@@ -3,6 +3,8 @@ package server
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"music-server/internal/backend" "music-server/internal/backend"
@@ -84,3 +86,47 @@ func TestQueueResponsesIncludeUUIDs(t *testing.T) {
require.Len(t, list, 1) require.Len(t, list, 1)
assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"]) 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)
}
+20 -1
View File
@@ -74,6 +74,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
} }
slices.Sort(operations) slices.Sort(operations)
assert.Equal(t, []string{ assert.Equal(t, []string{
"DELETE /api/v1/soundtracks/{uuid}/cover",
"DELETE /api/v1/token", "DELETE /api/v1/token",
"GET /api/v1/character", "GET /api/v1/character",
"GET /api/v1/character/details", "GET /api/v1/character/details",
@@ -102,6 +103,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"GET /api/v1/songs/{uuid}/file", "GET /api/v1/songs/{uuid}/file",
"GET /api/v1/soundtracks", "GET /api/v1/soundtracks",
"GET /api/v1/soundtracks/{uuid}", "GET /api/v1/soundtracks/{uuid}",
"GET /api/v1/soundtracks/{uuid}/cover",
"GET /api/v1/statistics/songs/least-played", "GET /api/v1/statistics/songs/least-played",
"GET /api/v1/statistics/songs/most-played", "GET /api/v1/statistics/songs/most-played",
"GET /api/v1/statistics/soundtracks", "GET /api/v1/statistics/soundtracks",
@@ -119,6 +121,9 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
"POST /api/v1/token", "POST /api/v1/token",
"POST /api/v1/token/cleanup", "POST /api/v1/token/cleanup",
"PUT /api/v1/music/played", "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") }, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type) 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) { func TestResponsesMatchOpenAPI(t *testing.T) {
gitea := newGitea(t, http.StatusOK) 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) v := newSpecValidator(t, e)
song := e.addSong("Game A", "Song", "0123456789") song := e.addSong("Game A", "Song", "0123456789")
songURL := "/api/v1/songs/" + song.Uuid.String() songURL := "/api/v1/songs/" + song.Uuid.String()
@@ -202,6 +207,20 @@ func TestResponsesMatchOpenAPI(t *testing.T) {
// Library. // Library.
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", 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, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
v.check(http.MethodGet, songURL, "", http.StatusOK) v.check(http.MethodGet, songURL, "", http.StatusOK)
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK) v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
+1
View File
@@ -63,6 +63,7 @@ func (s *Server) routes() (*echo.Echo, huma.API) {
s.registerSync(a) s.registerSync(a)
s.registerMusic(a) s.registerMusic(a)
s.registerLibrary(a) s.registerLibrary(a)
s.registerCovers(a)
s.registerStatistics(a) s.registerStatistics(a)
// Older links to the spec and docs. // Older links to the spec and docs.
+54 -1
View File
@@ -35,6 +35,11 @@ type Server struct {
releases *backend.ReleaseService releases *backend.ReleaseService
stats *backend.StatisticsService stats *backend.StatisticsService
search *backend.SearchCache 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 httpServer *http.Server
// metrics serves /metrics on its own port; nil when metrics are off. // metrics serves /metrics on its own port; nil when metrics are off.
metrics *metrics.Server metrics *metrics.Server
@@ -57,9 +62,19 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
releases: backend.NewReleaseService(cfg.ReleasesURL), releases: backend.NewReleaseService(cfg.ReleasesURL),
stats: backend.NewStatisticsService(q), 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) 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 { if cfg.MetricsPort > 0 {
s.metrics = metrics.NewServer(cfg.MetricsPort) 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 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. // afterSync refreshes cached data once a sync has finished.
func (s *Server) afterSync(ctx context.Context) { func (s *Server) afterSync(ctx context.Context) {
if err := s.music.EndAllQueues(ctx); err != nil { 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 { if err := s.search.Reload(ctx); err != nil {
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) 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. // 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)) 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)) logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr))
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err return err
@@ -146,6 +195,10 @@ func (s *Server) Shutdown(ctx context.Context) error {
if s.metrics != nil { if s.metrics != nil {
err = errors.Join(err, s.metrics.Shutdown(ctx)) err = errors.Join(err, s.metrics.Shutdown(ctx))
} }
if s.stopCovers != nil {
s.stopCovers()
<-s.coversDone
}
s.sync.Wait() s.sync.Wait()
s.characters.Close() s.characters.Close()
s.db.Close() s.db.Close()
+87
View File
@@ -193,6 +193,93 @@ func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository
return repository.Soundtrack{}, pgx.ErrNoRows 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 { func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error {
defer s.lock()() defer s.lock()()
if s.Err != nil { if s.Err != nil {
+5
View File
@@ -21,6 +21,11 @@ sql:
pointer: true pointer: true
import: "time" import: "time"
type: "Time" type: "Time"
- db_type: "text"
nullable: true
go_type:
pointer: true
type: "string"
- db_type: "pg_catalog.varchar" - db_type: "pg_catalog.varchar"
nullable: true nullable: true
go_type: go_type: