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 }