Generate the OpenAPI spec with Huma instead of swag
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped

The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
This commit is contained in:
Sansan committed 2026-10-06 16:31:06 +02:00
1 parent 0ec2425f78
commit 973ebf224b
37 files changed
+1245 -5967

No files matched your search

+134 -218
View File
@@ -1,240 +1,156 @@
package server
import (
"context"
"net/http"
"strconv"
"music-server/internal/backend"
"music-server/internal/logging"
"github.com/labstack/echo/v5"
"github.com/danielgtaylor/huma/v2"
"go.uber.org/zap"
)
type MusicHandler struct {
music *backend.MusicService
type songNumberInput struct {
Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
}
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
return &MusicHandler{music: music}
}
const audioMPEG = "audio/mpeg"
// GetSong godoc
// @Summary Get a specific song
// @Description Returns the song at the given position in the queue
// @Tags music
// @Produce audio/mpeg
// @Param song query int true "Song number in the queue"
// @Success 200 {file} file
// @Success 206 {file} file
// @Failure 400 {string} string "song must be a number"
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music [get]
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "song must be a number")
func (s *Server) registerMusic(a apis) {
m := s.music
audio := func(id, path, summary, description string, errs ...int) route {
return route{
op: huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"music"},
Responses: fileResponses("The song (MP3)", audioMPEG),
Errors: errs,
},
legacyPath: path,
locked: true,
}
}
path, err := m.music.Song(song)
return serveSong(ctx, path, err)
}
// GetSoundCheckSong godoc
// @Summary Get sound check song
// @Description Returns the sound check song
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/soundTest [get]
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
path, err := m.music.SoundCheckSong()
return serveSong(ctx, path, err)
}
// ResetMusic godoc
// @Summary Reset music state
// @Description Resets the music state
// @Tags music
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/reset [get]
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
if err := m.music.Reset(ctx.Request().Context()); err != nil {
return songError(err)
status := func(id, method, path, summary, description string) route {
return route{
op: huma.Operation{
OperationID: id,
Method: method,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"music"},
DefaultStatus: http.StatusOK,
},
legacyPath: path,
locked: true,
}
}
return ctx.NoContent(http.StatusOK)
}
// GetRandomSong godoc
// @Summary Get random song
// @Description Returns a random song
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand [get]
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
path, err := m.music.RandomSong(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongLowChance godoc
// @Summary Get random song with low chance
// @Description Returns a random song, favouring soundtracks that have been played less
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/low [get]
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetRandomSongClassic godoc
// @Summary Get random classic song
// @Description Returns a random song where every song has the same chance
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/rand/classic [get]
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
path, err := m.music.RandomSongClassic(ctx.Request().Context())
return serveSong(ctx, path, err)
}
// GetSongInfo godoc
// @Summary Get current song info
// @Description Returns information about the current song
// @Tags music
// @Produce json
// @Success 200 {object} backend.SongInfo
// @Router /music/info [get]
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
}
// GetPlayedSongs godoc
// @Summary Get played songs list
// @Description Returns a list of played songs
// @Tags music
// @Produce json
// @Success 200 {array} backend.SongInfo
// @Router /music/list [get]
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
}
// GetNextSong godoc
// @Summary Get next song
// @Description Returns the next song in the queue
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/next [get]
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
path, err := m.music.NextSong()
return serveSong(ctx, path, err)
}
// GetPreviousSong godoc
// @Summary Get previous song
// @Description Returns the previous song in the queue
// @Tags music
// @Produce audio/mpeg
// @Success 200 {file} file
// @Failure 404 {string} string "Not Found"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/previous [get]
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
path, err := m.music.PreviousSong()
return serveSong(ctx, path, err)
}
// GetAllSoundtracks godoc
// @Summary Get all soundtracks
// @Description Returns a list of all soundtracks in order
// @Tags music
// @Produce json
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/order [get]
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
names, err := m.music.SoundtrackNames(ctx.Request().Context())
if err != nil {
return songError(err)
list := func(id, path, summary, description string, locked bool) route {
return route{
op: huma.Operation{
OperationID: id,
Method: http.MethodGet,
Path: path,
Summary: summary,
Description: description,
Tags: []string{"music"},
},
legacyPath: path,
locked: locked,
}
}
return ctx.JSON(http.StatusOK, names)
}
// GetAllSoundtracksRandom godoc
// @Summary Get all soundtracks random
// @Description Returns a list of all soundtracks in random order
// @Tags music
// @Produce json
// @Success 200 {array} string
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/all/random [get]
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
if err != nil {
return songError(err)
}
return ctx.JSON(http.StatusOK, names)
}
register(s, a, audio("get-queue-song", "/music", "Play a song from the queue",
"Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound),
func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) {
return streamSong(m.Song(in.Song))
})
register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song",
"Returns a random song for checking the volume.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.SoundCheckSong())
})
register(s, a, audio("get-random-song", "/music/rand", "Random song",
"Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSong(ctx))
})
register(s, a, audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played",
"Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSongLowChance(ctx))
})
register(s, a, audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal",
"Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.RandomSongClassic(ctx))
})
register(s, a, audio("get-next-song", "/music/next", "Next song in the queue",
"Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.NextSong())
})
register(s, a, audio("get-previous-song", "/music/previous", "Previous song in the queue",
"Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound),
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
return streamSong(m.PreviousSong())
})
// PutPlayed godoc
// @Summary Mark song as played
// @Description Marks the song at the given queue position as played
// @Tags music
// @Param song query int true "Song number in the queue"
// @Success 200
// @Failure 400 {string} string "Bad Request"
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/played [put]
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
song, err := strconv.Atoi(ctx.QueryParam("song"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "song must be a number")
register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false),
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) {
return ok(m.SongInfo(ctx), nil)
})
register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) {
return ok(m.PlayedSongs(ctx), nil)
})
randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(m.SoundtrackNamesRandom(ctx))
}
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
}
register(s, a, list("get-soundtrack-names-random", "/music/all", "Soundtrack names, random order",
"Returns the names of all soundtracks in random order.", true), randomNames)
register(s, a, list("get-soundtrack-names-random-alias", "/music/all/random", "Soundtrack names, random order",
"Same as /music/all.", true), randomNames)
register(s, a, list("get-soundtrack-names", "/music/all/order", "Soundtrack names, sorted",
"Returns the names of all soundtracks in alphabetical order.", true),
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
return ok(m.SoundtrackNames(ctx))
})
// AddLatestToQue godoc
// @Summary Add latest to queue
// @Description Adds the latest song to the queue
// @Tags music
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addQue [get]
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
m.music.AddLatestToQueue()
return ctx.NoContent(http.StatusOK)
}
// AddLatestPlayed godoc
// @Summary Add latest to played
// @Description Marks the current song as played
// @Tags music
// @Success 200
// @Failure 423 {string} string "Syncing is in progress"
// @Router /music/addPlayed [get]
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
return songError(err)
}
return ctx.NoContent(http.StatusOK)
register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue",
"Clears the queue and reloads the soundtrack list."),
func(ctx context.Context, _ *struct{}) (*noBody, error) {
if err := m.Reset(ctx); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil
})
register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue",
"Adds the song returned by the last random song request to the end of the queue."),
func(ctx context.Context, _ *struct{}) (*noBody, error) {
m.AddLatestToQueue()
return &noBody{}, nil
})
register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played",
"Increments the play counters of the current song and its soundtrack."),
func(ctx context.Context, _ *struct{}) (*noBody, error) {
if err := m.AddLatestPlayed(ctx); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil
})
register(s, a, status("mark-song-played", http.MethodPut, "/music/played", "Mark a queued song as played",
"Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."),
func(ctx context.Context, in *songNumberInput) (*noBody, error) {
logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song))
if err := m.SetPlayed(ctx, in.Song); err != nil {
return nil, apiError(err)
}
return &noBody{}, nil
})
}