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

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