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:
2026-05-03 11:10:21 -04:00
parent 79bab14b30
commit c331168d3b
6 changed files with 915 additions and 4 deletions
+16 -1
View File
@@ -17,13 +17,14 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// 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()))
h := &handlers{
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,
lidarrQuarantine: lidarrQuar,
tracks: tracksSvc,
playlists: playlistsSvc,
dataDir: dataDir,
}
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)
})
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
lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service
playlists *playlists.Service
dataDir string
}
+4 -1
View File
@@ -26,6 +26,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"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
// when they need a stubbed Lidarr.
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
}
+1 -1
View File
@@ -443,7 +443,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
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{
"/api/artists",
+429
View File
@@ -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
}
+457
View File
@@ -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())
}
}
+8 -1
View File
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"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/tracks"
"git.fabledsword.com/bvandeusen/minstrel/web"
@@ -62,6 +63,11 @@ type Server struct {
SubsonicCfg subsonic.Config
EventsCfg config.EventsConfig
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 {
@@ -98,7 +104,8 @@ func (s *Server) Router() http.Handler {
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, 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
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second