From 6e2b875eaa53bf1ba4c1d16244e5ceb976560dce Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:09:36 -0500 Subject: [PATCH] Subsonic: playlist endpoints (getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist) --- internal/database/playlists.go | 47 +++++ internal/subsonic/playlists.go | 268 ++++++++++++++++++++++++++++ internal/subsonic/playlists_test.go | 146 +++++++++++++++ 3 files changed, 461 insertions(+) create mode 100644 internal/subsonic/playlists.go create mode 100644 internal/subsonic/playlists_test.go diff --git a/internal/database/playlists.go b/internal/database/playlists.go index 98076bd..a9a11f2 100644 --- a/internal/database/playlists.go +++ b/internal/database/playlists.go @@ -123,3 +123,50 @@ func RemovePlaylistEntry(db *sql.DB, entryID string) error { _, err := db.Exec(`DELETE FROM playlist_entries WHERE id = ?`, entryID) return err } + +// ReplacePlaylistSongs atomically replaces a playlist's entire song list +// with songIDs, in order. +func ReplacePlaylistSongs(db *sql.DB, playlistID string, songIDs []string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM playlist_entries WHERE playlist_id = ?`, playlistID); err != nil { + return err + } + for i, songID := range songIDs { + if _, err := tx.Exec(`INSERT INTO playlist_entries (id, playlist_id, song_id, position) VALUES (?, ?, ?, ?)`, + idgen.New("pe"), playlistID, songID, i); err != nil { + return err + } + } + if _, err := tx.Exec(`UPDATE playlists SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, playlistID); err != nil { + return err + } + return tx.Commit() +} + +// ListPlaylistsByOwner returns only playlists owned by userID (used when +// an admin requests another user's playlists by username). +func ListPlaylistsByOwner(db *sql.DB, ownerID string) ([]*Playlist, error) { + rows, err := db.Query(playlistSelect+` WHERE owner_id = ? ORDER BY name COLLATE NOCASE`, ownerID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Playlist + for rows.Next() { + var p Playlist + var isPublic int + var createdAt, updatedAt string + if err := rows.Scan(&p.ID, &p.OwnerID, &p.Name, &p.Description, &isPublic, &createdAt, &updatedAt); err != nil { + return nil, err + } + p.IsPublic = isPublic != 0 + p.CreatedAt, _ = parseTime(createdAt) + p.UpdatedAt, _ = parseTime(updatedAt) + out = append(out, &p) + } + return out, rows.Err() +} diff --git a/internal/subsonic/playlists.go b/internal/subsonic/playlists.go new file mode 100644 index 0000000..b34b636 --- /dev/null +++ b/internal/subsonic/playlists.go @@ -0,0 +1,268 @@ +package subsonic + +import ( + "database/sql" + "strconv" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + register("getPlaylists", handleGetPlaylists) + register("getPlaylist", handleGetPlaylist) + register("createPlaylist", handleCreatePlaylist) + register("updatePlaylist", handleUpdatePlaylist) + register("deletePlaylist", handleDeletePlaylist) +} + +func playlistToModel(db *sql.DB, p *database.Playlist) Playlist { + entries, _ := database.ListPlaylistEntries(db, p.ID) + duration := 0 + for _, e := range entries { + if mf, ok := mediaFileForSongID(db, e.SongID); ok { + duration += int(mf.DurationSeconds + 0.5) + } + } + owner, _ := database.GetUserByID(db, p.OwnerID) + ownerName := "" + if owner != nil { + ownerName = owner.Username + } + return Playlist{ + ID: p.ID, + Name: p.Name, + Comment: p.Description, + Owner: ownerName, + Public: p.IsPublic, + SongCount: len(entries), + Duration: duration, + Created: isoTime(p.CreatedAt), + Changed: isoTime(p.UpdatedAt), + } +} + +func mediaFileForSongID(db *sql.DB, songID string) (*database.MediaFile, bool) { + song, err := database.GetSong(db, songID) + if err != nil { + return nil, false + } + mf, err := database.GetMediaFile(db, song.MediaFileID) + if err != nil { + return nil, false + } + return mf, true +} + +// canSeePlaylist reports whether user may view playlist p: they own it, +// it's public, or they're an admin. +func canSeePlaylist(user *database.User, p *database.Playlist) bool { + return user.IsAdmin || user.ID == p.OwnerID || p.IsPublic +} + +func handleGetPlaylists(rc *Context) *apiError { + db := rc.Server.DB + var playlists []*database.Playlist + var err error + + if username := rc.Params.Get("username"); username != "" { + if !rc.User.IsAdmin && username != rc.User.Username { + return errUnauthorized("viewing another user's playlists") + } + target, terr := database.GetUserByUsername(db, username) + if terr != nil { + return errNotFound("User") + } + playlists, err = database.ListPlaylistsByOwner(db, target.ID) + } else { + playlists, err = database.ListPlaylistsVisibleToUser(db, rc.User.ID) + } + if err != nil { + return newError(ErrGeneric, err.Error()) + } + + out := &Playlists{} + for _, p := range playlists { + out.Playlist = append(out.Playlist, playlistToModel(db, p)) + } + rc.Response = newResponse() + rc.Response.Playlists = out + return nil +} + +func handleGetPlaylist(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + p, err := database.GetPlaylist(db, id) + if err != nil { + return errNotFound("Playlist") + } + if !canSeePlaylist(rc.User, p) { + return errUnauthorized("viewing this playlist") + } + + entries, err := database.ListPlaylistEntries(db, id) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &PlaylistWithSongs{Playlist: playlistToModel(db, p)} + for _, e := range entries { + song, serr := database.GetSong(db, e.SongID) + if serr != nil { + continue + } + mf, merr := database.GetMediaFile(db, song.MediaFileID) + if merr != nil { + continue + } + out.Entry = append(out.Entry, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.Playlist = out + return nil +} + +func handleCreatePlaylist(rc *Context) *apiError { + if !rc.User.Allows(rc.User.Permissions.Playlist) { + return errUnauthorized("creating playlists") + } + db := rc.Server.DB + songIDs := rc.Params.GetAll("songId") + + if playlistID := rc.Params.Get("playlistId"); playlistID != "" { + p, err := database.GetPlaylist(db, playlistID) + if err != nil { + return errNotFound("Playlist") + } + if p.OwnerID != rc.User.ID && !rc.User.IsAdmin { + return errUnauthorized("modifying this playlist") + } + if err := database.ReplacePlaylistSongs(db, playlistID, songIDs); err != nil { + return newError(ErrGeneric, err.Error()) + } + return respondWithPlaylist(rc, playlistID) + } + + name, aerr := rc.Params.Required("name") + if aerr != nil { + return aerr + } + p, err := database.CreatePlaylist(db, rc.User.ID, name, "", false) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + if len(songIDs) > 0 { + if err := database.ReplacePlaylistSongs(db, p.ID, songIDs); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + return respondWithPlaylist(rc, p.ID) +} + +func respondWithPlaylist(rc *Context, playlistID string) *apiError { + db := rc.Server.DB + p, err := database.GetPlaylist(db, playlistID) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + entries, err := database.ListPlaylistEntries(db, playlistID) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &PlaylistWithSongs{Playlist: playlistToModel(db, p)} + for _, e := range entries { + song, serr := database.GetSong(db, e.SongID) + if serr != nil { + continue + } + mf, merr := database.GetMediaFile(db, song.MediaFileID) + if merr != nil { + continue + } + out.Entry = append(out.Entry, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID)) + } + rc.Response = newResponse() + rc.Response.Playlist = out + return nil +} + +func handleUpdatePlaylist(rc *Context) *apiError { + id, aerr := rc.Params.Required("playlistId") + if aerr != nil { + return aerr + } + db := rc.Server.DB + p, err := database.GetPlaylist(db, id) + if err != nil { + return errNotFound("Playlist") + } + if p.OwnerID != rc.User.ID && !rc.User.IsAdmin { + return errUnauthorized("modifying this playlist") + } + + name := p.Name + if v := rc.Params.Get("name"); v != "" { + name = v + } + comment := p.Description + if rc.Params.Has("comment") { + comment = rc.Params.Get("comment") + } + public := p.IsPublic + if rc.Params.Has("public") { + public = rc.Params.Bool("public", p.IsPublic) + } + if err := database.RenamePlaylist(db, id, name, comment, public); err != nil { + return newError(ErrGeneric, err.Error()) + } + + toRemove := rc.Params.GetAll("songIndexToRemove") + toAdd := rc.Params.GetAll("songIdToAdd") + if len(toRemove) > 0 || len(toAdd) > 0 { + entries, err := database.ListPlaylistEntries(db, id) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + removeSet := map[int]bool{} + for _, s := range toRemove { + if n, err := strconv.Atoi(s); err == nil { + removeSet[n] = true + } + } + var songIDs []string + for i, e := range entries { + if !removeSet[i] { + songIDs = append(songIDs, e.SongID) + } + } + songIDs = append(songIDs, toAdd...) + if err := database.ReplacePlaylistSongs(db, id, songIDs); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + + rc.Response = newResponse() + return nil +} + +func handleDeletePlaylist(rc *Context) *apiError { + id, aerr := rc.Params.Required("id") + if aerr != nil { + return aerr + } + db := rc.Server.DB + p, err := database.GetPlaylist(db, id) + if err != nil { + return errNotFound("Playlist") + } + if p.OwnerID != rc.User.ID && !rc.User.IsAdmin { + return errUnauthorized("deleting this playlist") + } + if err := database.DeletePlaylist(db, id); err != nil { + return newError(ErrGeneric, err.Error()) + } + rc.Response = newResponse() + return nil +} diff --git a/internal/subsonic/playlists_test.go b/internal/subsonic/playlists_test.go new file mode 100644 index 0000000..0ae2101 --- /dev/null +++ b/internal/subsonic/playlists_test.go @@ -0,0 +1,146 @@ +package subsonic + +import ( + "net/http/httptest" + "testing" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func TestPlaylistLifecycle(t *testing.T) { + s, db := newTestServer(t) + createTestUser(t, s, "alice", "password123", false) + seedLibrary(t, s) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + songs, err := database.ListSongs(db, 2, 0) + if err != nil || len(songs) != 2 { + t.Fatalf("ListSongs: %v %v", songs, err) + } + + // Create + q := baseParams("alice", "password123") + q.Set("name", "My Playlist") + q.Add("songId", songs[0].ID) + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/createPlaylist.view", q) + j := decodeJSON(t, resp) + playlist := j["playlist"].(map[string]any) + if playlist["name"] != "My Playlist" { + t.Fatalf("created playlist name = %v", playlist["name"]) + } + playlistID := playlist["id"].(string) + if entrySongs, ok := playlist["entry"].([]any); !ok || len(entrySongs) != 1 { + t.Fatalf("created playlist entries = %v, want 1", playlist["entry"]) + } + + // getPlaylists lists it + resp = getPath(t, srv.URL, "/rest/getPlaylists.view", q) + j = decodeJSON(t, resp) + playlists := j["playlists"].(map[string]any)["playlist"].([]any) + if len(playlists) != 1 { + t.Fatalf("getPlaylists = %v, want 1", playlists) + } + + // getPlaylist shows the one song + q2 := baseParams("alice", "password123") + q2.Set("id", playlistID) + q2.Set("f", "json") + resp = getPath(t, srv.URL, "/rest/getPlaylist.view", q2) + j = decodeJSON(t, resp) + got := j["playlist"].(map[string]any) + if entries, ok := got["entry"].([]any); !ok || len(entries) != 1 { + t.Fatalf("getPlaylist entries = %v, want 1", got["entry"]) + } + + // updatePlaylist: rename, add second song + q3 := baseParams("alice", "password123") + q3.Set("playlistId", playlistID) + q3.Set("name", "Renamed") + q3.Add("songIdToAdd", songs[1].ID) + resp = getPath(t, srv.URL, "/rest/updatePlaylist.view", q3) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("updatePlaylist failed: %+v", x) + } + + resp = getPath(t, srv.URL, "/rest/getPlaylist.view", q2) + j = decodeJSON(t, resp) + got = j["playlist"].(map[string]any) + if got["name"] != "Renamed" { + t.Fatalf("playlist name after update = %v, want Renamed", got["name"]) + } + if entries, ok := got["entry"].([]any); !ok || len(entries) != 2 { + t.Fatalf("entries after add = %v, want 2", got["entry"]) + } + + // updatePlaylist: remove index 0 + q4 := baseParams("alice", "password123") + q4.Set("playlistId", playlistID) + q4.Add("songIndexToRemove", "0") + getPath(t, srv.URL, "/rest/updatePlaylist.view", q4) + + resp = getPath(t, srv.URL, "/rest/getPlaylist.view", q2) + j = decodeJSON(t, resp) + got = j["playlist"].(map[string]any) + if entries, ok := got["entry"].([]any); !ok || len(entries) != 1 { + t.Fatalf("entries after remove = %v, want 1", got["entry"]) + } + + // deletePlaylist + q5 := baseParams("alice", "password123") + q5.Set("id", playlistID) + resp = getPath(t, srv.URL, "/rest/deletePlaylist.view", q5) + x = decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("deletePlaylist failed: %+v", x) + } + + resp = getPath(t, srv.URL, "/rest/getPlaylists.view", q) + j = decodeJSON(t, resp) + pls := j["playlists"].(map[string]any) + if _, ok := pls["playlist"]; ok { + t.Fatalf("expected no playlists after delete, got %v", pls) + } +} + +func TestPlaylistPermissions(t *testing.T) { + s, db := newTestServer(t) + owner := createTestUser(t, s, "owner", "password123", false) + createTestUser(t, s, "other", "password123", false) + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + p, err := database.CreatePlaylist(db, owner.ID, "Private", "", false) + if err != nil { + t.Fatal(err) + } + + q := baseParams("other", "password123") + q.Set("id", p.ID) + resp := getPath(t, srv.URL, "/rest/getPlaylist.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50 accessing another user's private playlist, got %+v", x) + } + + resp = getPath(t, srv.URL, "/rest/deletePlaylist.view", q) + x = decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50 deleting another user's playlist, got %+v", x) + } +} + +func TestGetPlaylistNotFound(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("id", "pl_doesnotexist") + resp := getPath(t, srv.URL, "/rest/getPlaylist.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound { + t.Fatalf("expected error 70, got %+v", x) + } +}