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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f79f51ea4c
commit
bb8de5548d
39 files changed
+2418
-99
No files matched your search
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user