From f5dd7f79edc2be0e25673a0fa18b02b51320a323 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 19:06:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(audiobooks):=20POST=20/playlists=20?= =?UTF-8?q?=E2=80=94=20ABS=20playlist=20create=20+=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First handler of the playlists surface. Body {name, description?, cover_item?, isPublic?} returns the created playlist in full-shape (empty items[]). Fires playlist_added realtime event. Adds the in-memory test harness (memPlaylistStore) parallel to memCollectionStore. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/handler.go | 3 + internal/audiobooks/abs/playlists_handler.go | 131 +++++++++++ .../audiobooks/abs/playlists_handler_test.go | 207 ++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 internal/audiobooks/abs/playlists_handler.go create mode 100644 internal/audiobooks/abs/playlists_handler_test.go diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index bb10106a..a8a19f60 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -185,6 +185,9 @@ type Dependencies struct { // CollectionStore persists ABS user-collection rows (migrations 149 + 150). // May be nil; handlers respond 503 when unset. CollectionStore CollectionStore + // PlaylistStore persists ABS playlist rows (migrations 151 + 152). + // May be nil; handlers respond 503 when unset. + PlaylistStore PlaylistStore // SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil; // the route is only registered when a non-nil value is supplied. SocketIO SocketIOServer diff --git a/internal/audiobooks/abs/playlists_handler.go b/internal/audiobooks/abs/playlists_handler.go new file mode 100644 index 00000000..ddcdf90b --- /dev/null +++ b/internal/audiobooks/abs/playlists_handler.go @@ -0,0 +1,131 @@ +package abs + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" +) + +// playlistBody is the JSON body for POST and PATCH /playlists[/{id}]. +// Fields are pointers so PATCH can distinguish "field absent" from +// "field set to empty/false". +type playlistBody struct { + Name *string `json:"name"` + Description *string `json:"description"` + CoverItem *string `json:"cover_item"` + IsPublic *bool `json:"isPublic"` +} + +// playlistItemRef is the JSON body for adding/removing a single +// playlist item (and an element of the batch arrays). +type playlistItemRef struct { + LibraryItemID string `json:"libraryItemId"` + EpisodeID string `json:"episodeId"` +} + +// handleCreatePlaylist — POST /playlists. +// Body: {name, description?, cover_item?, isPublic?}. +// Returns the created playlist in full-shape (empty items[]). +// Fires playlist_added on success. +func (h *Handler) handleCreatePlaylist(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 { + http.Error(w, "playlist store unavailable", http.StatusServiceUnavailable) + return + } + + var body playlistBody + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if body.Name == nil || *body.Name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + + p := Playlist{ + ID: ulid.Make().String(), + UserID: a.UserID, + ProfileID: a.ProfileID, + Name: *body.Name, + } + if body.Description != nil { + p.Description = *body.Description + } + if body.CoverItem != nil { + p.CoverItem = *body.CoverItem + } + if body.IsPublic != nil { + p.IsPublic = *body.IsPublic + } + if err := h.deps.PlaylistStore.CreatePlaylist(r.Context(), p); err != nil { + slog.Error("abs playlist create failed", "err", err, "user", a.UserID) + http.Error(w, "playlist persist failed", http.StatusInternalServerError) + return + } + + persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), p.ID) + if errors.Is(err, ErrNotFound) { + persisted = p + } else if err != nil { + persisted = p + } + + h.publish(a.UserID, "playlist_added", map[string]any{"id": p.ID, "name": p.Name}) + writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted)) +} + +// playlistFullShape renders a Playlist in full-shape, hydrating items[] +// via MediaStore for audiobook items (episode items echo bare refs). +func (h *Handler) playlistFullShape(r *http.Request, p Playlist) map[string]any { + items := h.playlistItems(r, p.ID) + return playlistToABS(p, items) +} + +// playlistItems resolves items in a playlist to wire-shape entries. +// Audiobook items (empty episodeId) hydrate title via MediaStore. +// Episode items are emitted as bare {libraryItemId, episodeId, position}. +func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string]any { + if h.deps.PlaylistStore == nil { + return []map[string]any{} + } + rows, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID) + if err != nil { + slog.Warn("abs playlist list-items failed", "err", err, "playlist", playlistID) + return []map[string]any{} + } + lib := h.resolveDefaultLibrary(r.Context()) + libID := audiobookLibraryID(lib) + out := make([]map[string]any, 0, len(rows)) + for _, it := range rows { + entry := map[string]any{ + "libraryItemId": it.LibraryItemID, + "position": it.Position, + } + if it.EpisodeID != "" { + entry["episodeId"] = it.EpisodeID + } else { + // Audiobook hydration. + if item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID); err == nil && item != nil { + entry["libraryId"] = libID + entry["title"] = item.Title + } + } + out = append(out, entry) + } + return out +} + +// playlistURLID is a tiny shim around chi.URLParam(r, "id") to read +// uniformly with the collections handler's chiURLID. +func playlistURLID(r *http.Request) string { return chi.URLParam(r, "id") } diff --git a/internal/audiobooks/abs/playlists_handler_test.go b/internal/audiobooks/abs/playlists_handler_test.go new file mode 100644 index 00000000..3c868775 --- /dev/null +++ b/internal/audiobooks/abs/playlists_handler_test.go @@ -0,0 +1,207 @@ +package abs + +import ( + "context" + "encoding/json" + "net/http" + "sort" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// memPlaylistStore is an in-memory PlaylistStore for handler tests. +type memPlaylistStore struct { + mu sync.Mutex + rows map[string]Playlist // id -> row + items map[string][]PlaylistItem // playlist_id -> items +} + +func newMemPlaylistStore() *memPlaylistStore { + return &memPlaylistStore{ + rows: map[string]Playlist{}, + items: map[string][]PlaylistItem{}, + } +} + +func (m *memPlaylistStore) ListUserPlaylists(_ context.Context, userID, profileID string) ([]Playlist, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Playlist, 0) + for _, p := range m.rows { + if p.UserID == userID && p.ProfileID == profileID { + out = append(out, p) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + +func (m *memPlaylistStore) GetPlaylist(_ context.Context, id string) (Playlist, error) { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.rows[id] + if !ok { + return Playlist{}, ErrNotFound + } + return p, nil +} + +func (m *memPlaylistStore) CreatePlaylist(_ context.Context, p Playlist) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[p.ID] = p + return nil +} + +func (m *memPlaylistStore) UpdatePlaylist(_ context.Context, p Playlist) error { + m.mu.Lock() + defer m.mu.Unlock() + existing, ok := m.rows[p.ID] + if !ok { + return ErrNotFound + } + existing.Name = p.Name + existing.Description = p.Description + existing.CoverItem = p.CoverItem + existing.IsPublic = p.IsPublic + existing.UpdatedAt = time.Now() + m.rows[p.ID] = existing + return nil +} + +func (m *memPlaylistStore) DeletePlaylist(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.rows, id) + delete(m.items, id) + return nil +} + +func (m *memPlaylistStore) ListPlaylistItems(_ context.Context, playlistID string) ([]PlaylistItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + items := m.items[playlistID] + out := make([]PlaylistItem, len(items)) + copy(out, items) + sort.Slice(out, func(i, j int) bool { return out[i].Position < out[j].Position }) + return out, nil +} + +func (m *memPlaylistStore) AddPlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, it := range m.items[playlistID] { + if it.LibraryItemID == libraryItemID && it.EpisodeID == episodeID { + return nil // ON CONFLICT DO NOTHING + } + } + maxPos := 0 + for _, it := range m.items[playlistID] { + if it.Position > maxPos { + maxPos = it.Position + } + } + m.items[playlistID] = append(m.items[playlistID], PlaylistItem{ + PlaylistID: playlistID, + LibraryItemID: libraryItemID, + EpisodeID: episodeID, + Position: maxPos + 1, + AddedAt: time.Now(), + }) + if p, ok := m.rows[playlistID]; ok { + p.UpdatedAt = time.Now() + m.rows[playlistID] = p + } + return nil +} + +func (m *memPlaylistStore) RemovePlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error { + m.mu.Lock() + defer m.mu.Unlock() + items := m.items[playlistID] + out := items[:0] + for _, it := range items { + if it.LibraryItemID != libraryItemID || it.EpisodeID != episodeID { + out = append(out, it) + } + } + m.items[playlistID] = out + if p, ok := m.rows[playlistID]; ok { + p.UpdatedAt = time.Now() + m.rows[playlistID] = p + } + return nil +} + +type playlistsHarness struct { + H *Handler + Play *memPlaylistStore + Pub *recordingPublisher +} + +func newPlaylistsHarness(t *testing.T, knownItems ...string) *playlistsHarness { + t.Helper() + known := map[string]*models.MediaItem{} + for _, id := range knownItems { + known[id] = nil + } + pub := &recordingPublisher{} + store := newMemPlaylistStore() + h := New(Dependencies{ + MediaStore: &stubMediaStore{known: known}, + PlaylistStore: store, + Publisher: pub, + }) + return &playlistsHarness{H: h, Play: store, Pub: pub} +} + +func TestPlaylist_Create_ReturnsFullShape(t *testing.T) { + hb := newPlaylistsHarness(t) + body := []byte(`{"name":"queue","description":"d","isPublic":true}`) + rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, body, "1", "", hb.H.handleCreatePlaylist) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if got["name"] != "queue" { + t.Errorf("name = %v, want queue", got["name"]) + } + if got["isPublic"] != true { + t.Errorf("isPublic = %v, want true", got["isPublic"]) + } + items, _ := got["items"].([]any) + if items == nil { + t.Errorf("items missing on full-shape: %v", got) + } +} + +func TestPlaylist_Create_NameRequired_400(t *testing.T) { + hb := newPlaylistsHarness(t) + rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{}`), "1", "", hb.H.handleCreatePlaylist) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +func TestPlaylist_Create_FiresPlaylistAddedEvent(t *testing.T) { + hb := newPlaylistsHarness(t) + _ = dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{"name":"queue"}`), "7", "", hb.H.handleCreatePlaylist) + evts := hb.Pub.snapshot() + if len(evts) != 1 { + t.Fatalf("events = %d, want 1", len(evts)) + } + if evts[0].Event != "playlist_added" { + t.Errorf("event = %q, want playlist_added", evts[0].Event) + } + if evts[0].UserID != "7" { + t.Errorf("event userID = %q, want 7", evts[0].UserID) + } + payload, _ := evts[0].Payload.(map[string]any) + if payload["name"] != "queue" { + t.Errorf("payload name = %v, want queue", payload["name"]) + } +}