fix(audiobooks): add /libraries/{id}/playlists for mobile create modal

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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-27 00:48:00 +02:00
co-authored by Claude Opus 4.7
parent 1470077877
commit 0a766d9bb1
2 changed files with 45 additions and 0 deletions
+4
View File
@@ -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)
@@ -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[]).