Subsonic: jukeboxControl endpoint wired to real mpv-backed controller
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"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/metadata"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
||||||
@@ -96,6 +97,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
|||||||
CoverArt: covers,
|
CoverArt: covers,
|
||||||
Transcoder: transcoding.NewManager(cfg.FFmpegPath),
|
Transcoder: transcoding.NewManager(cfg.FFmpegPath),
|
||||||
Podcasts: podcastMgr,
|
Podcasts: podcastMgr,
|
||||||
|
Jukebox: jukebox.New(cfg.MPVPath),
|
||||||
Log: log,
|
Log: log,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
|
a.Subsonic.Jukebox.Close()
|
||||||
return a.DB.Close()
|
return a.DB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Config struct {
|
|||||||
MusicDirs []string
|
MusicDirs []string
|
||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
FFprobePath string
|
FFprobePath string
|
||||||
|
MPVPath string
|
||||||
ScanInterval time.Duration
|
ScanInterval time.Duration
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
CookieSecure bool
|
CookieSecure bool
|
||||||
@@ -49,6 +50,7 @@ func Load() (Config, error) {
|
|||||||
DataDir: envOr("VIBESONIC_DATA_DIR", "/var/lib/vibesonic"),
|
DataDir: envOr("VIBESONIC_DATA_DIR", "/var/lib/vibesonic"),
|
||||||
FFmpegPath: envOr("VIBESONIC_FFMPEG_PATH", "ffmpeg"),
|
FFmpegPath: envOr("VIBESONIC_FFMPEG_PATH", "ffmpeg"),
|
||||||
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"),
|
||||||
|
MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"),
|
||||||
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"),
|
||||||
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true),
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"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/podcasts"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/transcoding"
|
||||||
@@ -25,6 +26,7 @@ type Server struct {
|
|||||||
CoverArt *coverart.Store
|
CoverArt *coverart.Store
|
||||||
Transcoder *transcoding.Manager
|
Transcoder *transcoding.Manager
|
||||||
Podcasts *podcasts.Manager
|
Podcasts *podcasts.Manager
|
||||||
|
Jukebox *jukebox.Jukebox
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"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/metadata"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/podcasts"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
||||||
@@ -40,8 +41,10 @@ func newTestServer(t *testing.T) (*Server, *sql.DB) {
|
|||||||
CoverArt: coverart.New(t.TempDir(), ""),
|
CoverArt: coverart.New(t.TempDir(), ""),
|
||||||
Transcoder: transcoding.NewManager(""),
|
Transcoder: transcoding.NewManager(""),
|
||||||
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
|
Podcasts: podcasts.NewManager(db, t.TempDir(), "", log),
|
||||||
|
Jukebox: jukebox.New(""),
|
||||||
Log: log,
|
Log: log,
|
||||||
}
|
}
|
||||||
|
t.Cleanup(s.Jukebox.Close)
|
||||||
return s, db
|
return s, db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user