From 815f207d6ca5f4f16e705c43542f33ee039b9b6e Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 13:41:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(audiobooks):=20POST=20/me/item/{id}/bookma?= =?UTF-8?q?rk=20=E2=80=94=20ABS=20bookmark=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three ABS bookmark endpoints. Body { title, time } upserts on (user, profile, item, time); response is the item's full bookmark list. Backed by a new BookmarkStore dependency (nil-safe: handler returns 503 when unwired). Item validation via MediaStore; realtime user_updated event with reason=bookmark_created on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/bookmarks_handler.go | 110 ++++++++ .../audiobooks/abs/bookmarks_handler_test.go | 238 ++++++++++++++++++ internal/audiobooks/abs/handler.go | 4 + 3 files changed, 352 insertions(+) create mode 100644 internal/audiobooks/abs/bookmarks_handler.go create mode 100644 internal/audiobooks/abs/bookmarks_handler_test.go diff --git a/internal/audiobooks/abs/bookmarks_handler.go b/internal/audiobooks/abs/bookmarks_handler.go new file mode 100644 index 00000000..541edaa1 --- /dev/null +++ b/internal/audiobooks/abs/bookmarks_handler.go @@ -0,0 +1,110 @@ +package abs + +import ( + "encoding/json" + "io" + "log/slog" + "math" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" +) + +// bookmarkBody is the JSON body for POST and PATCH +// /me/item/{itemId}/bookmark. Time is a pointer so we can distinguish +// missing (→ 400) from the literal 0.0. +type bookmarkBody struct { + Title string `json:"title"` + Time *float64 `json:"time"` +} + +// handleUpsertBookmark backs both POST (reason="bookmark_created") and +// PATCH (reason="bookmark_updated") /me/item/{itemId}/bookmark. Both +// share the exact same upsert semantics — only the realtime event +// reason differs. +func (h *Handler) handleUpsertBookmark(reason string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.BookmarkStore == nil { + http.Error(w, "bookmark store unavailable", http.StatusServiceUnavailable) + return + } + + itemID := chi.URLParam(r, "itemId") + if itemID == "" { + http.Error(w, "itemId required", http.StatusBadRequest) + return + } + + // 1 MiB body cap — matches handleStandaloneLogin. + var body bookmarkBody + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + if err := dec.Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if body.Time == nil || math.IsNaN(*body.Time) { + http.Error(w, "time required", http.StatusBadRequest) + return + } + + // Item validation: avoid orphan bookmark rows whose item no + // longer exists. Skipped on DELETE (see handleDeleteBookmark). + item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), itemID) + if err != nil || item == nil { + http.Error(w, "item not found", http.StatusNotFound) + return + } + + bm, err := h.deps.BookmarkStore.Upsert(r.Context(), a.UserID, a.ProfileID, itemID, *body.Time, body.Title) + if err != nil { + slog.Error("abs bookmark upsert failed", "err", err, "user", a.UserID, "item", itemID) + http.Error(w, "bookmark persist failed", http.StatusInternalServerError) + return + } + + h.publish(a.UserID, "user_updated", map[string]any{ + "reason": reason, + "bookmark": bookmarkToABS(bm), + }) + + writeBookmarkList(w, r, h, a.UserID, a.ProfileID, itemID) + } +} + +// writeBookmarkList re-fetches the item's bookmarks and writes them as +// the JSON response. On list-fetch failure after a successful mutation, +// degrade to 200 + empty list + slog.Warn (the mutation already +// committed; failing the response would mis-report the state). +func writeBookmarkList(w http.ResponseWriter, r *http.Request, h *Handler, userID, profileID, itemID string) { + rows, err := h.deps.BookmarkStore.List(r.Context(), userID, profileID, itemID) + if err != nil { + slog.Warn("abs bookmark list after mutation failed", "err", err, "user", userID, "item", itemID) + writeJSON(w, http.StatusOK, []any{}) + return + } + out := make([]map[string]any, 0, len(rows)) + for _, b := range rows { + out = append(out, bookmarkToABS(b)) + } + writeJSON(w, http.StatusOK, out) +} + +// parseBookmarkTime parses the {time} URL parameter on DELETE +// /me/item/{itemId}/bookmark/{time}. Returns (0, false) on parse +// failure. +func parseBookmarkTime(s string) (float64, bool) { + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(s, 64) + if err != nil || math.IsNaN(v) { + return 0, false + } + return v, true +} diff --git a/internal/audiobooks/abs/bookmarks_handler_test.go b/internal/audiobooks/abs/bookmarks_handler_test.go new file mode 100644 index 00000000..8b4021f1 --- /dev/null +++ b/internal/audiobooks/abs/bookmarks_handler_test.go @@ -0,0 +1,238 @@ +package abs + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// --------------------------------------------------------------------------- +// In-memory fakes +// --------------------------------------------------------------------------- + +// memBookmarkStore is an in-memory BookmarkStore for handler tests. +// Keyed on (userID, profileID, itemID, time) to mirror the SQL unique +// index. Thread-safe so parallel sub-tests can share an instance. +type memBookmarkStore struct { + mu sync.Mutex + rows map[string]Bookmark // key = userID|profileID|itemID|time + seq int // monotonic counter for deterministic IDs in tests +} + +func newMemBookmarkStore() *memBookmarkStore { + return &memBookmarkStore{rows: map[string]Bookmark{}} +} + +func bkKey(userID, profileID, itemID string, t float64) string { + return userID + "|" + profileID + "|" + itemID + "|" + formatTime(t) +} + +func formatTime(t float64) string { + // Round-trip-safe encoding for map keys. Postgres compares float8 + // bit-for-bit too, so this matches production semantics. + b, _ := json.Marshal(t) + return string(b) +} + +// List iterates the keyed map directly so a row only matches when ALL +// of (user, profile, item) line up. Iterating values and reconstructing +// the key would be ambiguous when two users have a bookmark at the +// same (item, time). +func (m *memBookmarkStore) List(_ context.Context, userID, profileID, itemID string) ([]Bookmark, error) { + m.mu.Lock() + defer m.mu.Unlock() + prefix := userID + "|" + profileID + "|" + itemID + "|" + out := make([]Bookmark, 0) + for k, b := range m.rows { + if strings.HasPrefix(k, prefix) { + out = append(out, b) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time }) + return out, nil +} + +func (m *memBookmarkStore) Upsert(_ context.Context, userID, profileID, itemID string, t float64, title string) (Bookmark, error) { + m.mu.Lock() + defer m.mu.Unlock() + key := bkKey(userID, profileID, itemID, t) + now := time.Now() + if existing, ok := m.rows[key]; ok { + existing.Title = title + existing.UpdatedAt = now + m.rows[key] = existing + return existing, nil + } + m.seq++ + b := Bookmark{ + ID: "01HTEST" + formatSeq(m.seq), + LibraryItemID: itemID, + Time: t, + Title: title, + CreatedAt: now, + UpdatedAt: now, + } + m.rows[key] = b + return b, nil +} + +func (m *memBookmarkStore) Delete(_ context.Context, userID, profileID, itemID string, t float64) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.rows, bkKey(userID, profileID, itemID, t)) + return nil +} + +func formatSeq(n int) string { + b, _ := json.Marshal(n) + return string(b) +} + +// recordingPublisher captures publish() calls so tests can assert socket +// event semantics without wiring a real Socket.io server. +type recordingPublisher struct { + mu sync.Mutex + events []publishedEvent +} + +type publishedEvent struct { + UserID string + Event string + Payload any +} + +func (p *recordingPublisher) Publish(userID, event string, payload any) { + p.mu.Lock() + defer p.mu.Unlock() + p.events = append(p.events, publishedEvent{UserID: userID, Event: event, Payload: payload}) +} +func (p *recordingPublisher) Broadcast(_ string, _ any) {} + +func (p *recordingPublisher) snapshot() []publishedEvent { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]publishedEvent, len(p.events)) + copy(out, p.events) + return out +} + +// stubMediaStore satisfies MediaStore with a configurable item lookup so +// handler tests can drive both the 200 and 404 branches. +type stubMediaStore struct { + noopMediaStore + known map[string]*models.MediaItem // itemID → row (nil means "exists but no row needed") +} + +func (s *stubMediaStore) GetAudiobookByID(_ context.Context, id string) (*models.MediaItem, error) { + if it, ok := s.known[id]; ok { + if it == nil { + return &models.MediaItem{ContentID: id}, nil + } + return it, nil + } + return nil, nil +} + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +type bookmarksHarness struct { + H *Handler + Pub *recordingPublisher + Book *memBookmarkStore +} + +func newBookmarksHarness(t *testing.T, knownItems ...string) *bookmarksHarness { + t.Helper() + known := map[string]*models.MediaItem{} + for _, id := range knownItems { + known[id] = nil // exists, body content not used by handlers + } + pub := &recordingPublisher{} + store := newMemBookmarkStore() + h := New(Dependencies{ + MediaStore: &stubMediaStore{known: known}, + BookmarkStore: store, + Publisher: pub, + }) + return &bookmarksHarness{H: h, Pub: pub, Book: store} +} + +// dispatchBookmark drives a bookmarks handler directly. Injects ctxAuth +// (the bearerAuth middleware's product) and chi route params so the +// handler can read both via absAuthFrom() and chi.URLParam() without +// running the full middleware chain. +func dispatchBookmark(h *Handler, method, path, itemID, timeParam string, body []byte, userID, profileID string, fn http.HandlerFunc) *httptest.ResponseRecorder { + var rd *bytes.Reader + if body != nil { + rd = bytes.NewReader(body) + } + var req *http.Request + if rd != nil { + req = httptest.NewRequest(method, path, rd) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + rctx := chi.NewRouteContext() + if itemID != "" { + rctx.URLParams.Add("itemId", itemID) + } + if timeParam != "" { + rctx.URLParams.Add("time", timeParam) + } + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, ctxKey{}, ctxAuth{UserID: userID, ProfileID: profileID}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + fn(rec, req) + return rec +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestCreate_NewBookmark_ReturnsListContainingIt(t *testing.T) { + hb := newBookmarksHarness(t, "book-1") + body := []byte(`{"title":"Chapter cliffhanger","time":42.5}`) + rec := dispatchBookmark(hb.H, http.MethodPost, "/api/me/item/book-1/bookmark", "book-1", "", body, "1", "", hb.H.handleUpsertBookmark("bookmark_created")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var list []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if len(list) != 1 { + t.Fatalf("list len = %d, want 1; body=%s", len(list), rec.Body.String()) + } + got := list[0] + if got["libraryItemId"] != "book-1" { + t.Errorf("libraryItemId = %v, want book-1", got["libraryItemId"]) + } + if got["time"] != 42.5 { + t.Errorf("time = %v, want 42.5", got["time"]) + } + if got["title"] != "Chapter cliffhanger" { + t.Errorf("title = %v, want Chapter cliffhanger", got["title"]) + } + for _, k := range []string{"id", "createdAt", "updatedAt"} { + if _, ok := got[k]; !ok { + t.Errorf("response missing %q; body=%s", k, rec.Body.String()) + } + } +} diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index ece7bdfe..6b91e2b6 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -178,6 +178,10 @@ type Dependencies struct { // (migration 143) for /session/{sid}/sync and /session/{sid}/close. // May be nil; handlers degrade gracefully. PlaybackSessionStore ABSPlaybackSessionStore + // BookmarkStore persists ABS bookmark rows (migration 148) for the + // POST/PATCH/DELETE /me/item/{itemId}/bookmark endpoints. May be + // nil; handlers respond 503 when unset. + BookmarkStore BookmarkStore // 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