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

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

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

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

Closes #12, closes #32

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

No files matched your search

+20 -1
View File
@@ -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)