Subsonic: jukeboxControl endpoint wired to real mpv-backed controller

This commit is contained in:
2026-08-23 00:29:59 -05:00
parent 12eb4b9d01
commit 2e222bd193
8 changed files with 283 additions and 0 deletions
@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:1.023322,
seg00000.ts
#EXT-X-ENDLIST
+139
View File
@@ -0,0 +1,139 @@
package subsonic
import (
"database/sql"
"strconv"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/jukebox"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
)
func init() {
register("jukeboxControl", handleJukeboxControl)
}
// resolveSongPath maps a song ID to an absolute, path-traversal-checked
// filesystem path — the same validation every other media endpoint uses,
// so the jukebox can't be used to make the server play (or leak the
// existence of) an arbitrary file.
func resolveSongPath(db *sql.DB) jukebox.ResolveFunc {
return func(songID string) (string, error) {
song, err := database.GetSong(db, songID)
if err != nil {
return "", err
}
mf, err := database.GetMediaFile(db, song.MediaFileID)
if err != nil {
return "", err
}
folder, err := database.GetMusicFolder(db, mf.MusicFolderID)
if err != nil {
return "", err
}
return streaming.ResolvePath(folder.Path, mf.RelativePath)
}
}
func jukeboxStatusModel(state jukebox.State) *JukeboxStatus {
return &JukeboxStatus{
CurrentIndex: state.CurrentIndex,
Playing: state.Playing,
Gain: state.Gain,
Position: state.PositionSecs,
}
}
func jukeboxPlaylistModel(rc *Context, state jukebox.State) *JukeboxPlaylist {
out := &JukeboxPlaylist{JukeboxStatus: *jukeboxStatusModel(state)}
db := rc.Server.DB
for _, id := range state.SongIDs {
song, err := database.GetSong(db, id)
if err != nil {
continue
}
mf, err := database.GetMediaFile(db, song.MediaFileID)
if err != nil {
continue
}
out.Entry = append(out.Entry, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID))
}
return out
}
func handleJukeboxControl(rc *Context) *apiError {
if !rc.User.Allows(rc.User.Permissions.Jukebox) {
return errUnauthorized("jukebox control")
}
jb := rc.Server.Jukebox
if jb == nil || !jb.Available {
return newError(ErrGeneric, "Jukebox is not available on this server (mpv is not installed)")
}
action, aerr := rc.Params.Required("action")
if aerr != nil {
return aerr
}
resolve := resolveSongPath(rc.Server.DB)
var state jukebox.State
var err error
switch action {
case "get":
state, err = jb.Status()
if err != nil {
return newError(ErrGeneric, err.Error())
}
rc.Response = newResponse()
rc.Response.JukeboxPlaylist = jukeboxPlaylistModel(rc, state)
return nil
case "status":
state, err = jb.Status()
case "set":
state, err = jb.Set(rc.Params.GetAll("id"), resolve)
case "add":
state, err = jb.Add(rc.Params.GetAll("id"), resolve)
case "clear":
state, err = jb.Clear()
case "remove":
idx, ierr := rc.Params.RequiredInt("index")
if ierr != nil {
return ierr
}
state, err = jb.Remove(idx)
case "shuffle":
state, err = jb.Shuffle(resolve)
case "skip":
idx, ierr := rc.Params.RequiredInt("index")
if ierr != nil {
return ierr
}
offset := float64(rc.Params.Int("offset", 0))
state, err = jb.SkipTo(idx, offset)
case "start":
state, err = jb.Start()
case "stop":
state, err = jb.Stop()
case "setGain":
gainStr, gerr := rc.Params.Required("gain")
if gerr != nil {
return gerr
}
gain, perr := strconv.ParseFloat(gainStr, 64)
if perr != nil {
return newError(ErrGeneric, "invalid gain value")
}
state, err = jb.SetGain(gain)
default:
return newError(ErrGeneric, "Unknown jukebox action: "+action)
}
if err != nil {
return newError(ErrGeneric, err.Error())
}
rc.Response = newResponse()
rc.Response.JukeboxStatus = jukeboxStatusModel(state)
return nil
}
+127
View File
@@ -0,0 +1,127 @@
package subsonic
import (
"net/http/httptest"
"os/exec"
"testing"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func requireMPVForTest(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("mpv"); err != nil {
t.Skip("mpv not installed")
}
}
func TestJukeboxControlLifecycle(t *testing.T) {
requireMPVForTest(t)
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", true)
seedLibrary(t, s)
songs := []string{}
q0 := baseParams("alice", "password123")
q0.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getRandomSongs.view", q0)
j := decodeJSON(t, resp)
for _, s := range j["randomSongs"].(map[string]any)["song"].([]any) {
songs = append(songs, s.(map[string]any)["id"].(string))
}
if len(songs) == 0 {
t.Fatal("no songs available to jukebox")
}
q := baseParams("alice", "password123")
q.Set("action", "set")
for _, id := range songs {
q.Add("id", id)
}
q.Set("f", "json")
resp = getPath(t, srv.URL, "/rest/jukeboxControl.view", q)
j = decodeJSON(t, resp)
status, ok := j["jukeboxStatus"].(map[string]any)
if !ok {
t.Fatalf("expected jukeboxStatus in response: %v", j)
}
_ = status
time.Sleep(200 * time.Millisecond)
q2 := baseParams("alice", "password123")
q2.Set("action", "get")
q2.Set("f", "json")
resp = getPath(t, srv.URL, "/rest/jukeboxControl.view", q2)
j = decodeJSON(t, resp)
playlist, ok := j["jukeboxPlaylist"].(map[string]any)
if !ok {
t.Fatalf("expected jukeboxPlaylist for action=get: %v", j)
}
entries, ok := playlist["entry"].([]any)
if !ok || len(entries) != len(songs) {
t.Fatalf("jukeboxPlaylist entries = %v, want %d", playlist["entry"], len(songs))
}
q3 := baseParams("alice", "password123")
q3.Set("action", "stop")
resp = getPath(t, srv.URL, "/rest/jukeboxControl.view", q3)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("stop failed: %+v", x)
}
q4 := baseParams("alice", "password123")
q4.Set("action", "setGain")
q4.Set("gain", "0.3")
q4.Set("f", "json")
resp = getPath(t, srv.URL, "/rest/jukeboxControl.view", q4)
j = decodeJSON(t, resp)
status = j["jukeboxStatus"].(map[string]any)
if gain, _ := status["gain"].(float64); gain < 0.29 || gain > 0.31 {
t.Fatalf("gain = %v, want ~0.3", status["gain"])
}
q5 := baseParams("alice", "password123")
q5.Set("action", "clear")
resp = getPath(t, srv.URL, "/rest/jukeboxControl.view", q5)
x = decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("clear failed: %+v", x)
}
}
func TestJukeboxControlRequiresPermission(t *testing.T) {
requireMPVForTest(t)
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
srv := httptest.NewServer(s.Router())
defer srv.Close()
perms := u.Permissions
perms.Jukebox = false
if err := database.SetUserPermissions(db, u.ID, perms); err != nil {
t.Fatal(err)
}
q := baseParams("alice", "password123")
q.Set("action", "status")
resp := getPath(t, srv.URL, "/rest/jukeboxControl.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
t.Fatalf("expected error 50 without jukebox permission, got %+v", x)
}
}
func TestJukeboxControlMissingAction(t *testing.T) {
requireMPVForTest(t)
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", true)
resp := getPath(t, srv.URL, "/rest/jukeboxControl.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
+2
View File
@@ -8,6 +8,7 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/jukebox"
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
@@ -25,6 +26,7 @@ type Server struct {
CoverArt *coverart.Store
Transcoder *transcoding.Manager
Podcasts *podcasts.Manager
Jukebox *jukebox.Jukebox
Log *slog.Logger
}
+3
View File
@@ -12,6 +12,7 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/jukebox"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
@@ -40,8 +41,10 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) {
CoverArt: coverart.New(t.TempDir(), ""),
Transcoder: transcoding.NewManager(""),
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
Jukebox: jukebox.New(""),
Log: log,
}
t.Cleanup(s.Jukebox.Close)
return s, db
}