From bb8de5548db5946a7d50692e018421febe668fa6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 13 Oct 2026 21:57:18 +0200 Subject: [PATCH] Add alternative names and cover art for soundtracks Alternative names (#32): - PUT /api/v1/soundtracks/{uuid}/alternative-names replaces a soundtrack's alternative names (trimmed, deduplicated, without the soundtrack's own name). - alternative_names is in the soundtrack and statistics responses. - /find matches them too and shows which name matched. Covers (#12), stored in COVERS_PATH and off when it is not set: - GET, PUT (upload) and DELETE /api/v1/soundtracks/{uuid}/cover, and PUT .../cover/source to pick a SteamGridDB game or AniList anime by ID. Uploaded and picked covers are never replaced automatically. - With STEAMGRIDDB_API_KEY, a background job searches SteamGridDB, then AniList, by name, alternative names and the name without bracketed parts, at startup, after a sync and after names change. Search results are ranked by matching words, so "Civilization IV" gets "Sid Meier's Civilization IV". - cover_modified (Unix seconds) is in the soundtrack and statistics responses, so clients can cache covers. The new columns are added to migration 000006, which is not released yet. Closes #12, closes #32 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + Dockerfile | 2 + cmd/web/assets/css/styles.css | 6 + cmd/web/search.go | 36 +- cmd/web/search.templ | 7 +- cmd/web/search_test.go | 55 ++- internal/backend/cover_sources.go | 289 ++++++++++++ internal/backend/covers.go | 410 +++++++++++++++++ internal/backend/covers_integration_test.go | 98 ++++ internal/backend/covers_test.go | 425 ++++++++++++++++++ internal/backend/errors.go | 4 + internal/backend/library.go | 78 +++- internal/backend/library_test.go | 46 +- internal/backend/music.go | 13 + internal/backend/search_cache.go | 36 +- internal/backend/search_cache_test.go | 15 +- internal/backend/statistics.go | 6 + internal/backend/sync_test.go | 31 ++ internal/config/config.go | 10 + internal/config/config_test.go | 12 + .../000006_add_fingerprint_and_queue.down.sql | 8 + .../000006_add_fingerprint_and_queue.up.sql | 14 + internal/db/queries/soundtrack.sql | 41 ++ internal/db/queries/statistics.sql | 12 + internal/db/repository/models.go | 31 +- internal/db/repository/querier.go | 10 + internal/db/repository/soundtrack.sql.go | 191 +++++++- internal/db/repository/statistics.sql.go | 109 +++-- internal/server/api.go | 4 +- internal/server/cover_handler.go | 114 +++++ internal/server/cover_handler_test.go | 143 ++++++ internal/server/handlers_test.go | 7 + internal/server/library_handler.go | 36 ++ internal/server/library_handler_test.go | 46 ++ internal/server/openapi_test.go | 21 +- internal/server/routes.go | 1 + internal/server/server.go | 55 ++- internal/testutil/fakedb/fakedb.go | 87 ++++ sqlc.yaml | 5 + 39 files changed, 2418 insertions(+), 99 deletions(-) create mode 100644 internal/backend/cover_sources.go create mode 100644 internal/backend/covers.go create mode 100644 internal/backend/covers_integration_test.go create mode 100644 internal/backend/covers_test.go create mode 100644 internal/server/cover_handler.go create mode 100644 internal/server/cover_handler_test.go diff --git a/.gitignore b/.gitignore index 20cf7b3..1f21b4d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ main # Generated with `just openapi` /openapi.json /openapi-3.0.json + +# Local cover images (COVERS_PATH) +/covers/ diff --git a/Dockerfile b/Dockerfile index 8cd6e35..713391e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,8 @@ VOLUME /sorted VOLUME /characters # Required: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME, MUSIC_PATH, CHARACTERS_PATH +# Covers (optional): COVERS_PATH, a writable folder such as a /covers volume, turns covers on. +# STEAMGRIDDB_API_KEY also turns on the automatic search (SteamGridDB, then AniList). ENV PORT=8080 CMD ["./main"] diff --git a/cmd/web/assets/css/styles.css b/cmd/web/assets/css/styles.css index 12027fe..e65043a 100644 --- a/cmd/web/assets/css/styles.css +++ b/cmd/web/assets/css/styles.css @@ -135,6 +135,12 @@ main { margin-top: 1.5rem; } +.matched-name { + margin: 0.25rem 0 0; + font-size: 0.9rem; + color: var(--text-secondary); +} + /* Responsive adjustments */ @media (max-width: 768px) { #search_term { diff --git a/cmd/web/search.go b/cmd/web/search.go index 820cc45..9073096 100644 --- a/cmd/web/search.go +++ b/cmd/web/search.go @@ -33,15 +33,33 @@ func NewSearchHandler(cache *backend.SearchCache) http.Handler { }) } +// SearchResult is a soundtrack found by search. MatchedName is the alternative name that +// matched, or empty when the soundtrack's own name matched. +type SearchResult struct { + Name string + MatchedName string +} + // search returns the soundtracks matching searchText: exact matches first, then matches -// ignoring spaces and special characters, then matches with the letters in order. -func search(searchText string, soundtrackData []backend.SoundtrackData) []string { - var results []string +// ignoring spaces and special characters, then matches with the letters in order. Each +// step tries the soundtrack's own name before its alternative names. +func search(searchText string, soundtrackData []backend.SoundtrackData) []SearchResult { + var results []SearchResult seen := make(map[string]bool) - add := func(match func(backend.SoundtrackData) bool) { + add := func(match func(backend.SearchName) bool) { for _, data := range soundtrackData { - if !seen[data.Original] && match(data) { - results = append(results, data.Original) + if seen[data.Original] { + continue + } + matched, ok := "", match(data.SearchName) + for _, alternative := range data.Alternatives { + if ok { + break + } + matched, ok = alternative.Original, match(alternative) + } + if ok { + results = append(results, SearchResult{Name: data.Original, MatchedName: matched}) seen[data.Original] = true } } @@ -50,8 +68,8 @@ func search(searchText string, soundtrackData []backend.SoundtrackData) []string lowercase := strings.ToLower(searchText) cleaned := backend.CleanSearchTerm(searchText) - add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Lowercase, lowercase) }) - add(func(d backend.SoundtrackData) bool { return strings.Contains(d.Cleaned, cleaned) }) - add(func(d backend.SoundtrackData) bool { return backend.MatchesSubsequence(cleaned, d.Cleaned) }) + add(func(n backend.SearchName) bool { return strings.Contains(n.Lowercase, lowercase) }) + add(func(n backend.SearchName) bool { return strings.Contains(n.Cleaned, cleaned) }) + add(func(n backend.SearchName) bool { return backend.MatchesSubsequence(cleaned, n.Cleaned) }) return results } diff --git a/cmd/web/search.templ b/cmd/web/search.templ index 34dd3be..1b185ef 100644 --- a/cmd/web/search.templ +++ b/cmd/web/search.templ @@ -40,10 +40,13 @@ templ SearchForm() { } } -templ FoundSoundtracks(soundtracks []string) { +templ FoundSoundtracks(soundtracks []SearchResult) { for _, soundtrack := range soundtracks {
-

{ soundtrack }

+

{ soundtrack.Name }

+ if soundtrack.MatchedName != "" { +

Also known as { soundtrack.MatchedName }

+ }
} } \ No newline at end of file diff --git a/cmd/web/search_test.go b/cmd/web/search_test.go index 0a3c428..e8d55ba 100644 --- a/cmd/web/search_test.go +++ b/cmd/web/search_test.go @@ -28,8 +28,24 @@ var soundtracks = []string{ "Ys III - Wanderers from Ys (+The Oath in Felghana)", } -func newCache(names []string) *backend.SearchCache { - return backend.NewSearchCache(func(context.Context) ([]string, error) { return names, nil }) +func newCache(names []string, alternatives ...map[string][]string) *backend.SearchCache { + soundtracks := make([]backend.SearchSoundtrack, len(names)) + for i, name := range names { + soundtracks[i] = backend.SearchSoundtrack{Name: name} + for _, a := range alternatives { + soundtracks[i].AlternativeNames = a[name] + } + } + return backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return soundtracks, nil }) +} + +// names returns the soundtrack names of results. +func names(results []SearchResult) []string { + var n []string + for _, r := range results { + n = append(n, r.Name) + } + return n } func searchData(t *testing.T) []backend.SoundtrackData { @@ -62,23 +78,50 @@ func TestSearch(t *testing.T) { } for _, tt := range tests { t.Run(tt.query, func(t *testing.T) { - assert.Equal(t, tt.want, search(tt.query, data)) + assert.Equal(t, tt.want, names(search(tt.query, data))) }) } } func TestSearchEmptyReturnsEverything(t *testing.T) { - assert.Equal(t, soundtracks, search("", searchData(t))) + assert.Equal(t, soundtracks, names(search("", searchData(t)))) } func TestSearchOrdering(t *testing.T) { // "MG" is an exact match, "Metal Gear Solid..." only matches by letters in order. - results := search("mg", searchData(t)) + results := names(search("mg", searchData(t))) require.GreaterOrEqual(t, len(results), 2) assert.Equal(t, "MG", results[0]) assert.Contains(t, results, "Metal Gear Solid 4 - Guns of the Patriots") } +func TestSearchAlternativeNames(t *testing.T) { + data, err := newCache(soundtracks, map[string][]string{ + "Final Fantasy VII": {"FF7", "ファイナルファンタジーVII"}, + "Kingdom Hearts": {"KH"}, + }).Get(context.Background()) + require.NoError(t, err) + + assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "FF7"}}, search("ff7", data)) + assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII", MatchedName: "ファイナルファンタジーVII"}}, search("ファイナル", data)) + assert.Equal(t, []SearchResult{{Name: "Final Fantasy VII"}}, search("fantasy", data), + "no matched name when the soundtrack's own name matches") + // An exact match on an alternative name comes before a letters-in-order match on a name. + results := search("kh", data) + require.NotEmpty(t, results) + assert.Equal(t, SearchResult{Name: "Kingdom Hearts", MatchedName: "KH"}, results[0]) +} + +func TestFoundSoundtracksShowsMatchedName(t *testing.T) { + var out strings.Builder + require.NoError(t, FoundSoundtracks([]SearchResult{ + {Name: "Final Fantasy VII", MatchedName: "FF7"}, {Name: "Kingdom Hearts"}, + }).Render(context.Background(), &out)) + assert.Contains(t, out.String(), "Final Fantasy VII") + assert.Contains(t, out.String(), "Also known as FF7") + assert.Equal(t, 1, strings.Count(out.String(), "Also known as")) +} + func TestSearchHandler(t *testing.T) { handler := NewSearchHandler(newCache(soundtracks)) req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader(url.Values{"search_term": {"kingdom"}}.Encode())) @@ -93,7 +136,7 @@ func TestSearchHandler(t *testing.T) { } func TestSearchHandlerLoadError(t *testing.T) { - cache := backend.NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") }) + cache := backend.NewSearchCache(func(context.Context) ([]backend.SearchSoundtrack, error) { return nil, errors.New("db down") }) rec := httptest.NewRecorder() NewSearchHandler(cache).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/find", nil)) assert.Equal(t, http.StatusInternalServerError, rec.Code) diff --git a/internal/backend/cover_sources.go b/internal/backend/cover_sources.go new file mode 100644 index 0000000..d2d2894 --- /dev/null +++ b/internal/backend/cover_sources.go @@ -0,0 +1,289 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "unicode" +) + +// coverMatch is a cover found by a source. +type coverMatch struct { + ExternalID string + ImageURL string +} + +// coverSource finds cover images in an external database. +type coverSource interface { + // Name is stored as the cover's source, e.g. "steamgriddb". + Name() string + // Search returns the cover for the first of names that the source knows, or nil when + // none matched. An error means the source could not be asked. + Search(ctx context.Context, names []string) (*coverMatch, error) + // ByID returns the cover of the entry with the given ID, or ErrNotFound. + ByID(ctx context.Context, id string) (*coverMatch, error) +} + +// getJSON sends req and decodes a JSON response into v. A 404 gives ErrNotFound, other +// failures ErrUpstream. +func getJSON(client *http.Client, req *http.Request, v any) error { + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", ErrUpstream, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s answered %s", ErrUpstream, req.URL.Host, resp.Status) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(v); err != nil { + return fmt.Errorf("%w: invalid response from %s: %v", ErrUpstream, req.URL.Host, err) + } + return nil +} + +// steamGridDB finds game box art ("grids") on SteamGridDB. It needs an API key. +type steamGridDB struct { + baseURL string + key string + client *http.Client +} + +const steamGridDBURL = "https://www.steamgriddb.com/api/v2" + +func (s *steamGridDB) Name() string { return "steamgriddb" } + +func (s *steamGridDB) get(ctx context.Context, path string, query url.Values, v any) error { + u := s.baseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.key) + var wrapper struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + } + if err := getJSON(s.client, req, &wrapper); err != nil { + return err + } + if !wrapper.Success { + return fmt.Errorf("%w: SteamGridDB request failed", ErrUpstream) + } + return json.Unmarshal(wrapper.Data, v) +} + +type steamGridDBGame struct { + ID int `json:"id"` + Name string `json:"name"` +} + +func (s *steamGridDB) Search(ctx context.Context, names []string) (*coverMatch, error) { + for _, name := range names { + var games []steamGridDBGame + if err := s.get(ctx, "/search/autocomplete/"+url.PathEscape(name), nil, &games); err != nil { + return nil, err + } + if len(games) == 0 { + continue + } + match, err := s.ByID(ctx, strconv.Itoa(bestGame(name, games).ID)) + if errors.Is(err, ErrNotFound) { + continue // the game has no box art + } + return match, err + } + return nil, nil +} + +func (s *steamGridDB) ByID(ctx context.Context, id string) (*coverMatch, error) { + if _, err := strconv.Atoi(id); err != nil { + return nil, ErrNotFound + } + query := url.Values{ + "dimensions": {"600x900"}, // portrait box art + "types": {"static"}, + "mimes": {"image/png,image/jpeg"}, + "nsfw": {"false"}, + "humor": {"false"}, + } + var grids []struct { + URL string `json:"url"` + } + if err := s.get(ctx, "/grids/game/"+id, query, &grids); err != nil { + return nil, err + } + if len(grids) == 0 { + return nil, ErrNotFound + } + return &coverMatch{ExternalID: id, ImageURL: grids[0].URL}, nil +} + +// bestGame picks the search result for name: +// 1. a result with exactly the words of name, in any order ("Lemmings 3D" → "3D Lemmings"); +// 2. else a result that ends with the words of name, as words added in front are usually a +// publisher or series ("Civilization IV" → "Sid Meier's Civilization IV"); +// 3. else SteamGridDB's own first result. Words added after the name can be a subtitle, a +// sequel or an expansion ("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), which only +// SteamGridDB's ranking can tell apart. +func bestGame(name string, games []steamGridDBGame) steamGridDBGame { + want := titleWords(name) + for _, game := range games { + if sameWords(titleWords(game.Name), want) { + return game + } + } + for _, game := range games { + if have := titleWords(game.Name); len(have) > len(want) && slices.Equal(have[len(have)-len(want):], want) { + return game + } + } + return games[0] +} + +// sameWords reports whether a and b have the same words, in any order. +func sameWords(a, b []string) bool { + return len(a) == len(b) && slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b))) +} + +// romanNumerals are read as numbers, so "Octopath Traveler 2" matches "Octopath Traveler II". +var romanNumerals = map[string]string{ + "i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7", "viii": "8", "ix": "9", "x": "10", +} + +// titleWords returns the words of a title in lower case, without punctuation. +func titleWords(title string) []string { + words := strings.FieldsFunc(strings.ToLower(title), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + for i, word := range words { + if number, ok := romanNumerals[word]; ok { + words[i] = number + } + } + return words +} + +// aniList finds anime covers on AniList. It needs no key. Its search is loose, so a +// result is only used when one of its titles is the searched name. +type aniList struct { + url string + client *http.Client +} + +const aniListURL = "https://graphql.anilist.co" + +const aniListFields = `id title { romaji english native } coverImage { extraLarge large }` + +type aniListMedia struct { + ID int `json:"id"` + Title struct { + Romaji string `json:"romaji"` + English string `json:"english"` + Native string `json:"native"` + } `json:"title"` + CoverImage struct { + ExtraLarge string `json:"extraLarge"` + Large string `json:"large"` + } `json:"coverImage"` +} + +func (m aniListMedia) match() *coverMatch { + image := m.CoverImage.ExtraLarge + if image == "" { + image = m.CoverImage.Large + } + if image == "" { + return nil + } + return &coverMatch{ExternalID: strconv.Itoa(m.ID), ImageURL: image} +} + +func (a *aniList) Name() string { return "anilist" } + +func (a *aniList) query(ctx context.Context, query string, variables map[string]any) (*aniListMedia, error) { + body, err := json.Marshal(map[string]any{"query": query, "variables": variables}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + var resp struct { + Data struct { + Media *aniListMedia `json:"Media"` + } `json:"data"` + } + if err := getJSON(a.client, req, &resp); err != nil { + return nil, err + } + if resp.Data.Media == nil { + return nil, ErrNotFound + } + return resp.Data.Media, nil +} + +func (a *aniList) Search(ctx context.Context, names []string) (*coverMatch, error) { + for _, name := range names { + media, err := a.query(ctx, `query ($search: String) { Media(search: $search, type: ANIME) { `+aniListFields+` } }`, + map[string]any{"search": name}) + if errors.Is(err, ErrNotFound) { + continue + } + if err != nil { + return nil, err + } + want := normalizeTitle(name) + for _, title := range []string{media.Title.Romaji, media.Title.English, media.Title.Native} { + if title != "" && normalizeTitle(title) == want { + if match := media.match(); match != nil { + return match, nil + } + } + } + } + return nil, nil +} + +func (a *aniList) ByID(ctx context.Context, id string) (*coverMatch, error) { + n, err := strconv.Atoi(id) + if err != nil { + return nil, ErrNotFound + } + media, err := a.query(ctx, `query ($id: Int) { Media(id: $id) { `+aniListFields+` } }`, map[string]any{"id": n}) + if err != nil { + return nil, err + } + if match := media.match(); match != nil { + return match, nil + } + return nil, ErrNotFound +} + +// normalizeTitle keeps only the letters and digits of a title, in lower case, so +// "Cowboy Bebop: The Movie" and "cowboy bebop the movie" compare equal. +func normalizeTitle(title string) string { + var b strings.Builder + for _, r := range strings.ToLower(title) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/backend/covers.go b/internal/backend/covers.go new file mode 100644 index 0000000..4d9a1ed --- /dev/null +++ b/internal/backend/covers.go @@ -0,0 +1,410 @@ +package backend + +import ( + "cmp" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "music-server/internal/db/repository" + "music-server/internal/logging" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "go.uber.org/zap" +) + +const ( + // MaxCoverBytes is the largest cover image that is stored. + MaxCoverBytes = 10 << 20 + // coverRecheckAfter is how long a soundtrack without a match waits before the job + // searches for it again. Changing its alternative names makes it search right away. + coverRecheckAfter = 30 * 24 * time.Hour + // coverRequestInterval spaces out requests to the external APIs, to stay well inside + // their rate limits. + coverRequestInterval = time.Second +) + +// SourceUpload is the source of a cover that was uploaded. +const SourceUpload = "upload" + +// coverTypes are the image types stored as covers, by MIME type, with their file extension. +var coverTypes = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +// CoverService stores cover images for soundtracks in a folder and finds them in external +// databases in the background. +type CoverService struct { + q repository.Querier + dir string + sources []coverSource + client *http.Client + // interval spaces out external requests; tests set it to 0. + interval time.Duration + + trigger chan struct{} + // writeMu keeps the job from replacing a cover that is being set by hand. + writeMu sync.Mutex +} + +// CoverOptions configures where covers are searched. +type CoverOptions struct { + // SteamGridDBKey turns on SteamGridDB and the background job. + SteamGridDBKey string + // SteamGridDBURL and AniListURL replace the real APIs, for tests. + SteamGridDBURL string + AniListURL string +} + +// NewCoverService stores covers in dir. With a SteamGridDB API key the background job +// searches SteamGridDB, then AniList; without one there is no background job, but covers +// can still be uploaded or picked by ID. +func NewCoverService(q repository.Querier, dir string, opts CoverOptions) (*CoverService, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + client := &http.Client{Timeout: 30 * time.Second} + c := &CoverService{ + q: q, + dir: dir, + client: client, + interval: coverRequestInterval, + trigger: make(chan struct{}, 1), + } + if opts.SteamGridDBKey != "" { + c.sources = append(c.sources, &steamGridDB{ + baseURL: cmp.Or(opts.SteamGridDBURL, steamGridDBURL), key: opts.SteamGridDBKey, client: client, + }) + } + c.sources = append(c.sources, &aniList{url: cmp.Or(opts.AniListURL, aniListURL), client: client}) + return c, nil +} + +// SearchesAutomatically reports whether the background job runs. +func (c *CoverService) SearchesAutomatically() bool { + return c.source("steamgriddb") != nil +} + +func (c *CoverService) source(name string) coverSource { + for _, s := range c.sources { + if s.Name() == name { + return s + } + } + return nil +} + +// Path returns the cover image of an active soundtrack, or ErrNotFound. +func (c *CoverService) Path(ctx context.Context, id uuid.UUID) (string, error) { + st, err := c.q.GetSoundtrackByUUID(ctx, id) + if err != nil { + return "", notFound(err) + } + if st.CoverFile == nil { + return "", ErrNotFound + } + return filepath.Join(c.dir, *st.CoverFile), nil +} + +// Upload stores data as the soundtrack's cover. It is never replaced by the background job. +func (c *CoverService) Upload(ctx context.Context, id uuid.UUID, data []byte) error { + if _, err := c.activeSoundtrack(ctx, id); err != nil { + return err + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.store(ctx, id, data, SourceUpload, nil, true) +} + +// FromSource stores the cover of entry externalID in source (steamgriddb or anilist) as +// the soundtrack's cover. It is never replaced by the background job. +func (c *CoverService) FromSource(ctx context.Context, id uuid.UUID, source, externalID string) error { + s := c.source(source) + if s == nil { + return fmt.Errorf("%w: unknown or disabled cover source %q", ErrInvalidName, source) + } + if _, err := c.activeSoundtrack(ctx, id); err != nil { + return err + } + match, err := s.ByID(ctx, externalID) + if err != nil { + return err + } + data, err := c.download(ctx, match.ImageURL) + if err != nil { + return err + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.store(ctx, id, data, source, &match.ExternalID, true) +} + +// Remove deletes the soundtrack's cover, so the background job searches for a new one. +func (c *CoverService) Remove(ctx context.Context, id uuid.UUID) error { + st, err := c.activeSoundtrack(ctx, id) + if err != nil { + return err + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + if err := c.q.ClearCover(ctx, id); err != nil { + return err + } + if st.CoverFile != nil { + c.removeFile(*st.CoverFile) + } + c.Trigger() + return nil +} + +func (c *CoverService) activeSoundtrack(ctx context.Context, id uuid.UUID) (repository.Soundtrack, error) { + st, err := c.q.GetSoundtrackByUUID(ctx, id) + return st, notFound(err) +} + +// store writes data as the soundtrack's cover file and records it. Call with writeMu held. +func (c *CoverService) store(ctx context.Context, id uuid.UUID, data []byte, source string, externalID *string, manual bool) error { + ext, err := coverExtension(data) + if err != nil { + return err + } + name := id.String() + ext + tmp, err := os.CreateTemp(c.dir, ".upload-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmp.Name(), filepath.Join(c.dir, name)); err != nil { + return err + } + // A cover of another image type leaves a file with another extension behind. + for _, other := range coverTypes { + if other != ext { + c.removeFile(id.String() + other) + } + } + return c.q.SetCover(ctx, repository.SetCoverParams{ + Uuid: id, File: &name, Source: &source, ExternalID: externalID, Manual: manual, + }) +} + +func (c *CoverService) removeFile(name string) { + if err := os.Remove(filepath.Join(c.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + logging.GetLogger().Warn("Failed to remove cover", zap.String("file", name), zap.Error(err)) + } +} + +// coverExtension checks that data is a supported image and returns its file extension. +func coverExtension(data []byte) (string, error) { + if len(data) > MaxCoverBytes { + return "", fmt.Errorf("%w: the image is larger than %d MB", ErrInvalidImage, MaxCoverBytes>>20) + } + ext, ok := coverTypes[http.DetectContentType(data)] + if !ok { + return "", fmt.Errorf("%w: only JPEG, PNG and WebP images are supported", ErrInvalidImage) + } + return ext, nil +} + +// download fetches a cover image. +func (c *CoverService) download(ctx context.Context, imageURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, fmt.Errorf("%w: invalid image URL: %v", ErrUpstream, err) + } + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUpstream, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: image download answered %s", ErrUpstream, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, MaxCoverBytes+1)) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUpstream, err) + } + if _, err := coverExtension(data); err != nil { + return nil, fmt.Errorf("%w: %v", ErrUpstream, err) + } + return data, nil +} + +// Trigger asks the background job to look for missing covers. It never blocks. +func (c *CoverService) Trigger() { + select { + case c.trigger <- struct{}{}: + default: // a pass is already waiting + } +} + +// Run searches for missing covers now and each time Trigger is called, until ctx ends. +// It does nothing when automatic search is off. +func (c *CoverService) Run(ctx context.Context) { + if !c.SearchesAutomatically() { + return + } + c.Trigger() + for { + select { + case <-ctx.Done(): + return + case <-c.trigger: + if err := c.searchMissing(ctx); err != nil && ctx.Err() == nil { + logging.GetLogger().Warn("Cover search stopped, trying again after the next sync", zap.Error(err)) + } + } + } +} + +// searchMissing finds covers for active soundtracks without one. It stops at the first +// error from an external API, so an unreachable API is not asked again for every soundtrack. +func (c *CoverService) searchMissing(ctx context.Context) error { + c.removeOrphans(ctx) + rows, err := c.q.ListSoundtracksNeedingCover(ctx, + pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter), Valid: true}) + if err != nil { + return err + } + logger := logging.GetLogger() + found := 0 + for _, row := range rows { + match, source, err := c.search(ctx, coverSearchNames(row.SoundtrackName, row.AlternativeNames)) + if err != nil { + return err + } + if match == nil { + if err := c.q.MarkCoverChecked(ctx, row.Uuid); err != nil { + return err + } + continue + } + if err := c.wait(ctx); err != nil { + return err + } + data, err := c.download(ctx, match.ImageURL) + if err != nil { + return err + } + stored, err := c.storeFound(ctx, row.Uuid, data, source, match.ExternalID) + if err != nil { + return err + } + if stored { + found++ + logger.Info("Cover found", zap.String("soundtrack", row.SoundtrackName), + zap.String("source", source), zap.String("external_id", match.ExternalID)) + } + } + if len(rows) > 0 { + logger.Info("Cover search finished", zap.Int("searched", len(rows)), zap.Int("found", found)) + } + return nil +} + +// storeFound stores a cover found by the job, unless one was set by hand meanwhile. +func (c *CoverService) storeFound(ctx context.Context, id uuid.UUID, data []byte, source, externalID string) (bool, error) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + st, err := c.q.GetSoundtrackByUUID(ctx, id) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil // removed meanwhile + } + if err != nil { + return false, err + } + if st.CoverManual || st.CoverFile != nil { + return false, nil + } + return true, c.store(ctx, id, data, source, &externalID, false) +} + +// search asks the sources in order and returns the first match. +func (c *CoverService) search(ctx context.Context, names []string) (*coverMatch, string, error) { + for _, s := range c.sources { + if err := c.wait(ctx); err != nil { + return nil, "", err + } + match, err := s.Search(ctx, names) + if err != nil { + return nil, "", fmt.Errorf("%s: %w", s.Name(), err) + } + if match != nil { + return match, s.Name(), nil + } + } + return nil, "", nil +} + +func (c *CoverService) wait(ctx context.Context) error { + if c.interval <= 0 { + return ctx.Err() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(c.interval): + return nil + } +} + +// removeOrphans deletes cover files that no soundtrack uses any more, for example after +// the library was reset. +func (c *CoverService) removeOrphans(ctx context.Context) { + files, err := c.q.ListCoverFiles(ctx) + if err != nil { + logging.GetLogger().Warn("Failed to list cover files", zap.Error(err)) + return + } + used := make(map[string]bool, len(files)) + for _, f := range files { + used[f] = true + } + entries, err := os.ReadDir(c.dir) + if err != nil { + logging.GetLogger().Warn("Failed to read the covers folder", zap.Error(err)) + return + } + for _, entry := range entries { + if !entry.IsDir() && !used[entry.Name()] { + c.removeFile(entry.Name()) + } + } +} + +var bracketed = regexp.MustCompile(`\s*[(\[][^)\]]*[)\]]`) + +// coverSearchNames returns the names to search for: the soundtrack's name, its alternative +// names, and the name without parts in brackets ("Lemmings (SNES + Mega Drive)" → +// "Lemmings"), without duplicates. +func coverSearchNames(name string, alternatives []string) []string { + var names []string + seen := map[string]bool{} + for _, n := range append(append([]string{name}, alternatives...), bracketed.ReplaceAllString(name, "")) { + n = strings.TrimSpace(n) + if n != "" && !seen[strings.ToLower(n)] { + seen[strings.ToLower(n)] = true + names = append(names, n) + } + } + return names +} diff --git a/internal/backend/covers_integration_test.go b/internal/backend/covers_integration_test.go new file mode 100644 index 0000000..d41e497 --- /dev/null +++ b/internal/backend/covers_integration_test.go @@ -0,0 +1,98 @@ +//go:build integration + +package backend + +import ( + "context" + "path/filepath" + "testing" + "time" + + "music-server/internal/db/repository" + "music-server/internal/testutil/pgtest" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAlternativeNamesAndCoversWithPostgres checks the alternative name and cover queries +// against a real database. +func TestAlternativeNamesAndCoversWithPostgres(t *testing.T) { + ctx := context.Background() + q := repository.New(pgtest.NewMigratedDatabase(t).Pool) + s := NewSyncService(q, copyTestMusic(t), nil) + require.True(t, s.Start(false)) + s.Wait() + + library := NewLibraryService(q) + soundtracks, err := library.Soundtracks(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(soundtracks), 2) + first, second := soundtracks[0], soundtracks[1] + assert.Equal(t, []string{}, first.AlternativeNames) + assert.Nil(t, first.CoverModified) + + // Alternative names, in every soundtrack response. + updated, err := library.SetAlternativeNames(ctx, first.Uuid, []string{" Alt ", "alt", first.Name, "Другое"}) + require.NoError(t, err) + assert.Equal(t, []string{"Alt", "Другое"}, updated.AlternativeNames) + stats, err := NewStatisticsService(q).AllSoundtracks(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"Alt", "Другое"}, stats[0].AlternativeNames) + never, err := NewStatisticsService(q).NeverPlayedSoundtracks(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"Alt", "Другое"}, never[0].AlternativeNames) + search, err := NewMusicService(q, t.TempDir()).SearchSoundtracks(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"Alt", "Другое"}, search[0].AlternativeNames) + + // Covers. + c, err := NewCoverService(q, filepath.Join(t.TempDir(), "covers"), CoverOptions{}) + require.NoError(t, err) + require.NoError(t, c.Upload(ctx, first.Uuid, pngBytes(t))) + withCover, err := library.Soundtrack(ctx, first.Uuid) + require.NoError(t, err) + require.NotNil(t, withCover.CoverModified) + stats, err = NewStatisticsService(q).AllSoundtracks(ctx) + require.NoError(t, err) + assert.Equal(t, withCover.CoverModified, stats[0].CoverModified) + + needing, err := q.ListSoundtracksNeedingCover(ctx, nowPlus(0)) + require.NoError(t, err) + for _, row := range needing { + assert.NotEqual(t, first.Uuid, row.Uuid, "a soundtrack with a cover is not searched") + } + require.NoError(t, q.MarkCoverChecked(ctx, second.Uuid)) + needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60)) + require.NoError(t, err) + for _, row := range needing { + assert.NotEqual(t, second.Uuid, row.Uuid, "searched recently") + } + + // New names make a soundtrack without a cover searched again. + _, err = library.SetAlternativeNames(ctx, second.Uuid, []string{"Other"}) + require.NoError(t, err) + needing, err = q.ListSoundtracksNeedingCover(ctx, nowPlus(-60)) + require.NoError(t, err) + var found bool + for _, row := range needing { + if row.Uuid == second.Uuid { + found = true + assert.Equal(t, []string{"Other"}, row.AlternativeNames) + } + } + assert.True(t, found) + + files, err := q.ListCoverFiles(ctx) + require.NoError(t, err) + assert.Equal(t, []string{first.Uuid.String() + ".png"}, files) + require.NoError(t, c.Remove(ctx, first.Uuid)) + files, err = q.ListCoverFiles(ctx) + require.NoError(t, err) + assert.Empty(t, files) +} + +func nowPlus(seconds int) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: time.Now().Add(time.Duration(seconds) * time.Second), Valid: true} +} diff --git a/internal/backend/covers_test.go b/internal/backend/covers_test.go new file mode 100644 index 0000000..f1b44ea --- /dev/null +++ b/internal/backend/covers_test.go @@ -0,0 +1,425 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "music-server/internal/db/repository" + "music-server/internal/testutil/fakedb" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func pngBytes(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3)))) + return buf.Bytes() +} + +// coverAPIs fakes SteamGridDB, AniList and the image host. +type coverAPIs struct { + t *testing.T + server *httptest.Server + // games maps search terms to SteamGridDB game IDs; 404 has no grids. + games map[string]int + // anime maps search terms to AniList titles; the ID is always 42. + anime map[string]string + // failSteamGridDB makes SteamGridDB answer 500. + failSteamGridDB bool + + mu sync.Mutex + searches []string +} + +func newCoverAPIs(t *testing.T) *coverAPIs { + a := &coverAPIs{t: t, games: map[string]int{}, anime: map[string]string{}} + image := pngBytes(t) + mux := http.NewServeMux() + reply := func(w http.ResponseWriter, data any) { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data}) + } + mux.HandleFunc("GET /sgdb/search/autocomplete/{term}", func(w http.ResponseWriter, r *http.Request) { + a.record("sgdb:" + r.PathValue("term")) + if a.failSteamGridDB { + http.Error(w, "down", http.StatusInternalServerError) + return + } + assert.Equal(t, "Bearer key", r.Header.Get("Authorization")) + if id, ok := a.games[r.PathValue("term")]; ok { + reply(w, []map[string]any{{"id": id, "name": r.PathValue("term")}}) + return + } + reply(w, []any{}) + }) + mux.HandleFunc("GET /sgdb/grids/game/{id}", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "600x900", r.URL.Query().Get("dimensions")) + if r.PathValue("id") == "404" { + reply(w, []any{}) + return + } + reply(w, []map[string]any{{"id": 1, "url": a.server.URL + "/img/grid-" + r.PathValue("id") + ".png"}}) + }) + mux.HandleFunc("POST /anilist", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Variables struct { + Search string `json:"search"` + ID int `json:"id"` + } `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + a.record("anilist:" + req.Variables.Search) + title, ok := a.anime[req.Variables.Search] + if req.Variables.ID == 42 { + title, ok = "By ID", true + } + if !ok { + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": nil}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": map[string]any{ + "id": 42, "title": map[string]any{"romaji": title}, + "coverImage": map[string]any{"extraLarge": a.server.URL + "/img/anime.png"}, + }}}) + }) + mux.HandleFunc("GET /img/{name}", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("name") == "text.png" { + _, _ = w.Write([]byte("not an image")) + return + } + _, _ = w.Write(image) + }) + a.server = httptest.NewServer(mux) + t.Cleanup(a.server.Close) + return a +} + +func (a *coverAPIs) record(search string) { + a.mu.Lock() + defer a.mu.Unlock() + a.searches = append(a.searches, search) +} + +func (a *coverAPIs) searched() []string { + a.mu.Lock() + defer a.mu.Unlock() + return append([]string(nil), a.searches...) +} + +func (a *coverAPIs) options(withKey bool) CoverOptions { + opts := CoverOptions{SteamGridDBURL: a.server.URL + "/sgdb", AniListURL: a.server.URL + "/anilist"} + if withKey { + opts.SteamGridDBKey = "key" + } + return opts +} + +func newCovers(t *testing.T, store *fakedb.Store, opts CoverOptions) *CoverService { + t.Helper() + c, err := NewCoverService(store, filepath.Join(t.TempDir(), "covers"), opts) + require.NoError(t, err) + c.interval = 0 + return c +} + +func coverOf(t *testing.T, store *fakedb.Store, name string) repository.Soundtrack { + t.Helper() + st, ok := store.Soundtrack(name) + require.True(t, ok) + return st +} + +func TestCoverSearchFindsCovers(t *testing.T) { + apis := newCoverAPIs(t) + apis.games["Chrono Trigger"] = 1 + apis.games["FF7"] = 7 + apis.games["Lemmings"] = 3 + apis.anime["Cowboy Bebop"] = "Cowboy Bebop" + apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children" + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"}) + ff := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII", AlternativeNames: []string{"FF7"}}) + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Lemmings (SNES + Mega Drive)"}) + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cowboy Bebop"}) + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Unknown"}) + c := newCovers(t, store, apis.options(true)) + + require.NoError(t, c.searchMissing(context.Background())) + + chrono := coverOf(t, store, "Chrono Trigger") + require.NotNil(t, chrono.CoverFile) + assert.Equal(t, chrono.Uuid.String()+".png", *chrono.CoverFile) + assert.Equal(t, "steamgriddb", *chrono.CoverSource) + assert.Equal(t, "1", *chrono.CoverExternalID) + assert.False(t, chrono.CoverManual) + assert.True(t, chrono.CoverModified.Valid) + assert.FileExists(t, filepath.Join(c.dir, *chrono.CoverFile)) + + assert.Equal(t, "7", *coverOf(t, store, "Final Fantasy VII").CoverExternalID, "found by its alternative name") + assert.Equal(t, "3", *coverOf(t, store, "Lemmings (SNES + Mega Drive)").CoverExternalID, "found without the part in brackets") + bebop := coverOf(t, store, "Cowboy Bebop") + assert.Equal(t, "anilist", *bebop.CoverSource, "AniList when SteamGridDB has nothing") + + unknown := coverOf(t, store, "Unknown") + assert.Nil(t, unknown.CoverFile) + assert.True(t, unknown.CoverChecked.Valid, "marked as searched") + assert.NotContains(t, apis.searched(), "anilist:FF7", "SteamGridDB found it first") + _ = ff + + // Searched soundtracks are not searched again until the recheck time. + before := len(apis.searched()) + require.NoError(t, c.searchMissing(context.Background())) + assert.Len(t, apis.searched(), before) +} + +func TestCoverSearchAniListNeedsAnExactTitle(t *testing.T) { + apis := newCoverAPIs(t) + apis.anime["Final Fantasy VII"] = "Final Fantasy VII: Advent Children" + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"}) + c := newCovers(t, store, apis.options(true)) + + require.NoError(t, c.searchMissing(context.Background())) + + assert.Nil(t, coverOf(t, store, "Final Fantasy VII").CoverFile, "a near match is not used") +} + +func TestCoverSearchStopsWhenAnAPIFails(t *testing.T) { + apis := newCoverAPIs(t) + apis.failSteamGridDB = true + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "A"}) + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "B"}) + c := newCovers(t, store, apis.options(true)) + + err := c.searchMissing(context.Background()) + assert.ErrorIs(t, err, ErrUpstream) + + assert.Equal(t, []string{"sgdb:A"}, apis.searched(), "the other soundtracks are not tried") + assert.False(t, coverOf(t, store, "A").CoverChecked.Valid, "searched again next time") +} + +func TestCoverSearchSkipsManualCovers(t *testing.T) { + apis := newCoverAPIs(t) + apis.games["Chrono Trigger"] = 1 + store := fakedb.New() + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Chrono Trigger"}) + c := newCovers(t, store, apis.options(true)) + require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t))) + require.NoError(t, store.ClearCover(context.Background(), uuid.Nil)) // no-op + + require.NoError(t, c.searchMissing(context.Background())) + + assert.Empty(t, apis.searched()) + assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource) + + // A cover set by hand while the job was searching is kept. + stored, err := c.storeFound(context.Background(), st.Uuid, pngBytes(t), "steamgriddb", "1") + require.NoError(t, err) + assert.False(t, stored) + assert.Equal(t, SourceUpload, *coverOf(t, store, "Chrono Trigger").CoverSource) +} + +func TestCoverSearchAgainAfterRecheckTime(t *testing.T) { + apis := newCoverAPIs(t) + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Old", + CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-coverRecheckAfter - time.Hour), Valid: true}}) + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Recent", + CoverChecked: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}) + c := newCovers(t, store, apis.options(true)) + + require.NoError(t, c.searchMissing(context.Background())) + + assert.Contains(t, apis.searched(), "sgdb:Old") + assert.NotContains(t, apis.searched(), "sgdb:Recent") +} + +func TestCoverUpload(t *testing.T) { + store := fakedb.New() + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) + c := newCovers(t, store, CoverOptions{}) + ctx := context.Background() + jpeg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, make([]byte, 100)...) + + require.NoError(t, c.Upload(ctx, st.Uuid, jpeg)) + got := coverOf(t, store, "Game") + assert.Equal(t, st.Uuid.String()+".jpg", *got.CoverFile) + assert.True(t, got.CoverManual) + assert.Nil(t, got.CoverExternalID) + path, err := c.Path(ctx, st.Uuid) + require.NoError(t, err) + data, _ := os.ReadFile(path) + assert.Equal(t, jpeg, data) + + // A PNG replaces the JPEG, and its old file is removed. + require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t))) + assert.Equal(t, st.Uuid.String()+".png", *coverOf(t, store, "Game").CoverFile) + assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".jpg")) + + assert.ErrorIs(t, c.Upload(ctx, st.Uuid, []byte("")), ErrInvalidImage) + assert.ErrorIs(t, c.Upload(ctx, st.Uuid, append(pngBytes(t), make([]byte, MaxCoverBytes)...)), ErrInvalidImage) + assert.ErrorIs(t, c.Upload(ctx, uuid.New(), pngBytes(t)), ErrNotFound) + entries, _ := os.ReadDir(c.dir) + assert.Len(t, entries, 1, "no temporary files are left behind") +} + +func TestCoverFromSource(t *testing.T) { + apis := newCoverAPIs(t) + store := fakedb.New() + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) + ctx := context.Background() + + c := newCovers(t, store, apis.options(true)) + require.NoError(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "5")) + got := coverOf(t, store, "Game") + assert.Equal(t, "steamgriddb", *got.CoverSource) + assert.Equal(t, "5", *got.CoverExternalID) + assert.True(t, got.CoverManual, "a picked cover is never replaced by the job") + + require.NoError(t, c.FromSource(ctx, st.Uuid, "anilist", "42")) + assert.Equal(t, "anilist", *coverOf(t, store, "Game").CoverSource) + + assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "404"), ErrNotFound, "no grids") + assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "steamgriddb", "abc"), ErrNotFound) + assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "anilist", "7"), ErrNotFound) + assert.ErrorIs(t, c.FromSource(ctx, st.Uuid, "igdb", "1"), ErrInvalidName) + assert.ErrorIs(t, c.FromSource(ctx, uuid.New(), "anilist", "42"), ErrNotFound) + + withoutKey := newCovers(t, store, apis.options(false)) + assert.ErrorIs(t, withoutKey.FromSource(ctx, st.Uuid, "steamgriddb", "5"), ErrInvalidName) + require.NoError(t, withoutKey.FromSource(ctx, st.Uuid, "anilist", "42"), "AniList needs no key") +} + +func TestCoverDownloadMustBeAnImage(t *testing.T) { + apis := newCoverAPIs(t) + c := newCovers(t, fakedb.New(), apis.options(true)) + _, err := c.download(context.Background(), apis.server.URL+"/img/text.png") + assert.ErrorIs(t, err, ErrUpstream) +} + +func TestCoverRemove(t *testing.T) { + store := fakedb.New() + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) + c := newCovers(t, store, CoverOptions{}) + ctx := context.Background() + require.NoError(t, c.Upload(ctx, st.Uuid, pngBytes(t))) + + require.NoError(t, c.Remove(ctx, st.Uuid)) + + got := coverOf(t, store, "Game") + assert.Nil(t, got.CoverFile) + assert.False(t, got.CoverManual) + assert.False(t, got.CoverChecked.Valid, "the job searches for it again") + assert.NoFileExists(t, filepath.Join(c.dir, st.Uuid.String()+".png")) + _, err := c.Path(ctx, st.Uuid) + assert.ErrorIs(t, err, ErrNotFound) + assert.Len(t, c.trigger, 1, "the job is asked to search") +} + +func TestCoverRemoveOrphans(t *testing.T) { + store := fakedb.New() + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) + c := newCovers(t, store, CoverOptions{}) + require.NoError(t, c.Upload(context.Background(), st.Uuid, pngBytes(t))) + writeFile(t, filepath.Join(c.dir, uuid.NewString()+".png"), "left over after a reset") + + c.removeOrphans(context.Background()) + + entries, _ := os.ReadDir(c.dir) + require.Len(t, entries, 1) + assert.Equal(t, st.Uuid.String()+".png", entries[0].Name()) +} + +func TestCoverRunSearchesUntilStopped(t *testing.T) { + apis := newCoverAPIs(t) + apis.games["Game"] = 1 + store := fakedb.New() + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) + c := newCovers(t, store, apis.options(true)) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { c.Run(ctx); close(done) }() + + assert.Eventually(t, func() bool { return coverOf(t, store, "Game").CoverFile != nil }, time.Second, 10*time.Millisecond, + "searches when started") + store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Game"}) // same name, new soundtrack + c.Trigger() + assert.Eventually(t, func() bool { return strings.Count(strings.Join(apis.searched(), ","), "sgdb:Game") == 2 }, + time.Second, 10*time.Millisecond, "searches again when triggered") + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run did not stop") + } +} + +func TestCoverRunWithoutKeyDoesNothing(t *testing.T) { + c := newCovers(t, fakedb.New(), CoverOptions{}) + assert.False(t, c.SearchesAutomatically()) + done := make(chan struct{}) + go func() { c.Run(context.Background()); close(done) }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Run should return at once without a SteamGridDB key") + } +} + +func TestCoverSearchNames(t *testing.T) { + assert.Equal(t, []string{"Lemmings (SNES + Mega Drive)", "LEM", "lemmings"}, + coverSearchNames("Lemmings (SNES + Mega Drive)", []string{"LEM", "lemmings"})) + assert.Equal(t, []string{"Chrono Trigger"}, coverSearchNames("Chrono Trigger", nil)) + assert.Equal(t, []string{"Resident Evil 2 [Remake]", "Resident Evil 2"}, coverSearchNames("Resident Evil 2 [Remake]", nil)) +} + +func TestNormalizeTitle(t *testing.T) { + assert.Equal(t, normalizeTitle("Cowboy Bebop: The Movie"), normalizeTitle("cowboy bebop the movie")) + assert.Equal(t, "ファイナルファンタジーvii", normalizeTitle("ファイナルファンタジーVII")) +} + +func TestBestGame(t *testing.T) { + games := func(names ...string) []steamGridDBGame { + result := make([]steamGridDBGame, len(names)) + for i, name := range names { + result[i] = steamGridDBGame{ID: i + 1, Name: name} + } + return result + } + tests := []struct { + search string + games []steamGridDBGame + want string + }{ + // Real SteamGridDB results, in their order. + {"Civilization IV", games("Sid Meier's Civilization IV: Colonization", "Sid Meier's Civilization IV", + "Sid Meier's Civilization IV: Warlords", "Galactic Civilizations IV"), "Sid Meier's Civilization IV"}, + {"Lemmings 3D", games("3D Lemmings Winterland", "3D Lemmings", "Lemmings"), "3D Lemmings"}, + {"Octopath Traveler 2", games("Octopath Traveler II", "Octopath Traveler"), "Octopath Traveler II"}, + {"Joe & Mac", games("Joe & Mac: Caveman Ninja", "Joe & Mac Returns"), "Joe & Mac: Caveman Ninja"}, + {"Ys (Chronicles)", games("Ys I & II Chronicles", "Ys Origin"), "Ys I & II Chronicles"}, + // No result has every word: SteamGridDB's first result. + {"Resident Evil 2 (Remake)", games("Resident Evil 2", "Resident Evil 2: 1-Shot Demo"), "Resident Evil 2"}, + {"Pokémon HeartGold & SoulSilver", games("Pokémon HeartGold Version", "Pokémon SoulSilver Version"), "Pokémon HeartGold Version"}, + {"Kingdom Hearts", games("Kingdom Hearts II", "Kingdom Hearts"), "Kingdom Hearts"}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, bestGame(tt.search, tt.games).Name, tt.search) + } +} diff --git a/internal/backend/errors.go b/internal/backend/errors.go index 16572cf..c7d7d06 100644 --- a/internal/backend/errors.go +++ b/internal/backend/errors.go @@ -11,6 +11,10 @@ var ( ErrNotFound = errors.New("not found") // ErrInvalidName is returned when a requested file name is not allowed. ErrInvalidName = errors.New("invalid name") + // ErrInvalidImage is returned when an image is not a supported type or too large. + ErrInvalidImage = errors.New("invalid image") + // ErrCoversDisabled is returned when covers are used but COVERS_PATH is not set. + ErrCoversDisabled = errors.New("covers are not enabled on this server") // ErrUpstream is returned when an external service could not be reached. ErrUpstream = errors.New("upstream service unavailable") ) diff --git a/internal/backend/library.go b/internal/backend/library.go index 8b2a38d..cc8a85f 100644 --- a/internal/backend/library.go +++ b/internal/backend/library.go @@ -3,23 +3,30 @@ package backend import ( "context" "errors" + "strings" "time" "music-server/internal/db/repository" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // Soundtrack is a soundtrack as returned by the API. type Soundtrack struct { - Uuid uuid.UUID `json:"uuid"` - Name string `json:"name"` - TimesPlayed int32 `json:"times_played"` - LastPlayed *time.Time `json:"last_played,omitempty"` - Added time.Time `json:"added"` + Uuid uuid.UUID `json:"uuid"` + Name string `json:"name"` + AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"` + TimesPlayed int32 `json:"times_played"` + LastPlayed *time.Time `json:"last_played,omitempty"` + Added time.Time `json:"added"` + CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."` } +// MaxNameLength is the longest alternative name that can be stored, in bytes. +const MaxNameLength = 200 + // SoundtrackWithSongList is a soundtrack with all its songs. type SoundtrackWithSongList struct { Soundtrack @@ -48,14 +55,65 @@ func NewLibraryService(q repository.Querier) *LibraryService { func toSoundtrack(s repository.Soundtrack) Soundtrack { return Soundtrack{ - Uuid: s.Uuid, - Name: s.SoundtrackName, - TimesPlayed: s.TimesPlayed, - LastPlayed: s.LastPlayed, - Added: s.Added, + Uuid: s.Uuid, + Name: s.SoundtrackName, + AlternativeNames: nonNil(s.AlternativeNames), + TimesPlayed: s.TimesPlayed, + LastPlayed: s.LastPlayed, + Added: s.Added, + CoverModified: unixSeconds(s.CoverModified), } } +// nonNil returns names, or an empty list for nil, so the JSON is [] and never null. +func nonNil(names []string) []string { + if names == nil { + return []string{} + } + return names +} + +// unixSeconds returns t in Unix seconds, or nil when it is not set. +func unixSeconds(t pgtype.Timestamptz) *int64 { + if !t.Valid { + return nil + } + seconds := t.Time.Unix() + return &seconds +} + +// SetAlternativeNames replaces the alternative names of an active soundtrack. The names +// are trimmed, and empty names, duplicates and names equal to the soundtrack's own name +// (ignoring case) are dropped. +func (l *LibraryService) SetAlternativeNames(ctx context.Context, id uuid.UUID, names []string) (Soundtrack, error) { + row, err := l.q.GetSoundtrackByUUID(ctx, id) + if err != nil { + return Soundtrack{}, notFound(err) + } + row, err = l.q.SetAlternativeNames(ctx, repository.SetAlternativeNamesParams{ + Uuid: id, Names: cleanAlternativeNames(row.SoundtrackName, names), + }) + if err != nil { + return Soundtrack{}, notFound(err) + } + return toSoundtrack(row), nil +} + +func cleanAlternativeNames(name string, names []string) []string { + cleaned := []string{} + seen := map[string]bool{strings.ToLower(strings.TrimSpace(name)): true} + for _, n := range names { + n = strings.TrimSpace(n) + key := strings.ToLower(n) + if n == "" || seen[key] { + continue + } + seen[key] = true + cleaned = append(cleaned, n) + } + return cleaned +} + func notFound(err error) error { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound diff --git a/internal/backend/library_test.go b/internal/backend/library_test.go index a707ebf..ec035d8 100644 --- a/internal/backend/library_test.go +++ b/internal/backend/library_test.go @@ -10,6 +10,7 @@ import ( "music-server/internal/testutil/fakedb" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -24,11 +25,54 @@ func TestLibrarySoundtracks(t *testing.T) { soundtracks, err := NewLibraryService(store).Soundtracks(context.Background()) require.NoError(t, err) require.Len(t, soundtracks, 2) - assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game"}, soundtracks[0]) + assert.Equal(t, Soundtrack{Uuid: a.Uuid, Name: "A Game", AlternativeNames: []string{}}, soundtracks[0]) assert.Equal(t, b.Uuid, soundtracks[1].Uuid) assert.Equal(t, int32(3), soundtracks[1].TimesPlayed) } +func TestLibrarySetAlternativeNames(t *testing.T) { + store := fakedb.New() + st := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Final Fantasy VII"}) + l := NewLibraryService(store) + ctx := context.Background() + + soundtrack, err := l.SetAlternativeNames(ctx, st.Uuid, + []string{" FF7 ", "ff7", "", "final fantasy vii", "ファイナルファンタジーVII", " "}) + require.NoError(t, err) + assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, soundtrack.AlternativeNames, + "trimmed, without empty names, duplicates or the soundtrack's own name") + + stored, err := l.Soundtrack(ctx, st.Uuid) + require.NoError(t, err) + assert.Equal(t, []string{"FF7", "ファイナルファンタジーVII"}, stored.AlternativeNames) + + soundtrack, err = l.SetAlternativeNames(ctx, st.Uuid, nil) + require.NoError(t, err) + assert.Equal(t, []string{}, soundtrack.AlternativeNames, "an empty list removes them") + + _, err = l.SetAlternativeNames(ctx, uuid.New(), []string{"x"}) + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestLibrarySetAlternativeNamesSearchesForACoverAgain(t *testing.T) { + store := fakedb.New() + checked := pgtype.Timestamptz{Time: time.Now(), Valid: true} + without := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "No cover", CoverChecked: checked}) + file := "cover.jpg" + with := store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Cover", CoverFile: &file, CoverChecked: checked}) + l := NewLibraryService(store) + + _, err := l.SetAlternativeNames(context.Background(), without.Uuid, []string{"x"}) + require.NoError(t, err) + _, err = l.SetAlternativeNames(context.Background(), with.Uuid, []string{"y"}) + require.NoError(t, err) + + st, _ := store.Soundtrack("No cover") + assert.False(t, st.CoverChecked.Valid, "searched again with the new names") + st, _ = store.Soundtrack("Cover") + assert.True(t, st.CoverChecked.Valid, "a soundtrack with a cover keeps it") +} + func TestLibrarySoundtrackWithSongs(t *testing.T) { store := fakedb.New() st := addSoundtrackWithSongs(t, store, t.TempDir(), "Game A", 0, "Song B", "Song A") diff --git a/internal/backend/music.go b/internal/backend/music.go index 591f998..39e4e31 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -348,6 +348,19 @@ func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) { return names, nil } +// SearchSoundtracks returns every soundtrack's name and alternative names, for search. +func (m *MusicService) SearchSoundtracks(ctx context.Context) ([]SearchSoundtrack, error) { + soundtracks, err := m.allSoundtracks(ctx) + if err != nil { + return nil, err + } + result := make([]SearchSoundtrack, len(soundtracks)) + for i, s := range soundtracks { + result[i] = SearchSoundtrack{Name: s.SoundtrackName, AlternativeNames: s.AlternativeNames} + } + return result, nil +} + // SoundtrackNamesRandom returns all soundtrack names in random order. func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) { names, err := m.SoundtrackNames(ctx) diff --git a/internal/backend/search_cache.go b/internal/backend/search_cache.go index b948416..b054580 100644 --- a/internal/backend/search_cache.go +++ b/internal/backend/search_cache.go @@ -6,23 +6,40 @@ import ( "sync" ) -// SoundtrackData holds precomputed search data for a soundtrack -type SoundtrackData struct { +// SearchSoundtrack is a soundtrack's name and alternative names, as loaded for search. +type SearchSoundtrack struct { + Name string + AlternativeNames []string +} + +// SearchName holds a name prepared for matching. +type SearchName struct { Original string Lowercase string Cleaned string } -// SearchCache holds precomputed search data for all soundtrack names. +func newSearchName(name string) SearchName { + return SearchName{Original: name, Lowercase: strings.ToLower(name), Cleaned: CleanSearchTerm(name)} +} + +// SoundtrackData holds precomputed search data for a soundtrack: its name and its +// alternative names. +type SoundtrackData struct { + SearchName + Alternatives []SearchName +} + +// SearchCache holds precomputed search data for all soundtracks. type SearchCache struct { - load func(context.Context) ([]string, error) + load func(context.Context) ([]SearchSoundtrack, error) mu sync.Mutex cache []SoundtrackData } -// NewSearchCache creates a SearchCache that gets the soundtrack names from load. -func NewSearchCache(load func(context.Context) ([]string, error)) *SearchCache { +// NewSearchCache creates a SearchCache that gets the soundtracks from load. +func NewSearchCache(load func(context.Context) ([]SearchSoundtrack, error)) *SearchCache { return &SearchCache{load: load} } @@ -52,10 +69,9 @@ func (s *SearchCache) reload(ctx context.Context) error { } cache := make([]SoundtrackData, len(soundtracks)) for i, soundtrack := range soundtracks { - cache[i] = SoundtrackData{ - Original: soundtrack, - Lowercase: strings.ToLower(soundtrack), - Cleaned: CleanSearchTerm(soundtrack), + cache[i] = SoundtrackData{SearchName: newSearchName(soundtrack.Name)} + for _, name := range soundtrack.AlternativeNames { + cache[i].Alternatives = append(cache[i].Alternatives, newSearchName(name)) } } s.cache = cache diff --git a/internal/backend/search_cache_test.go b/internal/backend/search_cache_test.go index bd2c9af..96e1be2 100644 --- a/internal/backend/search_cache_test.go +++ b/internal/backend/search_cache_test.go @@ -40,28 +40,31 @@ func TestMatchesSubsequence(t *testing.T) { } func TestSearchCache(t *testing.T) { - names := []string{"Final Fantasy VII"} + soundtracks := []SearchSoundtrack{{Name: "Final Fantasy VII", AlternativeNames: []string{"FF 7"}}} calls := 0 - cache := NewSearchCache(func(context.Context) ([]string, error) { + cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) { calls++ - return names, nil + return soundtracks, nil }) ctx := context.Background() data, err := cache.Get(ctx) require.NoError(t, err) - assert.Equal(t, []SoundtrackData{{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}}, data) + assert.Equal(t, []SoundtrackData{{ + SearchName: SearchName{Original: "Final Fantasy VII", Lowercase: "final fantasy vii", Cleaned: "finalfantasyvii"}, + Alternatives: []SearchName{{Original: "FF 7", Lowercase: "ff 7", Cleaned: "ff7"}}, + }}, data) _, _ = cache.Get(ctx) assert.Equal(t, 1, calls, "data is cached") - names = []string{"Chrono Trigger"} + soundtracks = []SearchSoundtrack{{Name: "Chrono Trigger"}} require.NoError(t, cache.Reload(ctx)) data, _ = cache.Get(ctx) assert.Equal(t, "Chrono Trigger", data[0].Original) } func TestSearchCacheLoadError(t *testing.T) { - cache := NewSearchCache(func(context.Context) ([]string, error) { return nil, errors.New("db down") }) + cache := NewSearchCache(func(context.Context) ([]SearchSoundtrack, error) { return nil, errors.New("db down") }) _, err := cache.Get(context.Background()) assert.Error(t, err) } diff --git a/internal/backend/statistics.go b/internal/backend/statistics.go index f61301f..d0b402a 100644 --- a/internal/backend/statistics.go +++ b/internal/backend/statistics.go @@ -15,8 +15,10 @@ type SoundtrackWithSongs struct { SoundtrackID int32 `json:"soundtrack_id"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackName string `json:"soundtrack_name"` + AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"` SoundtrackPlayed int32 `json:"soundtrack_played"` SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played,omitempty"` + CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."` Songs []SongInfoForStats `json:"songs"` } @@ -74,8 +76,10 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs, SoundtrackID: row.SoundtrackID, SoundtrackUuid: row.SoundtrackUuid, SoundtrackName: row.SoundtrackName, + AlternativeNames: nonNil(row.AlternativeNames), SoundtrackPlayed: row.SoundtrackPlayed, SoundtrackLastPlayed: row.SoundtrackLastPlayed, + CoverModified: unixSeconds(row.CoverModified), Songs: parseSongs(row.Songs), }) } @@ -144,7 +148,9 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound SoundtrackID: row.SoundtrackID, SoundtrackUuid: row.SoundtrackUuid, SoundtrackName: row.SoundtrackName, + AlternativeNames: nonNil(row.AlternativeNames), SoundtrackPlayed: row.SoundtrackPlayed, + CoverModified: unixSeconds(row.CoverModified), Songs: parseSongs(row.Songs), }) } diff --git a/internal/backend/sync_test.go b/internal/backend/sync_test.go index c94b622..090252d 100644 --- a/internal/backend/sync_test.go +++ b/internal/backend/sync_test.go @@ -403,3 +403,34 @@ func TestResetDB(t *testing.T) { func TestFormatDuration(t *testing.T) { assert.Equal(t, "01:02:03", formatDuration(time.Hour+2*time.Minute+3*time.Second)) } + +func TestSyncKeepsAlternativeNamesAndCover(t *testing.T) { + f := newSyncFixture(t) + f.write("Final Fantasy 7/01.mp3", "a1") + f.run(false) + st := f.soundtrack("Final Fantasy 7") + _, err := f.store.SetAlternativeNames(context.Background(), + repository.SetAlternativeNamesParams{Uuid: st.Uuid, Names: []string{"FF7"}}) + require.NoError(t, err) + file, source := "cover.jpg", "upload" + require.NoError(t, f.store.SetCover(context.Background(), + repository.SetCoverParams{Uuid: st.Uuid, File: &file, Source: &source, Manual: true})) + + // Renamed: the same row is kept. + require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy 7"), filepath.Join(f.dir, "Final Fantasy VII"))) + f.run(false) + renamed := f.soundtrack("Final Fantasy VII") + assert.Equal(t, st.Uuid, renamed.Uuid) + assert.Equal(t, []string{"FF7"}, renamed.AlternativeNames) + assert.Equal(t, &file, renamed.CoverFile) + + // Removed and added again. + require.NoError(t, os.Rename(filepath.Join(f.dir, "Final Fantasy VII"), filepath.Join(t.TempDir(), "away"))) + f.run(false) + require.NoError(t, os.MkdirAll(filepath.Join(f.dir, "Final Fantasy VII"), 0o755)) + f.write("Final Fantasy VII/01.mp3", "a1") + f.run(false) + readded := f.soundtrack("Final Fantasy VII") + assert.Nil(t, readded.Deleted) + assert.Equal(t, []string{"FF7"}, readded.AlternativeNames) +} diff --git a/internal/config/config.go b/internal/config/config.go index 64db90c..2da6c2c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,14 @@ type Config struct { CharactersPath string SoundCheckPath string FrontendPath string + // CoversPath is the folder for soundtrack covers. Empty turns covers off. + CoversPath string + + // SteamGridDBAPIKey turns on the background search for covers. + SteamGridDBAPIKey string + // SteamGridDBURL and AniListURL replace the real cover APIs; only tests set them. + SteamGridDBURL string + AniListURL string LogLevel string LogJSON bool @@ -65,6 +73,8 @@ func load(getenv func(string) string) (Config, error) { CharactersPath: getenv("CHARACTERS_PATH"), SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"), FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"), + CoversPath: getenv("COVERS_PATH"), + SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"), LogLevel: withDefault(getenv("LOG_LEVEL"), "info"), LogJSON: getenv("LOG_JSON") == "true", APIPassword: getenv("API_PASSWORD"), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bcc34c8..7ccf7a2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -33,6 +33,18 @@ func TestLoadDefaults(t *testing.T) { assert.Empty(t, cfg.TrustedProxies) assert.Empty(t, cfg.CORSAllowedOrigins) assert.Equal(t, DefaultMetricsPort, cfg.MetricsPort) + assert.Empty(t, cfg.CoversPath, "covers are off by default") + assert.Empty(t, cfg.SteamGridDBAPIKey) +} + +func TestLoadCovers(t *testing.T) { + values := validEnv() + values["COVERS_PATH"] = "/covers" + values["STEAMGRIDDB_API_KEY"] = "key" + cfg, err := load(env(values)) + require.NoError(t, err) + assert.Equal(t, "/covers", cfg.CoversPath) + assert.Equal(t, "key", cfg.SteamGridDBAPIKey) } func TestLoadMetricsPort(t *testing.T) { diff --git a/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql b/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql index 6b58d09..2dd622f 100644 --- a/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql +++ b/internal/db/migrations/000006_add_fingerprint_and_queue.down.sql @@ -1,3 +1,11 @@ +ALTER TABLE soundtrack + DROP COLUMN alternative_names, + DROP COLUMN cover_file, + DROP COLUMN cover_source, + DROP COLUMN cover_external_id, + DROP COLUMN cover_manual, + DROP COLUMN cover_modified, + DROP COLUMN cover_checked; DROP TABLE queue_song; DROP TABLE queue; ALTER TABLE soundtrack DROP COLUMN fingerprint; diff --git a/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql b/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql index 13bf09e..e696486 100644 --- a/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql +++ b/internal/db/migrations/000006_add_fingerprint_and_queue.up.sql @@ -27,3 +27,17 @@ CREATE TABLE queue_song ( added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (queue_uuid, song_no) ); + +-- Other names a soundtrack is known by, for search and the cover search. +ALTER TABLE soundtrack ADD COLUMN alternative_names TEXT[] NOT NULL DEFAULT '{}'; + +-- Cover art. cover_file is the image's file name in COVERS_PATH, NULL when there is no +-- cover. Manual covers (uploaded or picked) are never replaced by the background job. +-- cover_checked is when the job last searched, so soundtracks without a match are not +-- searched again for a while. +ALTER TABLE soundtrack ADD COLUMN cover_file TEXT NULL; +ALTER TABLE soundtrack ADD COLUMN cover_source VARCHAR(20) NULL; +ALTER TABLE soundtrack ADD COLUMN cover_external_id TEXT NULL; +ALTER TABLE soundtrack ADD COLUMN cover_manual BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE soundtrack ADD COLUMN cover_modified TIMESTAMPTZ NULL; +ALTER TABLE soundtrack ADD COLUMN cover_checked TIMESTAMPTZ NULL; diff --git a/internal/db/queries/soundtrack.sql b/internal/db/queries/soundtrack.sql index 1d6a103..ffd8fb9 100644 --- a/internal/db/queries/soundtrack.sql +++ b/internal/db/queries/soundtrack.sql @@ -65,3 +65,44 @@ SELECT uuid FROM soundtrack WHERE id = $1; -- name: AddSoundtrackPlayedByUUID :exec UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE uuid = $1; + +-- name: SetAlternativeNames :one +-- Replaces the alternative names. A soundtrack without a cover is searched again with them. +UPDATE soundtrack +SET alternative_names = sqlc.arg(names)::text[], + cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END +WHERE uuid = sqlc.arg(uuid) AND deleted IS NULL +RETURNING *; + +-- name: ListSoundtracksNeedingCover :many +-- Active soundtracks without a cover that the job has not searched since checked_before. +SELECT uuid, soundtrack_name, alternative_names +FROM soundtrack +WHERE deleted IS NULL + AND cover_file IS NULL + AND NOT cover_manual + AND (cover_checked IS NULL OR cover_checked < sqlc.arg(checked_before)) +ORDER BY soundtrack_name; + +-- name: SetCover :exec +UPDATE soundtrack +SET cover_file = sqlc.arg(file), + cover_source = sqlc.arg(source), + cover_external_id = sqlc.arg(external_id), + cover_manual = sqlc.arg(manual), + cover_modified = now(), + cover_checked = now() +WHERE uuid = sqlc.arg(uuid); + +-- name: MarkCoverChecked :exec +UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1; + +-- name: ClearCover :exec +-- Removes the cover, so the job searches for one again. +UPDATE soundtrack +SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false, + cover_modified = NULL, cover_checked = NULL +WHERE uuid = $1; + +-- name: ListCoverFiles :many +SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL; diff --git a/internal/db/queries/statistics.sql b/internal/db/queries/statistics.sql index e2ac549..1e9b995 100644 --- a/internal/db/queries/statistics.sql +++ b/internal/db/queries/statistics.sql @@ -5,6 +5,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -33,6 +35,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -95,6 +99,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.added, COALESCE( json_agg( @@ -121,6 +127,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -148,6 +156,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -188,6 +198,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index 4ae9c33..bfe9859 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -59,18 +59,25 @@ type SongList struct { } type Soundtrack struct { - ID int32 `json:"id"` - SoundtrackName string `json:"soundtrack_name"` - Added time.Time `json:"added"` - Deleted *time.Time `json:"deleted"` - LastChanged *time.Time `json:"last_changed"` - Path string `json:"path"` - TimesPlayed int32 `json:"times_played"` - LastPlayed *time.Time `json:"last_played"` - NumberOfSongs int32 `json:"number_of_songs"` - Hash string `json:"hash"` - Uuid uuid.UUID `json:"uuid"` - Fingerprint string `json:"fingerprint"` + ID int32 `json:"id"` + SoundtrackName string `json:"soundtrack_name"` + Added time.Time `json:"added"` + Deleted *time.Time `json:"deleted"` + LastChanged *time.Time `json:"last_changed"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + LastPlayed *time.Time `json:"last_played"` + NumberOfSongs int32 `json:"number_of_songs"` + Hash string `json:"hash"` + Uuid uuid.UUID `json:"uuid"` + Fingerprint string `json:"fingerprint"` + AlternativeNames []string `json:"alternative_names"` + CoverFile *string `json:"cover_file"` + CoverSource *string `json:"cover_source"` + CoverExternalID *string `json:"cover_external_id"` + CoverManual bool `json:"cover_manual"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + CoverChecked pgtype.Timestamptz `json:"cover_checked"` } type Vgmq struct { diff --git a/internal/db/repository/querier.go b/internal/db/repository/querier.go index a8dd494..d77acf9 100644 --- a/internal/db/repository/querier.go +++ b/internal/db/repository/querier.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) type Querier interface { @@ -23,6 +24,8 @@ type Querier interface { AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UUID) error CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) CheckSongWithHash(ctx context.Context, hash string) (int64, error) + // Removes the cover, so the job searches for one again. + ClearCover(ctx context.Context, argUuid uuid.UUID) error ClearSongs(ctx context.Context) error ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error ClearSoundtracks(ctx context.Context) error @@ -73,14 +76,21 @@ type Querier interface { InsertSongInList(ctx context.Context, arg InsertSongInListParams) error InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error + ListCoverFiles(ctx context.Context) ([]string, error) // The song columns are NULL when the song has been removed from the library since. ListQueueSongs(ctx context.Context, queueUuid uuid.UUID) ([]ListQueueSongsRow, error) ListSessions(ctx context.Context) ([]Session, error) + // Active soundtracks without a cover that the job has not searched since checked_before. + ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error) + MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error ResetSoundtrackIdSeq(ctx context.Context) (int64, error) + // Replaces the alternative names. A soundtrack without a cover is searched again with them. + SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error) + SetCover(ctx context.Context, arg SetCoverParams) error SetQueueCurrentSong(ctx context.Context, arg SetQueueCurrentSongParams) error SetQueueLastFetched(ctx context.Context, arg SetQueueLastFetchedParams) error SetSoundtrackDeletionDate(ctx context.Context) error diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go index c6062fe..283f7eb 100644 --- a/internal/db/repository/soundtrack.sql.go +++ b/internal/db/repository/soundtrack.sql.go @@ -9,6 +9,7 @@ import ( "context" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec @@ -29,6 +30,19 @@ func (q *Queries) AddSoundtrackPlayedByUUID(ctx context.Context, argUuid uuid.UU return err } +const clearCover = `-- name: ClearCover :exec +UPDATE soundtrack +SET cover_file = NULL, cover_source = NULL, cover_external_id = NULL, cover_manual = false, + cover_modified = NULL, cover_checked = NULL +WHERE uuid = $1 +` + +// Removes the cover, so the job searches for one again. +func (q *Queries) ClearCover(ctx context.Context, argUuid uuid.UUID) error { + _, err := q.db.Exec(ctx, clearCover, argUuid) + return err +} + const clearSoundtracks = `-- name: ClearSoundtracks :exec DELETE FROM soundtrack ` @@ -39,7 +53,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error { } const findAllSoundtracks = `-- name: FindAllSoundtracks :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked FROM soundtrack WHERE deleted IS NULL ORDER BY soundtrack_name @@ -67,6 +81,13 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) &i.Hash, &i.Uuid, &i.Fingerprint, + &i.AlternativeNames, + &i.CoverFile, + &i.CoverSource, + &i.CoverExternalID, + &i.CoverManual, + &i.CoverModified, + &i.CoverChecked, ); err != nil { return nil, err } @@ -79,7 +100,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) } const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked FROM soundtrack ORDER BY soundtrack_name ` @@ -106,6 +127,13 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun &i.Hash, &i.Uuid, &i.Fingerprint, + &i.AlternativeNames, + &i.CoverFile, + &i.CoverSource, + &i.CoverExternalID, + &i.CoverManual, + &i.CoverModified, + &i.CoverChecked, ); err != nil { return nil, err } @@ -129,7 +157,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri } const getSoundtrackById = `-- name: GetSoundtrackById :one -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked FROM soundtrack WHERE id = $1 AND deleted IS NULL @@ -151,12 +179,19 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, &i.Hash, &i.Uuid, &i.Fingerprint, + &i.AlternativeNames, + &i.CoverFile, + &i.CoverSource, + &i.CoverExternalID, + &i.CoverManual, + &i.CoverModified, + &i.CoverChecked, ) return i, err } const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked FROM soundtrack WHERE uuid = $1 AND deleted IS NULL @@ -178,6 +213,13 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S &i.Hash, &i.Uuid, &i.Fingerprint, + &i.AlternativeNames, + &i.CoverFile, + &i.CoverSource, + &i.CoverExternalID, + &i.CoverManual, + &i.CoverModified, + &i.CoverChecked, ) return i, err } @@ -253,6 +295,76 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert return err } +const listCoverFiles = `-- name: ListCoverFiles :many +SELECT cover_file::text FROM soundtrack WHERE cover_file IS NOT NULL +` + +func (q *Queries) ListCoverFiles(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, listCoverFiles) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var cover_file string + if err := rows.Scan(&cover_file); err != nil { + return nil, err + } + items = append(items, cover_file) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSoundtracksNeedingCover = `-- name: ListSoundtracksNeedingCover :many +SELECT uuid, soundtrack_name, alternative_names +FROM soundtrack +WHERE deleted IS NULL + AND cover_file IS NULL + AND NOT cover_manual + AND (cover_checked IS NULL OR cover_checked < $1) +ORDER BY soundtrack_name +` + +type ListSoundtracksNeedingCoverRow struct { + Uuid uuid.UUID `json:"uuid"` + SoundtrackName string `json:"soundtrack_name"` + AlternativeNames []string `json:"alternative_names"` +} + +// Active soundtracks without a cover that the job has not searched since checked_before. +func (q *Queries) ListSoundtracksNeedingCover(ctx context.Context, checkedBefore pgtype.Timestamptz) ([]ListSoundtracksNeedingCoverRow, error) { + rows, err := q.db.Query(ctx, listSoundtracksNeedingCover, checkedBefore) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSoundtracksNeedingCoverRow + for rows.Next() { + var i ListSoundtracksNeedingCoverRow + if err := rows.Scan(&i.Uuid, &i.SoundtrackName, &i.AlternativeNames); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markCoverChecked = `-- name: MarkCoverChecked :exec +UPDATE soundtrack SET cover_checked = now() WHERE uuid = $1 +` + +func (q *Queries) MarkCoverChecked(ctx context.Context, argUuid uuid.UUID) error { + _, err := q.db.Exec(ctx, markCoverChecked, argUuid) + return err +} + const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[])) ` @@ -282,6 +394,77 @@ func (q *Queries) ResetSoundtrackIdSeq(ctx context.Context) (int64, error) { return setval, err } +const setAlternativeNames = `-- name: SetAlternativeNames :one +UPDATE soundtrack +SET alternative_names = $1::text[], + cover_checked = CASE WHEN cover_file IS NULL THEN NULL ELSE cover_checked END +WHERE uuid = $2 AND deleted IS NULL +RETURNING id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint, alternative_names, cover_file, cover_source, cover_external_id, cover_manual, cover_modified, cover_checked +` + +type SetAlternativeNamesParams struct { + Names []string `json:"names"` + Uuid uuid.UUID `json:"uuid"` +} + +// Replaces the alternative names. A soundtrack without a cover is searched again with them. +func (q *Queries) SetAlternativeNames(ctx context.Context, arg SetAlternativeNamesParams) (Soundtrack, error) { + row := q.db.QueryRow(ctx, setAlternativeNames, arg.Names, arg.Uuid) + var i Soundtrack + err := row.Scan( + &i.ID, + &i.SoundtrackName, + &i.Added, + &i.Deleted, + &i.LastChanged, + &i.Path, + &i.TimesPlayed, + &i.LastPlayed, + &i.NumberOfSongs, + &i.Hash, + &i.Uuid, + &i.Fingerprint, + &i.AlternativeNames, + &i.CoverFile, + &i.CoverSource, + &i.CoverExternalID, + &i.CoverManual, + &i.CoverModified, + &i.CoverChecked, + ) + return i, err +} + +const setCover = `-- name: SetCover :exec +UPDATE soundtrack +SET cover_file = $1, + cover_source = $2, + cover_external_id = $3, + cover_manual = $4, + cover_modified = now(), + cover_checked = now() +WHERE uuid = $5 +` + +type SetCoverParams struct { + File *string `json:"file"` + Source *string `json:"source"` + ExternalID *string `json:"external_id"` + Manual bool `json:"manual"` + Uuid uuid.UUID `json:"uuid"` +} + +func (q *Queries) SetCover(ctx context.Context, arg SetCoverParams) error { + _, err := q.db.Exec(ctx, setCover, + arg.File, + arg.Source, + arg.ExternalID, + arg.Manual, + arg.Uuid, + ) + return err +} + const setSoundtrackDeletionDate = `-- name: SetSoundtrackDeletionDate :exec UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL ` diff --git a/internal/db/repository/statistics.sql.go b/internal/db/repository/statistics.sql.go index 89880af..36ed49b 100644 --- a/internal/db/repository/statistics.sql.go +++ b/internal/db/repository/statistics.sql.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) const countLibrary = `-- name: CountLibrary :one @@ -37,6 +38,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -59,12 +62,14 @@ ORDER BY g.soundtrack_name ` type GetAllSoundtracksWithSongsRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` - Songs []byte `json:"songs"` + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + AlternativeNames []string `json:"alternative_names"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` } // Every soundtrack with its songs, by name @@ -82,6 +87,8 @@ func (q *Queries) GetAllSoundtracksWithSongs(ctx context.Context) ([]GetAllSound &i.SoundtrackUuid, &i.SoundtrackName, &i.SoundtrackPlayed, + &i.AlternativeNames, + &i.CoverModified, &i.SoundtrackLastPlayed, &i.Songs, ); err != nil { @@ -101,6 +108,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -123,12 +132,14 @@ LIMIT $1 ` type GetLastPlayedSoundtracksRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` - Songs []byte `json:"songs"` + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + AlternativeNames []string `json:"alternative_names"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` } // Last played soundtracks (most recently played) @@ -146,6 +157,8 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([] &i.SoundtrackUuid, &i.SoundtrackName, &i.SoundtrackPlayed, + &i.AlternativeNames, + &i.CoverModified, &i.SoundtrackLastPlayed, &i.Songs, ); err != nil { @@ -223,6 +236,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -246,12 +261,14 @@ LIMIT $1 ` type GetLeastPlayedSoundtracksWithSongsRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` - Songs []byte `json:"songs"` + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + AlternativeNames []string `json:"alternative_names"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` } // Least played soundtracks with their songs @@ -269,6 +286,8 @@ func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit &i.SoundtrackUuid, &i.SoundtrackName, &i.SoundtrackPlayed, + &i.AlternativeNames, + &i.CoverModified, &i.SoundtrackLastPlayed, &i.Songs, ); err != nil { @@ -346,6 +365,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -369,12 +390,14 @@ LIMIT $1 ` type GetMostPlayedSoundtracksWithSongsRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` - Songs []byte `json:"songs"` + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + AlternativeNames []string `json:"alternative_names"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` } // Most played soundtracks with their songs @@ -392,6 +415,8 @@ func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit i &i.SoundtrackUuid, &i.SoundtrackName, &i.SoundtrackPlayed, + &i.AlternativeNames, + &i.CoverModified, &i.SoundtrackLastPlayed, &i.Songs, ); err != nil { @@ -411,6 +436,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.added, COALESCE( json_agg( @@ -432,12 +459,14 @@ ORDER BY g.soundtrack_name ` type GetNeverPlayedSoundtracksRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - Added time.Time `json:"added"` - Songs []byte `json:"songs"` + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + AlternativeNames []string `json:"alternative_names"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + Added time.Time `json:"added"` + Songs []byte `json:"songs"` } // Soundtracks that have never been played (times_played = 0) @@ -455,6 +484,8 @@ func (q *Queries) GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlay &i.SoundtrackUuid, &i.SoundtrackName, &i.SoundtrackPlayed, + &i.AlternativeNames, + &i.CoverModified, &i.Added, &i.Songs, ); err != nil { @@ -474,6 +505,8 @@ SELECT g.uuid as soundtrack_uuid, g.soundtrack_name, g.times_played as soundtrack_played, + g.alternative_names, + g.cover_modified, g.last_played as soundtrack_last_played, COALESCE( json_agg( @@ -496,12 +529,14 @@ LIMIT $1 ` type GetOldestPlayedSoundtracksRow struct { - SoundtrackID int32 `json:"soundtrack_id"` - SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` - SoundtrackName string `json:"soundtrack_name"` - SoundtrackPlayed int32 `json:"soundtrack_played"` - SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` - Songs []byte `json:"songs"` + SoundtrackID int32 `json:"soundtrack_id"` + SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` + SoundtrackName string `json:"soundtrack_name"` + SoundtrackPlayed int32 `json:"soundtrack_played"` + AlternativeNames []string `json:"alternative_names"` + CoverModified pgtype.Timestamptz `json:"cover_modified"` + SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"` + Songs []byte `json:"songs"` } // Oldest played soundtracks (least recently played, but has been played at least once) @@ -519,6 +554,8 @@ func (q *Queries) GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ( &i.SoundtrackUuid, &i.SoundtrackName, &i.SoundtrackPlayed, + &i.AlternativeNames, + &i.CoverModified, &i.SoundtrackLastPlayed, &i.Songs, ); err != nil { diff --git a/internal/server/api.go b/internal/server/api.go index 0bc1279..5609412 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -146,8 +146,10 @@ func apiError(err error) error { switch { case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound): return huma.Error404NotFound(err.Error()) - case errors.Is(err, backend.ErrInvalidName): + case errors.Is(err, backend.ErrInvalidName), errors.Is(err, backend.ErrInvalidImage): return huma.Error400BadRequest(err.Error()) + case errors.Is(err, backend.ErrCoversDisabled): + return huma.Error503ServiceUnavailable(err.Error()) case errors.Is(err, backend.ErrUpstream): return huma.Error502BadGateway(err.Error()) default: diff --git a/internal/server/cover_handler.go b/internal/server/cover_handler.go new file mode 100644 index 0000000..995d186 --- /dev/null +++ b/internal/server/cover_handler.go @@ -0,0 +1,114 @@ +package server + +import ( + "context" + "net/http" + + "music-server/internal/backend" + + "github.com/danielgtaylor/huma/v2" + "github.com/google/uuid" +) + +type coverUploadInput struct { + UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"` + RawBody []byte `contentType:"image/*" doc:"The image: JPEG, PNG or WebP, at most 10 MB"` +} + +// CoverSource is the body of PUT /soundtracks/{uuid}/cover/source. +type CoverSource struct { + Source string `json:"source" enum:"steamgriddb,anilist" doc:"Where the cover comes from"` + ID string `json:"id" minLength:"1" maxLength:"50" doc:"The game's ID on SteamGridDB, or the anime's ID on AniList"` +} + +type coverSourceInput struct { + UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"` + Body CoverSource +} + +func (s *Server) registerCovers(a huma.API) { + op := func(id, method, path, summary, description string, errs ...int) huma.Operation { + return huma.Operation{ + OperationID: id, + Method: method, + Path: path, + Summary: summary, + Description: description, + Tags: []string{"covers"}, + Errors: errs, + } + } + // covers returns the cover service, or ErrCoversDisabled when COVERS_PATH is not set. + covers := func() (*backend.CoverService, error) { + if s.covers == nil { + return nil, apiError(backend.ErrCoversDisabled) + } + return s.covers, nil + } + + get := op("get-soundtrack-cover", http.MethodGet, "/soundtracks/{uuid}/cover", "Soundtrack cover", + "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.", + http.StatusNotFound) + get.Responses = fileResponses("The cover image", "image/jpeg", "image/png", "image/webp") + register(s, a, route{op: get}, + func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) { + if s.covers == nil { + return nil, huma.Error404NotFound("Not Found") + } + path, err := s.covers.Path(ctx, in.id()) + if err != nil { + return nil, apiError(err) + } + return streamFile(path, "", false) + }) + + upload := op("upload-soundtrack-cover", http.MethodPut, "/soundtracks/{uuid}/cover", "Upload a cover", + "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.", + http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable) + upload.MaxBodyBytes = backend.MaxCoverBytes + upload.DefaultStatus = http.StatusNoContent + register(s, a, route{op: upload}, + func(ctx context.Context, in *coverUploadInput) (*struct{}, error) { + c, err := covers() + if err != nil { + return nil, err + } + if err := c.Upload(ctx, uuid.MustParse(in.UUID), in.RawBody); err != nil { + return nil, apiError(err) + } + return nil, nil + }) + + source := op("set-soundtrack-cover-source", http.MethodPut, "/soundtracks/{uuid}/cover/source", "Pick a cover", + "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. "+ + "The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.", + http.StatusBadRequest, http.StatusNotFound, http.StatusBadGateway, http.StatusServiceUnavailable) + source.DefaultStatus = http.StatusNoContent + register(s, a, route{op: source}, + func(ctx context.Context, in *coverSourceInput) (*struct{}, error) { + c, err := covers() + if err != nil { + return nil, err + } + if err := c.FromSource(ctx, uuid.MustParse(in.UUID), in.Body.Source, in.Body.ID); err != nil { + return nil, apiError(err) + } + return nil, nil + }) + + remove := op("delete-soundtrack-cover", http.MethodDelete, "/soundtracks/{uuid}/cover", "Remove the cover", + "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.", + http.StatusNotFound, http.StatusServiceUnavailable) + remove.DefaultStatus = http.StatusNoContent + register(s, a, route{op: remove}, + func(ctx context.Context, in *uuidInput) (*struct{}, error) { + c, err := covers() + if err != nil { + return nil, err + } + if err := c.Remove(ctx, in.id()); err != nil { + return nil, apiError(err) + } + return nil, nil + }) +} diff --git a/internal/server/cover_handler_test.go b/internal/server/cover_handler_test.go new file mode 100644 index 0000000..a633f68 --- /dev/null +++ b/internal/server/cover_handler_test.go @@ -0,0 +1,143 @@ +package server + +import ( + "bytes" + "encoding/json" + "image" + "image/png" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "music-server/internal/backend" + "music-server/internal/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func pngImage(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 3)))) + return buf.Bytes() +} + +// newFakeAniList answers AniList lookups by ID: ID 42 has a cover, others do not. +func newFakeAniList(t *testing.T) string { + img := pngImage(t) + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write(img) + return + } + var req struct { + Variables struct { + ID int `json:"id"` + } `json:"variables"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + media := any(nil) + if req.Variables.ID == 42 { + media = map[string]any{"id": 42, "coverImage": map[string]any{"extraLarge": server.URL + "/cover.png"}} + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"Media": media}}) + })) + t.Cleanup(server.Close) + return server.URL +} + +func withCovers(t *testing.T) func(*config.Config) { + aniList := newFakeAniList(t) + dir := filepath.Join(t.TempDir(), "covers") + return func(c *config.Config) { + c.CoversPath = dir + c.AniListURL = aniList + } +} + +func (e *testEnv) put(path, contentType string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + e.handler.ServeHTTP(rec, req) + return rec +} + +func TestCoverUploadAndGet(t *testing.T) { + e := newTestEnv(t, withCovers(t)) + song := e.addSong("Game A", "Song", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover" + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, "no cover yet") + + img := pngImage(t) + rec := e.put(path, "image/png", img) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + + rec = e.do(http.MethodGet, path) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) + assert.Equal(t, img, rec.Body.Bytes()) + assert.NotEmpty(t, rec.Header().Get("Last-Modified")) + + var soundtrack backend.Soundtrack + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()).Body.Bytes(), &soundtrack)) + assert.NotNil(t, soundtrack.CoverModified) + + assert.Equal(t, http.StatusBadRequest, e.put(path, "image/png", []byte("not an image")).Code) + big := append(pngImage(t), make([]byte, backend.MaxCoverBytes)...) + assert.Equal(t, http.StatusRequestEntityTooLarge, e.put(path, "image/png", big).Code) + assert.Equal(t, http.StatusNotFound, + e.put("/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/cover", "image/png", img).Code) +} + +func TestCoverPickFromSource(t *testing.T) { + e := newTestEnv(t, withCovers(t)) + song := e.addSong("Game A", "Song", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover" + + rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`) + require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String()) + assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code) + + assert.Equal(t, http.StatusNotFound, e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"7"}`).Code) + assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPut, path+"/source", `{"source":"steamgriddb","id":"7"}`).Code, + "SteamGridDB needs an API key") + assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path+"/source", `{"source":"igdb","id":"7"}`).Code) +} + +func TestCoverDelete(t *testing.T) { + e := newTestEnv(t, withCovers(t)) + song := e.addSong("Game A", "Song", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover" + require.Equal(t, http.StatusNoContent, e.put(path, "image/png", pngImage(t)).Code) + + assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, path).Code) + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code) +} + +func TestCoversDisabled(t *testing.T) { + e := newTestEnv(t) + song := e.addSong("Game A", "Song", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover" + + assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code) + assert.Equal(t, http.StatusServiceUnavailable, e.put(path, "image/png", pngImage(t)).Code) + assert.Equal(t, http.StatusServiceUnavailable, e.do(http.MethodDelete, path).Code) + rec := e.doBody(http.MethodPut, path+"/source", `{"source":"anilist","id":"42"}`) + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + body, _ := io.ReadAll(rec.Body) + assert.Contains(t, string(body), "not enabled") +} + +func TestCoverWritesRequireToken(t *testing.T) { + e := newTestEnv(t, withAuth("secret"), withCovers(t)) + song := e.addSong("Game A", "Song", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover" + assert.Equal(t, http.StatusUnauthorized, e.put(path, "image/png", pngImage(t)).Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, path).Code) + assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, path).Code) +} diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index e5f8488..bcefcac 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -334,6 +334,8 @@ func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.Ge return []repository.GetAllSoundtracksWithSongsRow{{ SoundtrackUuid: statsSoundtrackUUID, SoundtrackName: "Game A", + AlternativeNames: []string{"GA"}, + CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true}, SoundtrackPlayed: 4, Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`), }, { @@ -375,6 +377,11 @@ func TestStatisticsEndpoints(t *testing.T) { assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed) assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list") + assert.Equal(t, []string{"GA"}, soundtracks[0].AlternativeNames) + require.NotNil(t, soundtracks[0].CoverModified) + assert.Equal(t, int64(1730000000), *soundtracks[0].CoverModified) + assert.Equal(t, []string{}, soundtracks[1].AlternativeNames, "no names is an empty list") + assert.Nil(t, soundtracks[1].CoverModified) var summary backend.StatisticsSummary require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary)) diff --git a/internal/server/library_handler.go b/internal/server/library_handler.go index c84a016..6b530a2 100644 --- a/internal/server/library_handler.go +++ b/internal/server/library_handler.go @@ -2,6 +2,7 @@ package server import ( "context" + "fmt" "net/http" "music-server/internal/backend" @@ -19,6 +20,20 @@ func (in *uuidInput) id() uuid.UUID { return uuid.MustParse(in.UUID) } +// AlternativeNames is the body of PUT /soundtracks/{uuid}/alternative-names. +type AlternativeNames struct { + Names []string `json:"names" maxItems:"50" doc:"The new list of alternative names. Replaces the old list; send [] to remove them all."` +} + +type alternativeNamesInput struct { + UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack"` + Body AlternativeNames +} + +func (in *alternativeNamesInput) id() uuid.UUID { + return uuid.MustParse(in.UUID) +} + func (s *Server) registerLibrary(a huma.API) { l := s.library op := func(id, path, summary, description string, errs ...int) huma.Operation { @@ -49,6 +64,27 @@ func (s *Server) registerLibrary(a huma.API) { return ok(l.Song(ctx, in.id())) }) + names := op("set-alternative-names", "/soundtracks/{uuid}/alternative-names", "Set alternative names", + "Replaces the soundtrack's alternative names, which search and the cover search also use. "+ + "Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.", + http.StatusNotFound) + names.Method = http.MethodPut + register(s, a, route{op: names}, + func(ctx context.Context, in *alternativeNamesInput) (*jsonBody[backend.Soundtrack], error) { + for _, name := range in.Body.Names { + if len(name) > backend.MaxNameLength { + return nil, huma.Error422UnprocessableEntity( + fmt.Sprintf("names can be at most %d bytes long", backend.MaxNameLength)) + } + } + soundtrack, err := l.SetAlternativeNames(ctx, in.id(), in.Body.Names) + if err != nil { + return nil, apiError(err) + } + s.soundtracksChanged(ctx) + return ok(soundtrack, nil) + }) + file := op("get-song-file", "/songs/{uuid}/file", "Stream song", "Returns the audio file of a song.", http.StatusNotFound) file.Responses = fileResponses("The song (MP3)", audioMPEG) diff --git a/internal/server/library_handler_test.go b/internal/server/library_handler_test.go index bd7c920..5f88389 100644 --- a/internal/server/library_handler_test.go +++ b/internal/server/library_handler_test.go @@ -3,6 +3,8 @@ package server import ( "encoding/json" "net/http" + "net/http/httptest" + "strings" "testing" "music-server/internal/backend" @@ -84,3 +86,47 @@ func TestQueueResponsesIncludeUUIDs(t *testing.T) { require.Len(t, list, 1) assert.Equal(t, song.Uuid.String(), list[0]["SongUuid"]) } + +func TestSetAlternativeNames(t *testing.T) { + e := newTestEnv(t) + song := e.addSong("Final Fantasy VII", "Prelude", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names" + + rec := e.doBody(http.MethodPut, path, `{"names":[" FF7 ","ff7","Final Fantasy VII"]}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var soundtrack backend.Soundtrack + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &soundtrack)) + assert.Equal(t, []string{"FF7"}, soundtrack.AlternativeNames) + + rec = e.do(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()) + assert.Contains(t, rec.Body.String(), `"alternative_names":["FF7"]`) + + // The search page finds it by the new name right away. + req := httptest.NewRequest(http.MethodPost, "/find", strings.NewReader("search_term=ff7")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec = httptest.NewRecorder() + e.handler.ServeHTTP(rec, req) + assert.Contains(t, rec.Body.String(), "Final Fantasy VII") + assert.Contains(t, rec.Body.String(), "Also known as FF7") +} + +func TestSetAlternativeNamesValidation(t *testing.T) { + e := newTestEnv(t) + song := e.addSong("Game A", "Song", "x") + path := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/alternative-names" + + long := strings.Repeat("a", backend.MaxNameLength+1) + assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{"names":["`+long+`"]}`).Code) + many := `{"names":["` + strings.Repeat(`x","`, 50) + `x"]}` + assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, many).Code, "at most 50 names") + assert.Equal(t, http.StatusUnprocessableEntity, e.doBody(http.MethodPut, path, `{}`).Code, "names is required") + assert.Equal(t, http.StatusUnprocessableEntity, + e.doBody(http.MethodPut, "/api/v1/soundtracks/not-a-uuid/alternative-names", `{"names":[]}`).Code) +} + +func TestSetAlternativeNamesRequiresToken(t *testing.T) { + e := newTestEnv(t, withAuth("secret")) + song := e.addSong("Game A", "Song", "x") + rec := e.doBody(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names", `{"names":["A"]}`) + assert.Equal(t, http.StatusUnauthorized, rec.Code) +} diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index d37b7ba..bbf3137 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -74,6 +74,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) { } slices.Sort(operations) assert.Equal(t, []string{ + "DELETE /api/v1/soundtracks/{uuid}/cover", "DELETE /api/v1/token", "GET /api/v1/character", "GET /api/v1/character/details", @@ -102,6 +103,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) { "GET /api/v1/songs/{uuid}/file", "GET /api/v1/soundtracks", "GET /api/v1/soundtracks/{uuid}", + "GET /api/v1/soundtracks/{uuid}/cover", "GET /api/v1/statistics/songs/least-played", "GET /api/v1/statistics/songs/most-played", "GET /api/v1/statistics/soundtracks", @@ -119,6 +121,9 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) { "POST /api/v1/token", "POST /api/v1/token/cleanup", "PUT /api/v1/music/played", + "PUT /api/v1/soundtracks/{uuid}/alternative-names", + "PUT /api/v1/soundtracks/{uuid}/cover", + "PUT /api/v1/soundtracks/{uuid}/cover/source", }, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented") assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type) @@ -169,7 +174,7 @@ func (v *specValidator) check(method, path, body string, wantStatus int, headers func TestResponsesMatchOpenAPI(t *testing.T) { gitea := newGitea(t, http.StatusOK) - e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }) + e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }, withCovers(t)) v := newSpecValidator(t, e) song := e.addSong("Game A", "Song", "0123456789") songURL := "/api/v1/songs/" + song.Uuid.String() @@ -202,6 +207,20 @@ func TestResponsesMatchOpenAPI(t *testing.T) { // Library. v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK) + v.check(http.MethodPut, "/api/v1/soundtracks/"+song.SoundtrackUuid.String()+"/alternative-names", + `{"names":["Game Alpha"]}`, http.StatusOK) + v.check(http.MethodPut, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000/alternative-names", + `{"names":[]}`, http.StatusNotFound) + + // Covers. + coverURL := "/api/v1/soundtracks/" + song.SoundtrackUuid.String() + "/cover" + v.check(http.MethodGet, coverURL, "", http.StatusNotFound) + v.check(http.MethodPut, coverURL, string(pngImage(t)), http.StatusNoContent, "Content-Type", "image/png") + v.check(http.MethodPut, coverURL, "plain text", http.StatusBadRequest, "Content-Type", "image/png") + v.check(http.MethodGet, coverURL, "", http.StatusOK) + v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"42"}`, http.StatusNoContent) + v.check(http.MethodPut, coverURL+"/source", `{"source":"anilist","id":"7"}`, http.StatusNotFound) + v.check(http.MethodDelete, coverURL, "", http.StatusNoContent) v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound) v.check(http.MethodGet, songURL, "", http.StatusOK) v.check(http.MethodGet, songURL+"/file", "", http.StatusOK) diff --git a/internal/server/routes.go b/internal/server/routes.go index 6134c44..3b91c56 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -63,6 +63,7 @@ func (s *Server) routes() (*echo.Echo, huma.API) { s.registerSync(a) s.registerMusic(a) s.registerLibrary(a) + s.registerCovers(a) s.registerStatistics(a) // Older links to the spec and docs. diff --git a/internal/server/server.go b/internal/server/server.go index 055477e..46f959d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -35,6 +35,11 @@ type Server struct { releases *backend.ReleaseService stats *backend.StatisticsService search *backend.SearchCache + // covers is nil when COVERS_PATH is not set. + covers *backend.CoverService + // stopCovers ends the background cover search; coversDone is closed when it has ended. + stopCovers context.CancelFunc + coversDone chan struct{} httpServer *http.Server // metrics serves /metrics on its own port; nil when metrics are off. metrics *metrics.Server @@ -57,9 +62,19 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e releases: backend.NewReleaseService(cfg.ReleasesURL), stats: backend.NewStatisticsService(q), } - s.search = backend.NewSearchCache(s.music.SoundtrackNames) + s.search = backend.NewSearchCache(s.music.SearchSoundtracks) s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync) + if cfg.CoversPath != "" { + s.covers, err = backend.NewCoverService(q, cfg.CoversPath, backend.CoverOptions{ + SteamGridDBKey: cfg.SteamGridDBAPIKey, + SteamGridDBURL: cfg.SteamGridDBURL, + AniListURL: cfg.AniListURL, + }) + if err != nil { + return nil, fmt.Errorf("covers path: %w", err) + } + } if cfg.MetricsPort > 0 { s.metrics = metrics.NewServer(cfg.MetricsPort) } @@ -73,6 +88,36 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e return s, nil } +// startCoverSearch runs the background cover search until Shutdown. It does nothing when +// covers are off or there is no SteamGridDB API key. +func (s *Server) startCoverSearch() { + if s.covers == nil || !s.covers.SearchesAutomatically() { + logging.GetLogger().Info("Automatic cover search is off", + zap.Bool("covers_path_set", s.covers != nil)) + return + } + ctx, cancel := context.WithCancel(context.Background()) + s.stopCovers, s.coversDone = cancel, make(chan struct{}) + go func() { + defer close(s.coversDone) + s.covers.Run(ctx) + }() +} + +// soundtracksChanged reloads the cached soundtrack data after soundtracks were edited, and +// lets the cover search try the new names. +func (s *Server) soundtracksChanged(ctx context.Context) { + if err := s.music.ReloadSoundtracks(ctx); err != nil { + logging.GetLogger().Error("Failed to reload soundtracks", zap.Error(err)) + } + if err := s.search.Reload(ctx); err != nil { + logging.GetLogger().Error("Failed to reload search cache", zap.Error(err)) + } + if s.covers != nil { + s.covers.Trigger() + } +} + // afterSync refreshes cached data once a sync has finished. func (s *Server) afterSync(ctx context.Context) { if err := s.music.EndAllQueues(ctx); err != nil { @@ -84,6 +129,9 @@ func (s *Server) afterSync(ctx context.Context) { if err := s.search.Reload(ctx); err != nil { logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) } + if s.covers != nil { + s.covers.Trigger() + } } // NewServerInstance loads the configuration, connects to and migrates the database and creates the Server. @@ -133,6 +181,7 @@ func (s *Server) ListenAndServe() error { logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err)) } } + s.startCoverSearch() logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr)) if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { return err @@ -146,6 +195,10 @@ func (s *Server) Shutdown(ctx context.Context) error { if s.metrics != nil { err = errors.Join(err, s.metrics.Shutdown(ctx)) } + if s.stopCovers != nil { + s.stopCovers() + <-s.coversDone + } s.sync.Wait() s.characters.Close() s.db.Close() diff --git a/internal/testutil/fakedb/fakedb.go b/internal/testutil/fakedb/fakedb.go index 5545192..15fc902 100644 --- a/internal/testutil/fakedb/fakedb.go +++ b/internal/testutil/fakedb/fakedb.go @@ -193,6 +193,93 @@ func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository return repository.Soundtrack{}, pgx.ErrNoRows } +func (s *Store) soundtrackByUUID(id uuid.UUID) int { + return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.Uuid == id }) +} + +func (s *Store) updateSoundtrackByUUID(id uuid.UUID, f func(*repository.Soundtrack)) error { + defer s.lock()() + if s.Err != nil { + return s.Err + } + if i := s.soundtrackByUUID(id); i >= 0 { + f(&s.Soundtracks[i]) + } + return nil +} + +func (s *Store) SetAlternativeNames(_ context.Context, arg repository.SetAlternativeNamesParams) (repository.Soundtrack, error) { + defer s.lock()() + if s.Err != nil { + return repository.Soundtrack{}, s.Err + } + i := s.soundtrackByUUID(arg.Uuid) + if i < 0 || s.Soundtracks[i].Deleted != nil { + return repository.Soundtrack{}, pgx.ErrNoRows + } + st := &s.Soundtracks[i] + st.AlternativeNames = slices.Clone(arg.Names) + if st.CoverFile == nil { + st.CoverChecked = pgtype.Timestamptz{} + } + return *st, nil +} + +func (s *Store) ListSoundtracksNeedingCover( + _ context.Context, checkedBefore pgtype.Timestamptz, +) ([]repository.ListSoundtracksNeedingCoverRow, error) { + defer s.lock()() + if s.Err != nil { + return nil, s.Err + } + var rows []repository.ListSoundtracksNeedingCoverRow + for _, st := range sortedByName(slices.Clone(s.Soundtracks)) { + if st.Deleted != nil || st.CoverFile != nil || st.CoverManual || + (st.CoverChecked.Valid && !st.CoverChecked.Time.Before(checkedBefore.Time)) { + continue + } + rows = append(rows, repository.ListSoundtracksNeedingCoverRow{ + Uuid: st.Uuid, SoundtrackName: st.SoundtrackName, AlternativeNames: st.AlternativeNames, + }) + } + return rows, nil +} + +func (s *Store) SetCover(_ context.Context, arg repository.SetCoverParams) error { + return s.updateSoundtrackByUUID(arg.Uuid, func(st *repository.Soundtrack) { + now := pgtype.Timestamptz{Time: time.Now(), Valid: true} + st.CoverFile, st.CoverSource, st.CoverExternalID = arg.File, arg.Source, arg.ExternalID + st.CoverManual, st.CoverModified, st.CoverChecked = arg.Manual, now, now + }) +} + +func (s *Store) MarkCoverChecked(_ context.Context, id uuid.UUID) error { + return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) { + st.CoverChecked = pgtype.Timestamptz{Time: time.Now(), Valid: true} + }) +} + +func (s *Store) ClearCover(_ context.Context, id uuid.UUID) error { + return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) { + st.CoverFile, st.CoverSource, st.CoverExternalID, st.CoverManual = nil, nil, nil, false + st.CoverModified, st.CoverChecked = pgtype.Timestamptz{}, pgtype.Timestamptz{} + }) +} + +func (s *Store) ListCoverFiles(context.Context) ([]string, error) { + defer s.lock()() + if s.Err != nil { + return nil, s.Err + } + var files []string + for _, st := range s.Soundtracks { + if st.CoverFile != nil { + files = append(files, *st.CoverFile) + } + } + return files, nil +} + func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error { defer s.lock()() if s.Err != nil { diff --git a/sqlc.yaml b/sqlc.yaml index 2ad7112..088caaa 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -21,6 +21,11 @@ sql: pointer: true import: "time" type: "Time" + - db_type: "text" + nullable: true + go_type: + pointer: true + type: "string" - db_type: "pg_catalog.varchar" nullable: true go_type: