b64d8ad334
The native API endpoints GET /playlist/{id}/tracks and
GET /playlist/{id}/tracks/{id} were panicking with a nil pointer
dereference (resulting in a 500) when the playlist did not exist.
This happened because Tracks() returns nil for missing playlists,
and the nil repository was passed directly to the rest handler.
Extracted a shared playlistTracksHandler that checks for nil and
returns 404 early. Added tests covering both the error and happy paths.
231 lines
6.8 KiB
Go
231 lines
6.8 KiB
Go
package nativeapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/deluan/rest"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
)
|
|
|
|
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
|
|
|
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
plsId := chi.URLParam(r, "playlistId")
|
|
tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r))
|
|
if tracks == nil {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
|
handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool {
|
|
return req.Params(r).Int64Or("_start", 0) == 0
|
|
})
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
|
|
handleExportPlaylist(ds)(w, r)
|
|
return
|
|
}
|
|
handler(w, r)
|
|
}
|
|
}
|
|
|
|
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
|
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
|
|
}
|
|
|
|
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
pls, err := playlists.ImportM3U(ctx, r.Body)
|
|
if err != nil {
|
|
log.Error(r.Context(), "Error parsing playlist", err)
|
|
// TODO: consider returning StatusBadRequest for playlists that are malformed
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, err = w.Write([]byte(pls.ToM3U8()))
|
|
if err != nil {
|
|
log.Error(ctx, "Error sending m3u contents", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
plsRepo := ds.Playlist(ctx)
|
|
plsId := chi.URLParam(r, "playlistId")
|
|
pls, err := plsRepo.GetWithTracks(plsId, true, false)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
log.Warn(r.Context(), "Playlist not found", "playlistId", plsId)
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name)
|
|
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
|
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
|
|
w.Header().Set("Content-Disposition", disposition)
|
|
|
|
_, err = w.Write([]byte(pls.ToM3U8()))
|
|
if err != nil {
|
|
log.Error(ctx, "Error sending playlist", "name", pls.Name)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":playlistId")
|
|
ids, _ := p.Strings("id")
|
|
err := ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true)
|
|
return tracksRepo.Delete(ids...)
|
|
})
|
|
if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
|
|
log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0])
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeDeleteManyResponse(w, r, ids)
|
|
}
|
|
}
|
|
|
|
func addToPlaylist(ds model.DataStore) http.HandlerFunc {
|
|
type addTracksPayload struct {
|
|
Ids []string `json:"ids"`
|
|
AlbumIds []string `json:"albumIds"`
|
|
ArtistIds []string `json:"artistIds"`
|
|
Discs []model.DiscID `json:"discs"`
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":playlistId")
|
|
var payload addTracksPayload
|
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
|
count, c := 0, 0
|
|
if c, err = tracksRepo.Add(payload.Ids); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
count += c
|
|
|
|
// Must return an object with an ID, to satisfy ReactAdmin `create` call
|
|
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func reorderItem(ds model.DataStore) http.HandlerFunc {
|
|
type reorderPayload struct {
|
|
InsertBefore string `json:"insert_before"`
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := req.Params(r)
|
|
playlistId, _ := p.String(":playlistId")
|
|
id := p.IntOr(":id", 0)
|
|
if id == 0 {
|
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var payload reorderPayload
|
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
newPos, err := strconv.Atoi(payload.InsertBefore)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true)
|
|
err = tracksRepo.Reorder(id, newPos)
|
|
if errors.Is(err, rest.ErrPermissionDenied) {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p := req.Params(r)
|
|
trackId, _ := p.String(":id")
|
|
playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data, err := json.Marshal(playlists)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = w.Write(data)
|
|
}
|
|
}
|