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
+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
}