diff --git a/internal/app/app.go b/internal/app/app.go index bf06f70..c612b5c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,6 +16,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" @@ -96,6 +97,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { CoverArt: covers, Transcoder: transcoding.NewManager(cfg.FFmpegPath), Podcasts: podcastMgr, + Jukebox: jukebox.New(cfg.MPVPath), Log: log, } @@ -103,6 +105,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { } func (a *App) Close() error { + a.Subsonic.Jukebox.Close() return a.DB.Close() } diff --git a/internal/config/config.go b/internal/config/config.go index f329177..bf403a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { MusicDirs []string FFmpegPath string FFprobePath string + MPVPath string ScanInterval time.Duration SessionSecret string CookieSecure bool @@ -49,6 +50,7 @@ func Load() (Config, error) { DataDir: envOr("VIBESONIC_DATA_DIR", "/var/lib/vibesonic"), FFmpegPath: envOr("VIBESONIC_FFMPEG_PATH", "ffmpeg"), FFprobePath: envOr("VIBESONIC_FFPROBE_PATH", "ffprobe"), + MPVPath: envOr("VIBESONIC_MPV_PATH", "mpv"), SessionSecret: os.Getenv("VIBESONIC_SESSION_SECRET"), CookieSecure: envBool("VIBESONIC_COOKIE_SECURE", true), } diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1736734597_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1736734597_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1736734597_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 @@ -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 diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1736734597_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1736734597_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000..8423fdc Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist1736734597_004_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts differ diff --git a/internal/subsonic/jukebox.go b/internal/subsonic/jukebox.go new file mode 100644 index 0000000..f17665f --- /dev/null +++ b/internal/subsonic/jukebox.go @@ -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 +} diff --git a/internal/subsonic/jukebox_test.go b/internal/subsonic/jukebox_test.go new file mode 100644 index 0000000..def544e --- /dev/null +++ b/internal/subsonic/jukebox_test.go @@ -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) + } +} diff --git a/internal/subsonic/server.go b/internal/subsonic/server.go index 5d7782d..e6e6dfd 100644 --- a/internal/subsonic/server.go +++ b/internal/subsonic/server.go @@ -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 } diff --git a/internal/subsonic/testutil_test.go b/internal/subsonic/testutil_test.go index 7d1159a..92285a0 100644 --- a/internal/subsonic/testutil_test.go +++ b/internal/subsonic/testutil_test.go @@ -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 }