From 0a766d9bb106b52aa3ef8411072e4cb5be223159 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Wed, 27 May 2026 00:48:00 +0200 Subject: [PATCH] fix(audiobooks): add /libraries/{id}/playlists for mobile create modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ABS mobile create-playlist modal loads existing playlists BEFORE opening the form, hitting: GET /api/libraries/{libraryId}/playlists and reading `data.results` (NOT `data.playlists`). It then iterates each playlist's `items[]` to render "already in this playlist" badges. silo's existing `/api/playlists` route emitted the wrong envelope (`{playlists: [...]}`) and was at the wrong path; the modal silently treated the response as empty, the user saw an empty existing-playlist list, and depending on the device-side UX (no "Create New" CTA when results was undefined) the create action looked dead. Add handleListLibraryPlaylists at `/libraries/{libraryId}/playlists`, emit `{results: [Playlist full-shape]}` so playlist.items is populated for the membership check. libraryId param accepted but ignored — silo scopes playlists per (user, profile) globally. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/handler.go | 4 ++ internal/audiobooks/abs/playlists_handler.go | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index 97c2ee01..27ef7902 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -369,6 +369,10 @@ func (h *Handler) mountRoutes(r chi.Router) { // Playlists — owner-gated CRUD with cross-user public reads, // realtime events on every mutation, batch endpoints. r.Get(prefix+"/playlists", h.handleListPlaylists) + // Per-library playlist list — mobile create-playlist modal + // loads from here before opening the form. Emits + // `{results: [...]}` with full-shape entries. + r.Get(prefix+"/libraries/{libraryId}/playlists", h.handleListLibraryPlaylists) r.Post(prefix+"/playlists", h.handleCreatePlaylist) r.Get(prefix+"/playlists/{id}", h.handleGetPlaylist) r.Patch(prefix+"/playlists/{id}", h.handleUpdatePlaylist) diff --git a/internal/audiobooks/abs/playlists_handler.go b/internal/audiobooks/abs/playlists_handler.go index 9725cccb..efec288c 100644 --- a/internal/audiobooks/abs/playlists_handler.go +++ b/internal/audiobooks/abs/playlists_handler.go @@ -158,6 +158,47 @@ func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string // uniformly with the collections handler's chiURLID. func playlistURLID(r *http.Request) string { return chi.URLParam(r, "id") } +// handleListLibraryPlaylists — GET /libraries/{libraryId}/playlists. +// +// The ABS mobile create-playlist modal hits this endpoint BEFORE +// opening the form so it can show "already in playlist X" badges and +// the existing-playlists picker. It accesses `data.results` on the +// response (NOT `data.playlists`), and iterates `playlist.items` to +// check membership, so we emit: +// {"results": [Playlist full-shape with items[]]} +// +// The libraryId URL param is accepted but ignored — silo scopes +// playlists per (user, profile) globally rather than per-library; +// the mobile UI only needs to know which of the user's playlists +// already contain the selected item. +// +// Ref: audiobookshelf-app components/modals/playlists/AddCreateModal.vue +// loadPlaylists(). +func (h *Handler) handleListLibraryPlaylists(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.PlaylistStore == nil { + writeJSON(w, http.StatusOK, map[string]any{"results": []any{}}) + return + } + rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID) + if err != nil { + slog.Error("abs library playlist list failed", "err", err, "user", a.UserID) + http.Error(w, "playlist list failed", http.StatusInternalServerError) + return + } + out := make([]map[string]any, 0, len(rows)) + for _, p := range rows { + // Full-shape (with items[]) so the modal can check + // existing-membership via playlist.items.some(...). + out = append(out, h.playlistFullShape(r, p)) + } + writeJSON(w, http.StatusOK, map[string]any{"results": out}) +} + // handleListPlaylists — GET /playlists. // Returns the caller's playlists wrapped in {"playlists": [...]}. // List-shape (no items[]).