Subsonic: playlist endpoints (getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist)
This commit is contained in:
@@ -123,3 +123,50 @@ func RemovePlaylistEntry(db *sql.DB, entryID string) error {
|
|||||||
_, err := db.Exec(`DELETE FROM playlist_entries WHERE id = ?`, entryID)
|
_, err := db.Exec(`DELETE FROM playlist_entries WHERE id = ?`, entryID)
|
||||||
return err
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user