Store each client's play queue in the database
The queue lived in memory and was shared by all clients, so a restart or a finished sync cleared it. Every client now has its own queue in the new queue and queue_song tables (added to the undeployed migration 000006). The music endpoints return the queue ID in the X-Queue-Id header and expect it back. A request without an ID, or with an ended one, starts a new queue. /music/reset ends the queue and returns a new ID, and a sync ends all running queues. Ended queues are kept as history. The deprecated legacy routes without the /api/v1 prefix are removed.
This commit is contained in:
1 parent
b5dc42b654
commit
a705d7b476
28 files changed
+1103
-320
No files matched your search
+47
-35
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -50,47 +51,42 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes.
|
||||
type apis struct {
|
||||
v1 huma.API
|
||||
legacy huma.API
|
||||
}
|
||||
|
||||
func newAPIs(e *echo.Echo) apis {
|
||||
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" +
|
||||
"The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " +
|
||||
"and are not documented here. They will be removed."
|
||||
"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"},
|
||||
}
|
||||
|
||||
legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version)
|
||||
legacyConfig.CreateHooks = nil
|
||||
legacyConfig.OpenAPIPath = ""
|
||||
legacyConfig.DocsPath = ""
|
||||
legacyConfig.SchemasPath = ""
|
||||
|
||||
return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)}
|
||||
return humaecho.New(e, config)
|
||||
}
|
||||
|
||||
// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route.
|
||||
// route describes an endpoint registered on /api/v1.
|
||||
type route struct {
|
||||
op huma.Operation
|
||||
// public endpoints need no token.
|
||||
public bool
|
||||
// legacyPath is the deprecated path without /api/v1 (empty: no legacy route).
|
||||
legacyPath string
|
||||
// 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
|
||||
}
|
||||
|
||||
// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route.
|
||||
func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) {
|
||||
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 {
|
||||
@@ -98,23 +94,39 @@ func register[I, O any](s *Server, a apis, r route, handler func(context.Context
|
||||
op.Errors = append(op.Errors, http.StatusUnauthorized)
|
||||
}
|
||||
if r.locked {
|
||||
op.Middlewares = append(op.Middlewares, s.syncLock(a.v1))
|
||||
op.Middlewares = append(op.Middlewares, s.syncLock(a))
|
||||
op.Errors = append(op.Errors, http.StatusLocked)
|
||||
}
|
||||
huma.Register(a.v1, op, handler)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if r.legacyPath == "" {
|
||||
return
|
||||
// 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"},
|
||||
}
|
||||
}
|
||||
}
|
||||
legacy := r.op
|
||||
legacy.OperationID = "legacy-" + r.op.OperationID
|
||||
legacy.Path = r.legacyPath
|
||||
legacy.Hidden = true
|
||||
legacy.Deprecated = true
|
||||
if r.locked {
|
||||
legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy))
|
||||
}
|
||||
huma.Register(a.legacy, legacy, handler)
|
||||
}
|
||||
|
||||
// syncLock answers 423 Locked while a sync is running.
|
||||
|
||||
@@ -23,32 +23,80 @@ func TestMusicRandomAndQueue(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "0123456789")
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/rand")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "0123456789", rec.Body.String())
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addPlayed").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addPlayed").Code)
|
||||
|
||||
var info backend.SongInfo
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
|
||||
assert.Equal(t, "Game A", info.Soundtrack)
|
||||
assert.Equal(t, "Song", info.Song)
|
||||
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/api/v1/music?song=0"} {
|
||||
for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music?song=0"} {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/api/v1/music/played?song=0").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/api/v1/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/reset").Code)
|
||||
}
|
||||
|
||||
func TestMusicQueueID(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "x")
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
first := rec.Header().Get("X-Queue-Id")
|
||||
require.NotEmpty(t, first, "a request without a queue ID gets a new queue")
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
|
||||
rec = e.do(http.MethodGet, "/api/v1/music/list")
|
||||
assert.Equal(t, first, rec.Header().Get("X-Queue-Id"), "the sent queue is kept")
|
||||
assert.Contains(t, rec.Body.String(), `"Song":"Song"`)
|
||||
|
||||
// Another client has its own queue.
|
||||
other := e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", "")
|
||||
assert.NotEqual(t, first, other.Header().Get("X-Queue-Id"))
|
||||
assert.JSONEq(t, "[]", other.Body.String())
|
||||
e.queueID = first
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/music/reset")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
second := rec.Header().Get("X-Queue-Id")
|
||||
assert.NotEqual(t, first, second, "reset starts a new queue")
|
||||
assert.JSONEq(t, "[]", e.do(http.MethodGet, "/api/v1/music/list").Body.String())
|
||||
|
||||
// The old ID has ended, so it gets a new queue too.
|
||||
rec = e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", first)
|
||||
assert.NotEqual(t, first, rec.Header().Get("X-Queue-Id"))
|
||||
|
||||
assert.Empty(t, e.do(http.MethodGet, "/api/v1/music/all", "X-Queue-Id", "").Header().Get("X-Queue-Id"),
|
||||
"endpoints that do not use the queue do not start one")
|
||||
}
|
||||
|
||||
func TestMusicQueueEndsAfterSync(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "x")
|
||||
e.do(http.MethodGet, "/api/v1/music/rand")
|
||||
e.do(http.MethodGet, "/api/v1/music/addQue")
|
||||
before := e.queueID
|
||||
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
|
||||
e.server.sync.Wait()
|
||||
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/list")
|
||||
assert.NotEqual(t, before, rec.Header().Get("X-Queue-Id"))
|
||||
assert.JSONEq(t, "[]", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicRangeRequest(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "0123456789")
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/rand", "Range", "bytes=2-5")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/rand", "Range", "bytes=2-5")
|
||||
assert.Equal(t, http.StatusPartialContent, rec.Code)
|
||||
assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range"))
|
||||
assert.Equal(t, "2345", rec.Body.String())
|
||||
@@ -57,21 +105,21 @@ func TestMusicRangeRequest(t *testing.T) {
|
||||
|
||||
func TestMusicEmptyStates(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} {
|
||||
for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
// Invalid or missing parameters fail validation.
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music").Code)
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/list")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/list")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, "[]", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicSoundCheck(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/music/soundTest")
|
||||
rec := e.do(http.MethodGet, "/api/v1/music/soundTest")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "sound check", rec.Body.String())
|
||||
}
|
||||
@@ -82,31 +130,31 @@ func TestMusicSoundtrackLists(t *testing.T) {
|
||||
e.addSong("A Game", "Song", "a")
|
||||
|
||||
var ordered []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all/order").Body.Bytes(), &ordered))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all/order").Body.Bytes(), &ordered))
|
||||
assert.Equal(t, []string{"A Game", "B Game"}, ordered)
|
||||
|
||||
var random []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/all").Body.Bytes(), &random))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all").Body.Bytes(), &random))
|
||||
assert.ElementsMatch(t, ordered, random)
|
||||
}
|
||||
|
||||
func TestMusicDatabaseError(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.store.Err = errors.New("db down")
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/rand").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/music/all/order").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/rand").Code)
|
||||
assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/all/order").Code)
|
||||
}
|
||||
|
||||
func TestCharacterEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
var list []string
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/characters").Body.Bytes(), &list))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list))
|
||||
assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list)
|
||||
|
||||
rec := e.do(http.MethodGet, "/character?name=Adol.png")
|
||||
rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/png", rec.Header().Get("Content-Type"))
|
||||
|
||||
@@ -114,14 +162,14 @@ func TestCharacterEndpoints(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/character?name=Missing.png").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/character?name=Missing.png").Code)
|
||||
}
|
||||
|
||||
func TestCharacterPathTraversal(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/character?name=").Code, "name is required")
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required")
|
||||
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} {
|
||||
rec := e.do(http.MethodGet, "/character?name="+name)
|
||||
rec := e.do(http.MethodGet, "/api/v1/character?name="+name)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, name)
|
||||
assert.NotContains(t, rec.Body.String(), "secret", name)
|
||||
}
|
||||
@@ -140,23 +188,23 @@ func TestDownloadEndpoints(t *testing.T) {
|
||||
url := newGitea(t, http.StatusOK)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url })
|
||||
|
||||
rec := e.do(http.MethodGet, "/download")
|
||||
rec := e.do(http.MethodGet, "/api/v1/download")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `"v2.0.0"`, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/download/list")
|
||||
assert.JSONEq(t, `["Player.exe"]`, rec.Body.String())
|
||||
|
||||
rec = e.do(http.MethodGet, "/download/windows")
|
||||
rec = e.do(http.MethodGet, "/api/v1/download/windows")
|
||||
assert.Equal(t, http.StatusFound, rec.Code)
|
||||
assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location"))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/download/linux").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux").Code)
|
||||
}
|
||||
|
||||
func TestDownloadUpstreamDown(t *testing.T) {
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) })
|
||||
for _, path := range []string{"/download", "/download/list", "/download/windows"} {
|
||||
for _, path := range []string{"/api/v1/download", "/api/v1/download/list", "/api/v1/download/windows"} {
|
||||
assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
}
|
||||
@@ -191,7 +239,6 @@ func TestTokenAuth(t *testing.T) {
|
||||
token := e.createToken("secret")
|
||||
auth := []string{"Authorization", "Bearer " + token}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/version").Code, "legacy routes need no token")
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"music-server/internal/config"
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -48,6 +49,8 @@ type testEnv struct {
|
||||
db *fakeDatabase
|
||||
server *Server
|
||||
handler http.Handler
|
||||
// queueID is sent as X-Queue-Id and updated from responses, like a client keeping its queue.
|
||||
queueID string
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
@@ -111,10 +114,16 @@ func (e *testEnv) doBody(method, path, body string, headers ...string) *httptest
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.queueID != "" {
|
||||
req.Header.Set(middleware.QueueHeader, e.queueID)
|
||||
}
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.handler.ServeHTTP(rec, req)
|
||||
if id := rec.Header().Get(middleware.QueueHeader); id != "" {
|
||||
e.queueID = id
|
||||
}
|
||||
return rec
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func (in *uuidInput) id() uuid.UUID {
|
||||
return uuid.MustParse(in.UUID)
|
||||
}
|
||||
|
||||
func (s *Server) registerLibrary(a apis) {
|
||||
func (s *Server) registerLibrary(a huma.API) {
|
||||
l := s.library
|
||||
op := func(id, path, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
|
||||
@@ -55,7 +55,6 @@ func TestLibraryEndpointErrors(t *testing.T) {
|
||||
func TestLibraryEndpointsRequireToken(t *testing.T) {
|
||||
e := newTestEnv(t, withAuth("secret"))
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/soundtracks").Code)
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/soundtracks").Code, "not available on legacy routes")
|
||||
}
|
||||
|
||||
func TestSongFileLockedDuringSync(t *testing.T) {
|
||||
@@ -72,11 +71,11 @@ func TestSongFileLockedDuringSync(t *testing.T) {
|
||||
func TestQueueResponsesIncludeUUIDs(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
song := e.addSong("Game A", "Song", "x")
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/rand").Code)
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/addQue").Code)
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/rand").Code)
|
||||
require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code)
|
||||
|
||||
var info map[string]any
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/music/info").Body.Bytes(), &info))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info))
|
||||
assert.Equal(t, song.Uuid.String(), info["SongUuid"])
|
||||
assert.Equal(t, song.SoundtrackUuid.String(), info["SoundtrackUuid"])
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import "github.com/danielgtaylor/huma/v2"
|
||||
|
||||
// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1.
|
||||
func Deprecation(ctx huma.Context, next func(huma.Context)) {
|
||||
ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
|
||||
ctx.AppendHeader("Deprecation", "true")
|
||||
next(ctx)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// QueueHeader carries the client's queue ID in requests and responses.
|
||||
const QueueHeader = "X-Queue-Id"
|
||||
|
||||
type queueKey struct{}
|
||||
|
||||
// QueueID returns the queue ID resolved by Queue.
|
||||
func QueueID(ctx context.Context) uuid.UUID {
|
||||
id, _ := ctx.Value(queueKey{}).(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
|
||||
// Queue resolves the queue ID in the X-Queue-Id header with resolve, which starts a new queue
|
||||
// when the header is missing or the queue has ended. The ID is returned in the same header.
|
||||
func Queue(api huma.API, resolve func(context.Context, string) (uuid.UUID, error)) Middleware {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
id, err := resolve(ctx.Context(), ctx.Header(QueueHeader))
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to resolve queue", zap.Error(err))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
ctx.SetHeader(QueueHeader, id.String())
|
||||
next(huma.WithValue(ctx, queueKey{}, id))
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type healthOutput struct {
|
||||
Body map[string]string
|
||||
}
|
||||
|
||||
func (s *Server) registerMisc(a apis) {
|
||||
func (s *Server) registerMisc(a huma.API) {
|
||||
get := func(id, path, tag, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
OperationID: id,
|
||||
@@ -37,30 +37,30 @@ func (s *Server) registerMisc(a apis) {
|
||||
|
||||
register(s, a, route{op: get("get-health", "/health", "server", "Health check",
|
||||
"Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."),
|
||||
public: true, legacyPath: "/health"},
|
||||
public: true},
|
||||
func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
|
||||
return &healthOutput{Body: s.db.Health(ctx)}, nil
|
||||
})
|
||||
register(s, a, route{op: get("get-version", "/version", "server", "Latest server version",
|
||||
"Returns the server version and its changelog."), legacyPath: "/version"},
|
||||
"Returns the server version and its changelog.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) {
|
||||
return ok(backend.GetLatestVersion(), nil)
|
||||
})
|
||||
register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history",
|
||||
"Returns all server versions with their changelogs, newest first."), legacyPath: "/version/history"},
|
||||
"Returns all server versions with their changelogs, newest first.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) {
|
||||
return ok(backend.GetVersionHistory(), nil)
|
||||
})
|
||||
|
||||
register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters",
|
||||
"Returns the file names of all character images."), legacyPath: "/characters"},
|
||||
"Returns the file names of all character images.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(s.characters.List())
|
||||
})
|
||||
image := get("get-character", "/character", "characters", "Character image",
|
||||
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
|
||||
image.Responses = fileResponses("The image", "image/png", "image/jpeg")
|
||||
register(s, a, route{op: image, legacyPath: "/character"},
|
||||
register(s, a, route{op: image},
|
||||
func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) {
|
||||
f, err := s.characters.Open(in.Name)
|
||||
if err != nil {
|
||||
@@ -71,12 +71,12 @@ func (s *Server) registerMisc(a apis) {
|
||||
|
||||
r := s.releases
|
||||
register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version",
|
||||
"Returns the name of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download"},
|
||||
"Returns the name of the latest MusicPlayer release.", http.StatusBadGateway)},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
return ok(r.LatestVersion(ctx))
|
||||
})
|
||||
register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets",
|
||||
"Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download/list"},
|
||||
"Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway)},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(r.AssetNames(ctx))
|
||||
})
|
||||
@@ -85,7 +85,7 @@ func (s *Server) registerMisc(a apis) {
|
||||
"Redirects to the download URL of the latest MusicPlayer release for "+platform+".",
|
||||
http.StatusNotFound, http.StatusBadGateway)
|
||||
op.DefaultStatus = http.StatusFound
|
||||
register(s, a, route{op: op, legacyPath: path},
|
||||
register(s, a, route{op: op},
|
||||
func(ctx context.Context, _ *struct{}) (*redirectOutput, error) {
|
||||
url, err := r.AssetURL(ctx, suffix)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"go.uber.org/zap"
|
||||
@@ -15,9 +16,14 @@ type songNumberInput struct {
|
||||
Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
|
||||
}
|
||||
|
||||
// resetOutput returns the ID of the new queue.
|
||||
type resetOutput struct {
|
||||
QueueID string `header:"X-Queue-Id" doc:"ID of the new queue"`
|
||||
}
|
||||
|
||||
const audioMPEG = "audio/mpeg"
|
||||
|
||||
func (s *Server) registerMusic(a apis) {
|
||||
func (s *Server) registerMusic(a huma.API) {
|
||||
m := s.music
|
||||
audio := func(id, path, summary, description string, errs ...int) route {
|
||||
return route{
|
||||
@@ -31,8 +37,7 @@ func (s *Server) registerMusic(a apis) {
|
||||
Responses: fileResponses("The song (MP3)", audioMPEG),
|
||||
Errors: errs,
|
||||
},
|
||||
legacyPath: path,
|
||||
locked: true,
|
||||
locked: true,
|
||||
}
|
||||
}
|
||||
status := func(id, method, path, summary, description string) route {
|
||||
@@ -46,8 +51,8 @@ func (s *Server) registerMusic(a apis) {
|
||||
Tags: []string{"music"},
|
||||
DefaultStatus: http.StatusOK,
|
||||
},
|
||||
legacyPath: path,
|
||||
locked: true,
|
||||
locked: true,
|
||||
queue: true,
|
||||
}
|
||||
}
|
||||
list := func(id, path, summary, description string, locked bool) route {
|
||||
@@ -60,54 +65,59 @@ func (s *Server) registerMusic(a apis) {
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
},
|
||||
legacyPath: path,
|
||||
locked: locked,
|
||||
locked: locked,
|
||||
}
|
||||
}
|
||||
// queued marks a route that works on the client's play queue.
|
||||
queued := func(r route) route {
|
||||
r.queue = true
|
||||
return r
|
||||
}
|
||||
queue := middleware.QueueID
|
||||
|
||||
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),
|
||||
register(s, a, queued(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))
|
||||
return streamSong(m.Song(ctx, queue(ctx), 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),
|
||||
register(s, a, queued(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))
|
||||
return streamSong(m.RandomSong(ctx, queue(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),
|
||||
register(s, a, queued(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))
|
||||
return streamSong(m.RandomSongLowChance(ctx, queue(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),
|
||||
register(s, a, queued(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))
|
||||
return streamSong(m.RandomSongClassic(ctx, queue(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),
|
||||
register(s, a, queued(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())
|
||||
return streamSong(m.NextSong(ctx, queue(ctx)))
|
||||
})
|
||||
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),
|
||||
register(s, a, queued(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())
|
||||
return streamSong(m.PreviousSong(ctx, queue(ctx)))
|
||||
})
|
||||
|
||||
register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false),
|
||||
register(s, a, queued(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)
|
||||
return ok(m.SongInfo(ctx, queue(ctx)))
|
||||
})
|
||||
register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false),
|
||||
register(s, a, queued(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)
|
||||
return ok(m.PlayedSongs(ctx, queue(ctx)))
|
||||
})
|
||||
randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(m.SoundtrackNamesRandom(ctx))
|
||||
@@ -123,23 +133,30 @@ func (s *Server) registerMusic(a apis) {
|
||||
})
|
||||
|
||||
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 {
|
||||
"Ends the queue, starts a new one and reloads the soundtrack list. "+
|
||||
"The new queue's ID is returned in the X-Queue-Id header; send it with the following requests."),
|
||||
func(ctx context.Context, _ *struct{}) (*resetOutput, error) {
|
||||
id, err := m.NewQueue(ctx, queue(ctx))
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &noBody{}, nil
|
||||
if err := m.ReloadSoundtracks(ctx); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &resetOutput{QueueID: id.String()}, 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()
|
||||
if err := m.AddLatestToQueue(ctx, queue(ctx)); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
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 {
|
||||
if err := m.AddLatestPlayed(ctx, queue(ctx)); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &noBody{}, nil
|
||||
@@ -148,7 +165,7 @@ func (s *Server) registerMusic(a apis) {
|
||||
"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 {
|
||||
if err := m.SetPlayed(ctx, queue(ctx), in.Song); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &noBody{}, nil
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
||||
"POST /api/v1/token",
|
||||
"POST /api/v1/token/cleanup",
|
||||
"PUT /api/v1/music/played",
|
||||
}, operations, "/sync/reset is hidden and legacy routes are not documented")
|
||||
}, operations, "/api/v1/sync/reset is hidden and legacy routes are not documented")
|
||||
|
||||
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
|
||||
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
|
||||
|
||||
@@ -24,10 +24,10 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
// the handlers are registered but never called.
|
||||
func OpenAPISpec() *huma.OpenAPI {
|
||||
_, a := (&Server{}).routes()
|
||||
return a.v1.OpenAPI()
|
||||
return a.OpenAPI()
|
||||
}
|
||||
|
||||
func (s *Server) routes() (*echo.Echo, apis) {
|
||||
func (s *Server) routes() (*echo.Echo, huma.API) {
|
||||
e := echo.New()
|
||||
e.HTTPErrorHandler = httpErrorHandler
|
||||
if len(s.cfg.TrustedProxies) > 0 {
|
||||
@@ -43,15 +43,15 @@ func (s *Server) routes() (*echo.Echo, apis) {
|
||||
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
|
||||
AllowOrigins: []string{"https://*", "http://*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", middleware.QueueHeader},
|
||||
ExposeHeaders: []string{middleware.QueueHeader},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden.
|
||||
a := newAPIs(e)
|
||||
a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth))
|
||||
a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation)
|
||||
// The API: /api/v1 is documented at /docs and /openapi.json.
|
||||
a := newAPI(e)
|
||||
a.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a, s.q, s.cfg.DisableSessionAuth))
|
||||
s.registerToken(a)
|
||||
s.registerMisc(a)
|
||||
s.registerSync(a)
|
||||
|
||||
@@ -28,64 +28,56 @@ func (e *testEnv) blockSync() (release func()) {
|
||||
func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} {
|
||||
// GET falls through to the frontend's static files, which answer 404.
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} {
|
||||
// GET falls through to the frontend's static files, which answer 404.
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.False(t, e.server.sync.IsSyncing(), "no sync was started")
|
||||
assert.Len(t, e.store.Songs, 1, "nothing was deleted")
|
||||
}
|
||||
|
||||
func TestSyncStartAndLock(t *testing.T) {
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
t.Run("prefix="+prefix, func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
release := e.blockSync()
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
release := e.blockSync()
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/music/rand").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/info").Code, "info is available during sync")
|
||||
|
||||
progress := e.do(http.MethodGet, prefix+"/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, progress.Code)
|
||||
assert.Contains(t, progress.Body.String(), `"progress"`)
|
||||
progress := e.do(http.MethodGet, "/api/v1/sync/progress")
|
||||
assert.Equal(t, http.StatusOK, progress.Code)
|
||||
assert.Contains(t, progress.Body.String(), `"progress"`)
|
||||
|
||||
release()
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/all").Code)
|
||||
result := e.do(http.MethodGet, prefix+"/sync/progress")
|
||||
assert.Contains(t, result.Body.String(), `"total_time"`)
|
||||
})
|
||||
}
|
||||
release()
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code)
|
||||
result := e.do(http.MethodGet, "/api/v1/sync/progress")
|
||||
assert.Contains(t, result.Body.String(), `"total_time"`)
|
||||
}
|
||||
|
||||
func TestSyncResetEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/sync/reset").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync/reset").Code)
|
||||
assert.Empty(t, e.store.Soundtracks)
|
||||
assert.Empty(t, e.store.Songs)
|
||||
}
|
||||
|
||||
func TestLegacyRoutesAreDeprecated(t *testing.T) {
|
||||
func TestLegacyRoutesAreRemoved(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/version")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "true", rec.Header().Get("Deprecation"))
|
||||
|
||||
rec = e.do(http.MethodGet, "/api/v1/version")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Empty(t, rec.Header().Get("Deprecation"))
|
||||
for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code)
|
||||
assert.False(t, e.server.sync.IsSyncing())
|
||||
}
|
||||
|
||||
func TestVersionEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
var latest backend.VersionData
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/version").Body.Bytes(), &latest))
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version").Body.Bytes(), &latest))
|
||||
assert.Equal(t, backend.GetLatestVersion(), latest)
|
||||
|
||||
var history []backend.VersionData
|
||||
@@ -95,28 +87,19 @@ func TestVersionEndpoints(t *testing.T) {
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/health")
|
||||
rec := e.do(http.MethodGet, "/api/v1/health")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestSyncAliasesAreRemoved(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} {
|
||||
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} {
|
||||
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path)
|
||||
}
|
||||
assert.False(t, e.server.sync.IsSyncing())
|
||||
}
|
||||
|
||||
func TestV1HealthEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/api/v1/health")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestDocsAndSpecLinks(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
|
||||
|
||||
@@ -68,8 +68,11 @@ func New(cfg config.Config, q repository.Querier, database Database) (*Server, e
|
||||
|
||||
// afterSync refreshes cached data once a sync has finished.
|
||||
func (s *Server) afterSync(ctx context.Context) {
|
||||
if err := s.music.Reset(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reset music after sync", zap.Error(err))
|
||||
if err := s.music.EndAllQueues(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to end the queues after sync", zap.Error(err))
|
||||
}
|
||||
if err := s.music.ReloadSoundtracks(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload soundtracks after sync", zap.Error(err))
|
||||
}
|
||||
if err := s.search.Reload(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err))
|
||||
|
||||
@@ -19,7 +19,7 @@ func (in *limitInput) capped() int32 {
|
||||
return int32(min(in.Limit, maxStatsLimit))
|
||||
}
|
||||
|
||||
func (s *Server) registerStatistics(a apis) {
|
||||
func (s *Server) registerStatistics(a huma.API) {
|
||||
st := s.stats
|
||||
op := func(id, path, summary, description string) route {
|
||||
return route{op: huma.Operation{
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
func (s *Server) registerSync(a apis) {
|
||||
registry := a.v1.OpenAPI().Components.Schemas
|
||||
func (s *Server) registerSync(a huma.API) {
|
||||
registry := a.OpenAPI().Components.Schemas
|
||||
progress := huma.Operation{
|
||||
OperationID: "get-sync-progress",
|
||||
Method: http.MethodGet,
|
||||
@@ -30,7 +30,7 @@ func (s *Server) registerSync(a apis) {
|
||||
},
|
||||
},
|
||||
}
|
||||
register(s, a, route{op: progress, legacyPath: "/sync/progress"},
|
||||
register(s, a, route{op: progress},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
|
||||
if s.sync.IsSyncing() {
|
||||
return ok[any](s.sync.Progress(), nil)
|
||||
@@ -49,7 +49,6 @@ func (s *Server) registerSync(a apis) {
|
||||
Tags: []string{"sync"},
|
||||
Errors: []int{http.StatusLocked},
|
||||
},
|
||||
legacyPath: path,
|
||||
}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
message := "Start syncing soundtracks"
|
||||
if full {
|
||||
@@ -78,7 +77,7 @@ func (s *Server) registerSync(a apis) {
|
||||
Tags: []string{"sync"},
|
||||
Hidden: true,
|
||||
}
|
||||
register(s, a, route{op: reset, legacyPath: "/sync/reset", locked: true},
|
||||
register(s, a, route{op: reset, locked: true},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
logging.GetLogger().Info("Resetting soundtracks database")
|
||||
return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
|
||||
|
||||
@@ -55,7 +55,7 @@ func (s *Server) passwordValid(password string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) registerToken(a apis) {
|
||||
func (s *Server) registerToken(a huma.API) {
|
||||
create := huma.Operation{
|
||||
OperationID: "create-token",
|
||||
Method: http.MethodPost,
|
||||
@@ -65,7 +65,7 @@ func (s *Server) registerToken(a apis) {
|
||||
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
|
||||
Tags: []string{"auth"},
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
|
||||
Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)},
|
||||
Middlewares: huma.Middlewares{middleware.RateLimit(a, 10, time.Minute)},
|
||||
}
|
||||
register(s, a, route{op: create, public: true},
|
||||
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
|
||||
|
||||
Reference in new issue
Block a user