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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user