Skip to content

Commit

Permalink
Expose creating and listing all playlists via the Euterpe API
Browse files Browse the repository at this point in the history
  • Loading branch information
ironsmile committed Nov 16, 2024
1 parent 3c4f4bb commit d8cc509
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 1 deletion.
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ Authentication tokens can be acquired using the `/v1/login/token/` endpoint desc
* [Get Artist Image](#get-artist-image)
* [Upload Artist Image](#upload-artist-image)
* [Remove Artist Image](#remove-artist-image)
* [Playlists](#playlists)
* [List Playlists](#list-playlists)
* [Create Playlist](#create-playlist)
* [Token Request](#token-request)
* [Register Token](#register-token)

Expand Down Expand Up @@ -521,6 +524,67 @@ DELETE /v1/artist/{artistID}/image

Will remove the artist image the server database. Note, this will not touch any files on the file system.

### Playlists

Euterpe supports creating and using playlists. Below you will find all supported operations
with playlists.

#### List Playlists

```
GET /v1/playlists
```

Returns all playlists in a list. This list omits the playlist tracks and returns only the basic information about each playlist. Example response:

```js
{
"playlists": [
{
"id": 1, // ID of the playlist which have to be used for operations with it.
"name": "Quiet Evening", // Display name of the playlist.
"description": "For one is tired of heavy metal!", // Optional longer description.
"tracks_count": 3, // Number of track in this playlist.
"duration": 488000, // Duration of the playlist in milliseconds.
"created_at": 1728838802, // Unix timestamp for when the playlist was created.
"updated_at": 1728838923 // Unix timestamp for when the playlist was last updated.
},
{
"id": 2,
"name": "Summer Hits",
"tracks_count": 4,
"duration": 435000,
"created_at": 1731773035,
"updated_at": 1731773035
}
]
}
```

#### Create Playlist

```
POST /v1/playlists
{
"name": "Quiet Evening",
"add_tracks_by_id": [14, 18, 255, 99]
}
```

Creating a playlist is done with a `POST` request with a JSON body. The body is an object
with the following properties:

* `name` (_string_) - A short name of the playlist. Used for displaying it in lists.
* `add_tracks_by_id` (_list_ with integers) - An ordered list with track IDs which will be added in the playlist. IDs may repeat.

This API method returns the ID of the newly created playlist:

```js
{
"created_playlsit_id": 2
}
```

### Token Request

```
Expand Down
8 changes: 8 additions & 0 deletions src/webserver/apiv1_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ const (
APIv1EndpointSearch = "/v1/search/"
APIv1EndpointLoginToken = "/v1/login/token/"
APIv1EndpointRegisterToken = "/v1/register/token/"

APIv1EndpointPlaylists = "/v1/playlists"
APIv1EndpointPlaylist = "/v1/playlist/{playlistID}"
)

// APIv1Methods defines on which HTTP methods APIv1 endpoints will respond to.
Expand All @@ -33,4 +36,9 @@ var APIv1Methods map[string][]string = map[string][]string{
APIv1EndpointAlbumArtwork: {
http.MethodGet, http.MethodHead, http.MethodPut, http.MethodDelete,
},

APIv1EndpointPlaylists: {http.MethodGet, http.MethodPost},
APIv1EndpointPlaylist: {
http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodDelete,
},
}
130 changes: 130 additions & 0 deletions src/webserver/handler_playlists.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package webserver

import (
"encoding/json"
"fmt"
"net/http"

"github.com/ironsmile/euterpe/src/library"
"github.com/ironsmile/euterpe/src/playlists"
)

// playlistsHandler will list playlists (GET) and create a new one (POST).
type playlistsHandler struct {
playlists playlists.Playlister
}

// NewPlaylistsHandler returns an http.Handler which supports listing all playlists
// with a GET request and creating a new playlist with a POST request.
func NewPlaylistsHandler(playlister playlists.Playlister) http.Handler {
return &playlistsHandler{
playlists: playlister,
}
}

// ServeHTTP is required by the http.Handler's interface
func (plh playlistsHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
plh.create(w, req)
return
}

plh.listAll(w, req)
}

func (plh playlistsHandler) create(w http.ResponseWriter, req *http.Request) {
listReq := playlistRequest{}
dec := json.NewDecoder(req.Body)
if err := dec.Decode(&listReq); err != nil {
http.Error(
w,
fmt.Sprintf("Cannot decode playlist JSON: %s", err),
http.StatusBadRequest,
)
return
}

newID, err := plh.playlists.Create(req.Context(), listReq.Name, listReq.AddTrackByID)
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to create playlist: %s", err),
http.StatusInternalServerError,
)
return
}

resp := createPlaylistResponse{
CreatedPlaylistID: newID,
}

enc := json.NewEncoder(w)
if err := enc.Encode(resp); err != nil {
http.Error(
w,
fmt.Sprintf("Playlist created but cannot write response JSON: %s", err),
http.StatusInternalServerError,
)
return
}
}

func (plh playlistsHandler) listAll(w http.ResponseWriter, req *http.Request) {
resp := playlistsResponse{}
playlists, err := plh.playlists.GetAll(req.Context())
if err != nil {
http.Error(
w,
fmt.Sprintf("Getting playlists failed: %s", err),
http.StatusInternalServerError,
)
return
}

for _, pl := range playlists {
resp.Playlists = append(resp.Playlists, playlist{
ID: pl.ID,
Name: pl.Name,
Desc: pl.Desc,
TracksCount: pl.TracksCount,
Duration: pl.Duration.Milliseconds(),
CreatedAt: pl.CreatedAt.Unix(),
UpdatedAt: pl.UpdatedAt.Unix(),
})
}

enc := json.NewEncoder(w)
if err := enc.Encode(resp); err != nil {
http.Error(
w,
fmt.Sprintf("Encoding playlists response failed: %s", err),
http.StatusInternalServerError,
)
}
}

type playlistsResponse struct {
Playlists []playlist `json:"playlists"`
}

type createPlaylistResponse struct {
CreatedPlaylistID int64 `json:"created_playlsit_id"`
}

type playlist struct {
ID int64 `json:"id"`
Name string `json:"name"`
Desc string `json:"description,omitempty"`
TracksCount int64 `json:"tracks_count"`
Duration int64 `json:"duration"` // Playlist duration in millisecs.
CreatedAt int64 `json:"created_at"` // Unix timestamp in seconds.
UpdatedAt int64 `json:"updated_at"` // Unix timestamp in seconds.
Tracks []library.TrackInfo `json:"tracks,omitempty"`
}

type playlistRequest struct {
Name string `json:"name"`
Desc string `json:"description"`
AddTrackByID []int64 `json:"add_tracks_by_id"`
RemoveIndecies []int64 `json:"remove_indecies"`
}
7 changes: 6 additions & 1 deletion src/webserver/webserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func (srv *Server) serveGoroutine() {
if err != nil {
panic(err)
}
playlistsManager := playlists.NewManager(srv.library.ExecuteDBJobAndWait)

staticFilesHandler := http.FileServer(http.FS(
wrapfs.WithModTime(srv.httpRootFS, time.Now()),
Expand All @@ -109,13 +110,14 @@ func (srv *Server) serveGoroutine() {
indexHandler := NewTemplateHandler(allTpls.index, "")
addDeviceHandler := NewTemplateHandler(allTpls.addDevice, "Add Device")
registerTokenHandler := NewRigisterTokenHandler()
playlistsHandler := NewPlaylistsHandler(playlistsManager)

subsonicHandler := subsonic.NewHandler(
subsonic.Prefix,
srv.library,
srv.library,
radio.NewManager(srv.library.ExecuteDBJobAndWait),
playlists.NewManager(srv.library.ExecuteDBJobAndWait),
playlistsManager,
srv.cfg,
artoworkHandler,
artistImageHandler,
Expand Down Expand Up @@ -156,6 +158,9 @@ func (srv *Server) serveGoroutine() {
router.Handle(APIv1EndpointRegisterToken, registerTokenHandler).Methods(
APIv1Methods[APIv1EndpointRegisterToken]...,
)
router.Handle(APIv1EndpointPlaylists, playlistsHandler).Methods(
APIv1Methods[APIv1EndpointPlaylists]...,
)

// Kept for backward compatibility with older clients created before the
// API v1 compatibility promise. Although no promise has been made for
Expand Down

0 comments on commit d8cc509

Please sign in to comment.