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:
Sansan committed 2026-10-07 20:03:03 +02:00
1 parent b5dc42b654
commit a705d7b476
28 files changed
+1103 -320

No files matched your search

+75 -28
View File
@@ -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)