Files
MusicServer/internal/server/api.go
T
SansanandClaude Opus 5.5 a7e2280969
CI / integration (pull_request) Successful in 21s
CI / test (pull_request) Successful in 26s
CI / publish (pull_request) Skipped
CI / docker (pull_request) Successful in 43s
Upload new soundtracks through the API
A client starts an upload with the soundtrack's name, sends one MP3 per
request, and finishes it. The server then moves the songs into a new folder
in the music folder and syncs only that folder, so the soundtrack is in the
library, lists and search right away.

- POST /api/v1/soundtracks/uploads, GET/DELETE .../uploads/{id},
  PUT .../uploads/{id}/files/{file} and POST .../uploads/{id}/finish.
- Names must work as file names on every system and be free, ignoring case.
  File names are plain: clients flatten disc folders.
- Only MP3 files. Size limits from UPLOAD_MAX_FILE_MB (50) and
  UPLOAD_MAX_TOTAL_MB (2048).
- Refused while a sync runs, and a sync cannot start while an upload is
  finished. Songs equal to an existing soundtrack's are refused, as the sync
  would take them for that soundtrack renamed.
- Unfinished uploads live in MUSIC_PATH/.uploads and are removed after a day.
  The sync now skips every hidden folder.

Closes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:26 +02:00

186 lines
5.9 KiB
Go

package server
import (
"context"
"errors"
"net/http"
"strings"
"music-server/internal/backend"
"music-server/internal/logging"
"music-server/internal/server/middleware"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
const (
apiPrefix = "/api/v1"
bearerAuthName = "bearer"
)
var bearerAuth = []map[string][]string{{bearerAuthName: {}}}
// APIError is the body of every error response.
type APIError struct {
status int
Message string `json:"error" example:"Not Found" doc:"What went wrong"`
}
func (e *APIError) Error() string { return e.Message }
func (e *APIError) GetStatus() int { return e.status }
func init() {
// The API always returns [] for empty lists, never null.
huma.DefaultArrayNullable = false
// Huma creates all its errors (validation, 404, ...) through NewError.
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
var details []string
for _, err := range errs {
if err != nil {
details = append(details, err.Error())
}
}
if len(details) > 0 {
msg += ": " + strings.Join(details, "; ")
}
return &APIError{status: status, Message: msg}
}
}
func newAPI(e *echo.Echo) huma.API {
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
// No $schema fields or Link headers in responses.
config.CreateHooks = nil
config.DocsRenderer = huma.DocsRendererScalar
config.Info.Description = "API for the MusicServer application.\n\n" +
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
"Every client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; " +
"send it back in the same header to keep using that queue. Without it, or when the queue has ended " +
"(after a reset or a sync), a new queue is started."
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
}
return humaecho.New(e, config)
}
// route describes an endpoint registered on /api/v1.
type route struct {
op huma.Operation
// public endpoints need no token.
public bool
// locked endpoints answer 423 while a sync is running.
locked bool
// queue endpoints work on the client's play queue, see middleware.Queue.
queue bool
}
var queueHeaderParam = &huma.Param{
Name: middleware.QueueHeader,
In: "header",
Description: "ID of the client's play queue, from an earlier response. Omit it to start a new queue.",
Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"},
}
// register adds the route's operation to the API.
func register[I, O any](s *Server, a huma.API, r route, handler func(context.Context, *I) (*O, error)) {
op := r.op
op.Path = apiPrefix + r.op.Path
if !r.public {
op.Security = bearerAuth
op.Errors = append(op.Errors, http.StatusUnauthorized)
}
if r.locked {
op.Middlewares = append(op.Middlewares, s.syncLock(a))
op.Errors = append(op.Errors, http.StatusLocked)
}
if r.queue {
op.Middlewares = append(op.Middlewares, middleware.Queue(a, s.music.ResolveQueue))
op.Parameters = append(op.Parameters, queueHeaderParam)
}
huma.Register(a, op, handler)
if r.queue {
documentQueueHeader(a, op)
}
}
// documentQueueHeader adds the X-Queue-Id header to the operation's success responses.
func documentQueueHeader(a huma.API, op huma.Operation) {
path := a.OpenAPI().Paths[op.Path]
registered := map[string]*huma.Operation{
http.MethodGet: path.Get, http.MethodPut: path.Put, http.MethodPost: path.Post, http.MethodDelete: path.Delete,
}[op.Method]
for status, resp := range registered.Responses {
if !strings.HasPrefix(status, "2") {
continue
}
if resp.Headers == nil {
resp.Headers = map[string]*huma.Param{}
}
if _, ok := resp.Headers[middleware.QueueHeader]; !ok {
resp.Headers[middleware.QueueHeader] = &huma.Param{
Description: "ID of the client's play queue. Send it with the following requests.",
Schema: &huma.Schema{Type: huma.TypeString, Format: "uuid"},
}
}
}
}
// syncLock answers 423 Locked while a sync is running.
func (s *Server) syncLock(api huma.API) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) {
if s.sync.IsSyncing() {
logging.GetLogger().Info("Syncing is in progress - request blocked")
_ = huma.WriteErr(api, ctx, http.StatusLocked, "Syncing is in progress")
return
}
next(ctx)
}
}
// apiError maps backend errors to API errors.
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), errors.Is(err, backend.ErrInvalidImage):
return huma.Error400BadRequest(err.Error())
case errors.Is(err, backend.ErrCoversDisabled), errors.Is(err, backend.ErrUploadsUnavailable):
return huma.Error503ServiceUnavailable(err.Error())
case errors.Is(err, backend.ErrConflict):
return huma.Error409Conflict(err.Error())
case errors.Is(err, backend.ErrSyncing):
return huma.Error423Locked(err.Error())
case errors.Is(err, backend.ErrTooLarge):
return huma.Error413RequestEntityTooLarge(err.Error())
case errors.Is(err, backend.ErrUpstream):
return huma.Error502BadGateway(err.Error())
default:
logging.GetLogger().Error("Request failed", zap.Error(err))
return huma.Error500InternalServerError("Internal Server Error")
}
}
// echoContext returns the Echo context behind a Huma context.
func echoContext(ctx huma.Context) *echo.Context {
return humaecho.Unwrap(ctx)
}
// noBody is the output of operations that only return a status.
type noBody struct{}
// jsonBody wraps a JSON response body.
type jsonBody[T any] struct {
Body T
}
func ok[T any](body T, err error) (*jsonBody[T], error) {
if err != nil {
return nil, apiError(err)
}
return &jsonBody[T]{Body: body}, nil
}