feat(api): /api/playlists* handlers for M7 #352 slice 1
9 handlers covering create / get / list / update / delete / append /
remove / reorder / cover. Permissions enforced in the service layer
(ErrForbidden -> 403 not_authorized) so the handler is a thin
HTTP-shape adapter. /cover serves the cached collage from disk via
http.ServeFile; 404 when cover_path is nil.
Server gained a DataDir field so the playlists service can find the
collage cache; api.Mount picks up two new params (playlistsSvc and
dataDir). The stale TestRoutesRegisteredInMount Mount() call in
library_test.go was missing the tracks.Service argument added by an
earlier slice — fixed in passing.
Wire codes follow the project's existing nested errorBody envelope:
not_found, not_authorized, unauthenticated, bad_request, server_error.
The plan called for a flat envelope, but the api package's writeErr
already produces {"error":{"code":"...","message":"..."}} and
deviating here would break every existing client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -17,13 +17,14 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, dataDir string) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
@@ -32,6 +33,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
lidarrQuarantine: lidarrQuar,
|
lidarrQuarantine: lidarrQuar,
|
||||||
tracks: tracksSvc,
|
tracks: tracksSvc,
|
||||||
|
playlists: playlistsSvc,
|
||||||
|
dataDir: dataDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
@@ -98,6 +101,16 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
|
|
||||||
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
|
authed.Post("/playlists", h.handleCreatePlaylist)
|
||||||
|
authed.Get("/playlists/{id}", h.handleGetPlaylist)
|
||||||
|
authed.Patch("/playlists/{id}", h.handleUpdatePlaylist)
|
||||||
|
authed.Delete("/playlists/{id}", h.handleDeletePlaylist)
|
||||||
|
authed.Post("/playlists/{id}/tracks", h.handleAppendTracks)
|
||||||
|
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||||||
|
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||||||
|
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -112,4 +125,6 @@ type handlers struct {
|
|||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
lidarrQuarantine *lidarrquarantine.Service
|
lidarrQuarantine *lidarrquarantine.Service
|
||||||
tracks *tracks.Service
|
tracks *tracks.Service
|
||||||
|
playlists *playlists.Service
|
||||||
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +66,9 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
// admin-tracks tests below override h.tracks via installTracksLidarrStub
|
||||||
// when they need a stubbed Lidarr.
|
// when they need a stubbed Lidarr.
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil)
|
tracksSvc := tracks.NewService(pool, logger, nil)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc}
|
dataDir := t.TempDir()
|
||||||
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.dataDir)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
)
|
||||||
|
|
||||||
|
// playlistRowView is the wire shape for a playlist header. Mirrors
|
||||||
|
// playlists.PlaylistRow but renders UUIDs / timestamps as strings and
|
||||||
|
// derives the cover_url from cover_path so clients don't have to know
|
||||||
|
// the on-disk layout.
|
||||||
|
type playlistRowView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
OwnerUsername string `json:"owner_username"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
TrackCount int32 `json:"track_count"`
|
||||||
|
DurationSec int32 `json:"duration_sec"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistTrackView is one row of a playlist's track list on the wire.
|
||||||
|
// TrackID / AlbumID / ArtistID / StreamURL are pointers so they encode as
|
||||||
|
// JSON null when the upstream library row has been removed (ON DELETE
|
||||||
|
// SET NULL semantics from the snapshot table).
|
||||||
|
type playlistTrackView struct {
|
||||||
|
Position int32 `json:"position"`
|
||||||
|
TrackID *string `json:"track_id"`
|
||||||
|
AlbumID *string `json:"album_id"`
|
||||||
|
ArtistID *string `json:"artist_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
DurationSec int32 `json:"duration_sec"`
|
||||||
|
StreamURL *string `json:"stream_url"`
|
||||||
|
AddedAt string `json:"added_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistDetailView extends playlistRowView with the ordered track list.
|
||||||
|
// Returned by Get / Append / Remove / Reorder so the SPA always has a
|
||||||
|
// fresh authoritative state after a mutation.
|
||||||
|
type playlistDetailView struct {
|
||||||
|
playlistRowView
|
||||||
|
Tracks []playlistTrackView `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPlaylistsResponse splits owned vs. public so the SPA can render
|
||||||
|
// "your playlists" and "browse public" without re-filtering. Both
|
||||||
|
// arrays are guaranteed non-nil for stable JSON output.
|
||||||
|
type listPlaylistsResponse struct {
|
||||||
|
Owned []playlistRowView `json:"owned"`
|
||||||
|
Public []playlistRowView `json:"public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListPlaylists implements GET /api/playlists. The service returns
|
||||||
|
// every playlist visible to the caller (own + others' public); we split
|
||||||
|
// the result here so the wire shape carries the distinction the UI cares
|
||||||
|
// about without a second DB query.
|
||||||
|
func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := h.playlists.List(r.Context(), caller.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list playlists failed", "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}}
|
||||||
|
for i := range rows {
|
||||||
|
v := playlistRowToView(&rows[i])
|
||||||
|
if uuidEqual(rows[i].UserID, caller.ID) {
|
||||||
|
resp.Owned = append(resp.Owned, v)
|
||||||
|
} else {
|
||||||
|
resp.Public = append(resp.Public, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPlaylistBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreatePlaylist implements POST /api/playlists. Empty Name is
|
||||||
|
// rejected by the service (ErrInvalidInput → 400 bad_request).
|
||||||
|
func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body createPlaylistBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "create")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetPlaylist implements GET /api/playlists/{id}. Visibility is
|
||||||
|
// owner-or-public; the service returns ErrForbidden when a non-owner
|
||||||
|
// asks for a private playlist.
|
||||||
|
func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "get")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePlaylistBody uses pointers so omitted fields stay untouched
|
||||||
|
// (PATCH semantics). The service rejects an explicitly-empty Name.
|
||||||
|
type updatePlaylistBody struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
IsPublic *bool `json:"is_public,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdatePlaylist implements PATCH /api/playlists/{id}. Owner only;
|
||||||
|
// non-owners get 403 not_authorized.
|
||||||
|
func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body updatePlaylistBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{
|
||||||
|
Name: body.Name,
|
||||||
|
Description: body.Description,
|
||||||
|
IsPublic: body.IsPublic,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeletePlaylist implements DELETE /api/playlists/{id}. Owner only;
|
||||||
|
// returns 204 on success.
|
||||||
|
func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type appendTracksBody struct {
|
||||||
|
TrackIDs []string `json:"track_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAppendTracks implements POST /api/playlists/{id}/tracks. Owner
|
||||||
|
// only. Returns the playlist detail (with the new track rows) so the
|
||||||
|
// SPA never has to re-GET after a mutation.
|
||||||
|
func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body appendTracksBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs))
|
||||||
|
for _, s := range body.TrackIDs {
|
||||||
|
u, ok := parseUUID(s)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("invalid track id %q", s))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackIDs = append(trackIDs, u)
|
||||||
|
}
|
||||||
|
if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "append tracks")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "get-after-append")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemovePlaylistTrack implements DELETE /api/playlists/{id}/tracks/{position}.
|
||||||
|
// Owner only. Returns the post-mutation detail like AppendTracks.
|
||||||
|
func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
posStr := chi.URLParam(r, "position")
|
||||||
|
pos64, err := strconv.ParseInt(posStr, 10, 32)
|
||||||
|
if err != nil || pos64 < 0 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid position")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "remove track")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "get-after-remove")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
type reorderTracksBody struct {
|
||||||
|
OrderedPositions []int32 `json:"ordered_positions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReorderPlaylist implements PUT /api/playlists/{id}/tracks. Owner
|
||||||
|
// only. The body carries a permutation of every existing position; the
|
||||||
|
// service validates length and uniqueness.
|
||||||
|
func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body reorderTracksBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "reorder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "get-after-reorder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetPlaylistCover serves the cached collage from disk. The
|
||||||
|
// playlist visibility check (owner-or-public) runs in the service Get
|
||||||
|
// call before we look at cover_path, so we don't leak the existence of
|
||||||
|
// private playlists to non-owners.
|
||||||
|
func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := auth.UserFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playlistID, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||||||
|
if err != nil {
|
||||||
|
h.writePlaylistErr(w, err, "get cover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if detail.CoverPath == nil || *detail.CoverPath == "" {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "no cover")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
||||||
|
http.ServeFile(w, r, full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePlaylistErr maps the typed errors out of the playlists service to
|
||||||
|
// the project's standard error envelope. Unknown errors are logged at
|
||||||
|
// the handler boundary and surfaced as 500 server_error so callers don't
|
||||||
|
// see service-internal wrap text.
|
||||||
|
func (h *handlers) writePlaylistErr(w http.ResponseWriter, err error, op string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, playlists.ErrNotFound):
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "playlist not found")
|
||||||
|
case errors.Is(err, playlists.ErrForbidden):
|
||||||
|
writeErr(w, http.StatusForbidden, "not_authorized", "you don't own this playlist")
|
||||||
|
case errors.Is(err, playlists.ErrInvalidInput):
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
case errors.Is(err, playlists.ErrTrackNotFound):
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_request", "one of the supplied track ids does not exist")
|
||||||
|
default:
|
||||||
|
h.logger.Error("api: playlist op failed", "op", op, "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", op+" failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistRowToView projects a service-layer PlaylistRow onto the wire
|
||||||
|
// shape. CoverURL is derived from the presence of cover_path — the
|
||||||
|
// /cover endpoint serves the file from disk.
|
||||||
|
func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
|
||||||
|
v := playlistRowView{
|
||||||
|
ID: uuidToString(r.ID),
|
||||||
|
UserID: uuidToString(r.UserID),
|
||||||
|
OwnerUsername: r.OwnerUsername,
|
||||||
|
Name: r.Name,
|
||||||
|
Description: r.Description,
|
||||||
|
IsPublic: r.IsPublic,
|
||||||
|
TrackCount: r.TrackCount,
|
||||||
|
DurationSec: r.DurationSec,
|
||||||
|
CreatedAt: formatTimestamp(r.CreatedAt),
|
||||||
|
UpdatedAt: formatTimestamp(r.UpdatedAt),
|
||||||
|
}
|
||||||
|
if r.CoverPath != nil && *r.CoverPath != "" {
|
||||||
|
v.CoverURL = "/api/playlists/" + v.ID + "/cover"
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// playlistDetailToView extends playlistRowToView with the ordered track
|
||||||
|
// list. Empty playlists get a non-nil empty slice so the JSON encoder
|
||||||
|
// renders `"tracks": []` rather than `null`.
|
||||||
|
func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
|
||||||
|
out := playlistDetailView{
|
||||||
|
playlistRowView: playlistRowToView(&d.PlaylistRow),
|
||||||
|
Tracks: make([]playlistTrackView, 0, len(d.Tracks)),
|
||||||
|
}
|
||||||
|
for _, t := range d.Tracks {
|
||||||
|
v := playlistTrackView{
|
||||||
|
Position: t.Position,
|
||||||
|
Title: t.Title,
|
||||||
|
ArtistName: t.ArtistName,
|
||||||
|
AlbumTitle: t.AlbumTitle,
|
||||||
|
DurationSec: t.DurationSec,
|
||||||
|
AddedAt: formatTimestamp(t.AddedAt),
|
||||||
|
}
|
||||||
|
if t.TrackID != nil {
|
||||||
|
s := uuidToString(*t.TrackID)
|
||||||
|
v.TrackID = &s
|
||||||
|
streamURL := "/api/tracks/" + s + "/stream"
|
||||||
|
v.StreamURL = &streamURL
|
||||||
|
}
|
||||||
|
if t.AlbumID != nil {
|
||||||
|
s := uuidToString(*t.AlbumID)
|
||||||
|
v.AlbumID = &s
|
||||||
|
}
|
||||||
|
if t.ArtistID != nil {
|
||||||
|
s := uuidToString(*t.ArtistID)
|
||||||
|
v.ArtistID = &s
|
||||||
|
}
|
||||||
|
out.Tracks = append(out.Tracks, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidEqual compares two pgtype.UUID values for identity. Two
|
||||||
|
// invalid/zero UUIDs never compare equal — the caller's user id is
|
||||||
|
// always a real value, and we don't want a malformed playlist row to
|
||||||
|
// accidentally land in the "owned" bucket.
|
||||||
|
func uuidEqual(a, b pgtype.UUID) bool {
|
||||||
|
if !a.Valid || !b.Valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.Bytes == b.Bytes
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newPlaylistsRouter mounts the 9 playlist routes for an in-test chi
|
||||||
|
// instance. RequireUser is NOT applied — tests inject the user into
|
||||||
|
// context manually so we exercise the handler-owned auth defense
|
||||||
|
// directly. Mirrors the pattern from newAdminTracksRouter.
|
||||||
|
func newPlaylistsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/playlists", h.handleListPlaylists)
|
||||||
|
r.Post("/api/playlists", h.handleCreatePlaylist)
|
||||||
|
r.Get("/api/playlists/{id}", h.handleGetPlaylist)
|
||||||
|
r.Patch("/api/playlists/{id}", h.handleUpdatePlaylist)
|
||||||
|
r.Delete("/api/playlists/{id}", h.handleDeletePlaylist)
|
||||||
|
r.Post("/api/playlists/{id}/tracks", h.handleAppendTracks)
|
||||||
|
r.Delete("/api/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||||||
|
r.Put("/api/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||||||
|
r.Get("/api/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// doPlaylistsReq fires an HTTP request with the given user injected into
|
||||||
|
// the request context. Body may be nil.
|
||||||
|
func doPlaylistsReq(h *handlers, user dbq.User, method, path string, body []byte) *httptest.ResponseRecorder {
|
||||||
|
var rdr *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
rdr = bytes.NewReader(body)
|
||||||
|
} else {
|
||||||
|
rdr = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, rdr)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
newPlaylistsRouter(h).ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSimpleTrack inserts an artist + album + track triple under unique
|
||||||
|
// titles. Used by the append/reorder test that doesn't care about
|
||||||
|
// genre / file content. seedTrack from library_fixtures_test.go takes
|
||||||
|
// IDs the caller already has; we collapse the three calls here.
|
||||||
|
func seedSimpleTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
a := seedArtist(t, pool, artist)
|
||||||
|
al := seedAlbum(t, pool, a.ID, artist+" - "+title+" Album", 0)
|
||||||
|
return seedTrack(t, pool, al.ID, a.ID, title, 1, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_NoSession401 verifies the handler-owned defensive 401
|
||||||
|
// path on each route. Real traffic hits RequireUser upstream, but the
|
||||||
|
// handler also defends against routing misconfigurations.
|
||||||
|
func TestPlaylists_NoSession401(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newPlaylistsRouter(h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/playlists", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
// Note: NO user in context.
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var env errorBody
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if env.Error.Code != "unauthenticated" {
|
||||||
|
t.Errorf("error.code = %q, want unauthenticated", env.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_CreateThenGet exercises the happy-path end-to-end:
|
||||||
|
// POST creates a row, the response carries the id, GET round-trips
|
||||||
|
// the same fields.
|
||||||
|
func TestPlaylists_CreateThenGet(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
|
||||||
|
body := []byte(`{"name":"Saturday morning","description":"Mellow","is_public":false}`)
|
||||||
|
w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create status = %d, body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("decode create: %v; body = %s", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if created.ID == "" {
|
||||||
|
t.Fatal("created.id empty")
|
||||||
|
}
|
||||||
|
if created.Name != "Saturday morning" {
|
||||||
|
t.Errorf("name = %q, want Saturday morning", created.Name)
|
||||||
|
}
|
||||||
|
if created.IsPublic {
|
||||||
|
t.Error("is_public = true, want false")
|
||||||
|
}
|
||||||
|
if created.OwnerUsername != user.Username {
|
||||||
|
t.Errorf("owner_username = %q, want %q", created.OwnerUsername, user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+created.ID, nil)
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get status = %d, body = %s", w2.Code, w2.Body.String())
|
||||||
|
}
|
||||||
|
var got playlistDetailView
|
||||||
|
if err := json.Unmarshal(w2.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("decode get: %v; body = %s", err, w2.Body.String())
|
||||||
|
}
|
||||||
|
if got.Name != "Saturday morning" {
|
||||||
|
t.Errorf("get name = %q", got.Name)
|
||||||
|
}
|
||||||
|
if got.Tracks == nil {
|
||||||
|
t.Error("tracks is nil; want empty slice")
|
||||||
|
}
|
||||||
|
if len(got.Tracks) != 0 {
|
||||||
|
t.Errorf("tracks len = %d, want 0", len(got.Tracks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_CreateEmptyName400 verifies the service's
|
||||||
|
// ErrInvalidInput surfaces as bad_request on the wire.
|
||||||
|
func TestPlaylists_CreateEmptyName400(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
|
||||||
|
body := []byte(`{"name":"","description":""}`)
|
||||||
|
w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var env errorBody
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if env.Error.Code != "bad_request" {
|
||||||
|
t.Errorf("error.code = %q, want bad_request", env.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_PatchOwnerOnly verifies the ErrForbidden → 403
|
||||||
|
// not_authorized path. Bob creates nothing; Alice creates a playlist;
|
||||||
|
// Bob tries to PATCH it.
|
||||||
|
func TestPlaylists_PatchOwnerOnly(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
|
|
||||||
|
createBody := []byte(`{"name":"Mine","is_public":true}`)
|
||||||
|
cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", createBody)
|
||||||
|
if cw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patchBody := []byte(`{"name":"Hijacked"}`)
|
||||||
|
pw := doPlaylistsReq(h, bob, http.MethodPatch, "/api/playlists/"+created.ID, patchBody)
|
||||||
|
if pw.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("non-owner patch status = %d, want 403; body = %s", pw.Code, pw.Body.String())
|
||||||
|
}
|
||||||
|
var env errorBody
|
||||||
|
if err := json.Unmarshal(pw.Body.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if env.Error.Code != "not_authorized" {
|
||||||
|
t.Errorf("error.code = %q, want not_authorized", env.Error.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_GetNotFound verifies a well-formed UUID with no row
|
||||||
|
// surfaces as 404 not_found.
|
||||||
|
func TestPlaylists_GetNotFound(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
|
||||||
|
missing := "00000000-0000-0000-0000-000000000099"
|
||||||
|
w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+missing, nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_ListSplitsOwnedAndPublic verifies the response carries
|
||||||
|
// owned vs. public buckets correctly. Alice owns one private + one
|
||||||
|
// public; Bob sees only Alice's public.
|
||||||
|
func TestPlaylists_ListSplitsOwnedAndPublic(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
|
|
||||||
|
for _, body := range [][]byte{
|
||||||
|
[]byte(`{"name":"AlicePrivate","is_public":false}`),
|
||||||
|
[]byte(`{"name":"AlicePublic","is_public":true}`),
|
||||||
|
} {
|
||||||
|
w := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", body)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob lists: should see exactly one public playlist owned by Alice.
|
||||||
|
bw := doPlaylistsReq(h, bob, http.MethodGet, "/api/playlists", nil)
|
||||||
|
if bw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: %d %s", bw.Code, bw.Body.String())
|
||||||
|
}
|
||||||
|
var bobResp listPlaylistsResponse
|
||||||
|
if err := json.Unmarshal(bw.Body.Bytes(), &bobResp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(bobResp.Owned) != 0 {
|
||||||
|
t.Errorf("bob.owned len = %d, want 0", len(bobResp.Owned))
|
||||||
|
}
|
||||||
|
if len(bobResp.Public) != 1 || bobResp.Public[0].Name != "AlicePublic" {
|
||||||
|
t.Errorf("bob.public = %+v, want one [AlicePublic]", bobResp.Public)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice lists: sees both as owned, public bucket empty.
|
||||||
|
aw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists", nil)
|
||||||
|
if aw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list alice: %d %s", aw.Code, aw.Body.String())
|
||||||
|
}
|
||||||
|
var aliceResp listPlaylistsResponse
|
||||||
|
if err := json.Unmarshal(aw.Body.Bytes(), &aliceResp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(aliceResp.Owned) != 2 {
|
||||||
|
t.Errorf("alice.owned len = %d, want 2", len(aliceResp.Owned))
|
||||||
|
}
|
||||||
|
if len(aliceResp.Public) != 0 {
|
||||||
|
t.Errorf("alice.public len = %d, want 0", len(aliceResp.Public))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_AppendThenReorder appends three tracks and then reverses
|
||||||
|
// their order via the reorder endpoint. The post-mutation detail body
|
||||||
|
// must show Track 3 before Track 1.
|
||||||
|
func TestPlaylists_AppendThenReorder(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
t1 := seedSimpleTrack(t, pool, "Track 1", "Artist")
|
||||||
|
t2 := seedSimpleTrack(t, pool, "Track 2", "Artist")
|
||||||
|
t3 := seedSimpleTrack(t, pool, "Track 3", "Artist")
|
||||||
|
|
||||||
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||||
|
[]byte(`{"name":"R"}`))
|
||||||
|
if cw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
|
||||||
|
uuidToString(t1.ID), uuidToString(t2.ID), uuidToString(t3.ID),
|
||||||
|
}})
|
||||||
|
aw := doPlaylistsReq(h, user, http.MethodPost,
|
||||||
|
"/api/playlists/"+created.ID+"/tracks", appendPayload)
|
||||||
|
if aw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
|
||||||
|
}
|
||||||
|
var afterAppend playlistDetailView
|
||||||
|
if err := json.Unmarshal(aw.Body.Bytes(), &afterAppend); err != nil {
|
||||||
|
t.Fatalf("decode append: %v", err)
|
||||||
|
}
|
||||||
|
if len(afterAppend.Tracks) != 3 {
|
||||||
|
t.Fatalf("after append, tracks len = %d, want 3", len(afterAppend.Tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse: positions [2, 1, 0].
|
||||||
|
rw := doPlaylistsReq(h, user, http.MethodPut,
|
||||||
|
"/api/playlists/"+created.ID+"/tracks",
|
||||||
|
[]byte(`{"ordered_positions":[2,1,0]}`))
|
||||||
|
if rw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reorder: %d %s", rw.Code, rw.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
gw := doPlaylistsReq(h, user, http.MethodGet,
|
||||||
|
"/api/playlists/"+created.ID, nil)
|
||||||
|
if gw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get-after-reorder: %d %s", gw.Code, gw.Body.String())
|
||||||
|
}
|
||||||
|
body := gw.Body.String()
|
||||||
|
pos3 := strings.Index(body, `"title":"Track 3"`)
|
||||||
|
pos1 := strings.Index(body, `"title":"Track 1"`)
|
||||||
|
if pos3 == -1 || pos1 == -1 {
|
||||||
|
t.Fatalf("titles missing from body: %s", body)
|
||||||
|
}
|
||||||
|
if pos3 > pos1 {
|
||||||
|
t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_RemoveTrack appends two tracks, removes position 0, and
|
||||||
|
// verifies the surviving track sits at position 0 with the renumbered
|
||||||
|
// list length 1.
|
||||||
|
func TestPlaylists_RemoveTrack(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
t1 := seedSimpleTrack(t, pool, "First", "Artist")
|
||||||
|
t2 := seedSimpleTrack(t, pool, "Second", "Artist")
|
||||||
|
|
||||||
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||||
|
[]byte(`{"name":"X"}`))
|
||||||
|
if cw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
|
||||||
|
uuidToString(t1.ID), uuidToString(t2.ID),
|
||||||
|
}})
|
||||||
|
aw := doPlaylistsReq(h, user, http.MethodPost,
|
||||||
|
"/api/playlists/"+created.ID+"/tracks", appendPayload)
|
||||||
|
if aw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rw := doPlaylistsReq(h, user, http.MethodDelete,
|
||||||
|
"/api/playlists/"+created.ID+"/tracks/0", nil)
|
||||||
|
if rw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("remove: %d %s", rw.Code, rw.Body.String())
|
||||||
|
}
|
||||||
|
var afterRemove playlistDetailView
|
||||||
|
if err := json.Unmarshal(rw.Body.Bytes(), &afterRemove); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(afterRemove.Tracks) != 1 {
|
||||||
|
t.Fatalf("after remove, tracks len = %d, want 1", len(afterRemove.Tracks))
|
||||||
|
}
|
||||||
|
if afterRemove.Tracks[0].Title != "Second" {
|
||||||
|
t.Errorf("survivor title = %q, want Second", afterRemove.Tracks[0].Title)
|
||||||
|
}
|
||||||
|
if afterRemove.Tracks[0].Position != 0 {
|
||||||
|
t.Errorf("survivor position = %d, want 0 (renumbered)", afterRemove.Tracks[0].Position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_DeleteOwnerOnly verifies non-owner DELETE is 403 and
|
||||||
|
// owner DELETE is 204.
|
||||||
|
func TestPlaylists_DeleteOwnerOnly(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
alice := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
bob := seedUser(t, pool, "bob", "pw", false)
|
||||||
|
|
||||||
|
cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists",
|
||||||
|
[]byte(`{"name":"D","is_public":true}`))
|
||||||
|
if cw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
bw := doPlaylistsReq(h, bob, http.MethodDelete, "/api/playlists/"+created.ID, nil)
|
||||||
|
if bw.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("bob delete status = %d, want 403", bw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
aw := doPlaylistsReq(h, alice, http.MethodDelete, "/api/playlists/"+created.ID, nil)
|
||||||
|
if aw.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("alice delete status = %d, want 204; body = %s", aw.Code, aw.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent GET must 404.
|
||||||
|
gw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists/"+created.ID, nil)
|
||||||
|
if gw.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("get-after-delete status = %d, want 404", gw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_BadUUID400 verifies a malformed playlist id returns
|
||||||
|
// bad_request rather than reaching the service layer.
|
||||||
|
func TestPlaylists_BadUUID400(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
|
||||||
|
w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/not-a-uuid", nil)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_AppendInvalidTrackUUID400 verifies a malformed track id
|
||||||
|
// in the append body returns bad_request before reaching the DB.
|
||||||
|
func TestPlaylists_AppendInvalidTrackUUID400(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
|
||||||
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||||
|
[]byte(`{"name":"A"}`))
|
||||||
|
if cw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
body := []byte(`{"track_ids":["not-a-uuid"]}`)
|
||||||
|
w := doPlaylistsReq(h, user, http.MethodPost,
|
||||||
|
"/api/playlists/"+created.ID+"/tracks", body)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlaylists_CoverNoneReturns404 verifies that a freshly-created
|
||||||
|
// (empty) playlist has no cached cover and the /cover endpoint returns
|
||||||
|
// 404 not_found rather than serving a missing file.
|
||||||
|
func TestPlaylists_CoverNoneReturns404(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
user := seedUser(t, pool, "alice", "pw", false)
|
||||||
|
|
||||||
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
||||||
|
[]byte(`{"name":"NoCover"}`))
|
||||||
|
if cw.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
||||||
|
}
|
||||||
|
var created playlistRowView
|
||||||
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
w := doPlaylistsReq(h, user, http.MethodGet,
|
||||||
|
"/api/playlists/"+created.ID+"/cover", nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||||
@@ -62,6 +63,11 @@ type Server struct {
|
|||||||
SubsonicCfg subsonic.Config
|
SubsonicCfg subsonic.Config
|
||||||
EventsCfg config.EventsConfig
|
EventsCfg config.EventsConfig
|
||||||
RecommendationCfg config.RecommendationConfig
|
RecommendationCfg config.RecommendationConfig
|
||||||
|
// DataDir is the on-disk root for cached artifacts (currently
|
||||||
|
// playlist cover collages under <DataDir>/playlist_covers/). Empty
|
||||||
|
// strings are tolerated by tests that don't exercise persisted-cover
|
||||||
|
// codepaths; production callers should pass a writable directory.
|
||||||
|
DataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
|
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
|
||||||
@@ -98,7 +104,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
|
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
||||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn})
|
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn})
|
||||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc)
|
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||||||
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.DataDir)
|
||||||
// /api/admin/scan is the only admin route owned by the server package
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
Reference in New Issue
Block a user