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>
96 lines
3.2 KiB
Go
96 lines
3.2 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"music-server/internal/backend"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type uuidInput struct {
|
|
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack or song"`
|
|
}
|
|
|
|
func (in *uuidInput) id() uuid.UUID {
|
|
// The format is validated by Huma before the handler runs.
|
|
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 {
|
|
return huma.Operation{
|
|
OperationID: id,
|
|
Method: http.MethodGet,
|
|
Path: path,
|
|
Summary: summary,
|
|
Description: description,
|
|
Tags: []string{"library"},
|
|
Errors: errs,
|
|
}
|
|
}
|
|
|
|
register(s, a, route{op: op("list-soundtracks", "/soundtracks", "List soundtracks",
|
|
"Returns all soundtracks, sorted by name.")},
|
|
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.Soundtrack], error) {
|
|
return ok(l.Soundtracks(ctx))
|
|
})
|
|
register(s, a, route{op: op("get-soundtrack", "/soundtracks/{uuid}", "Get soundtrack",
|
|
"Returns a soundtrack with its songs, sorted by name.", http.StatusNotFound)},
|
|
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
|
|
return ok(l.Soundtrack(ctx, in.id()))
|
|
})
|
|
register(s, a, route{op: op("get-song", "/songs/{uuid}", "Get song",
|
|
"Returns information about a song.", http.StatusNotFound)},
|
|
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.Song], error) {
|
|
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)
|
|
register(s, a, route{op: file, locked: true},
|
|
func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
|
|
return streamSong(l.SongPath(ctx, in.id()))
|
|
})
|
|
}
|