From d4f35a4899ad1615dbd79cef061944312221ce83 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 4 Apr 2026 21:00:10 +0500 Subject: [PATCH] fix: prevent duplicate chapters_idx records + add dedup endpoint - Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata) so concurrent goroutines don't double-POST the same chapter number - Add DeduplicateChapters to BookWriter interface and Store implementation; keeps the latest record per (slug, number) and deletes extras - Wire POST /api/admin/dedup-chapters/{slug} handler in server.go --- backend/internal/backend/handlers.go | 24 ++++++++++ backend/internal/backend/server.go | 3 ++ backend/internal/bookstore/bookstore.go | 5 ++ backend/internal/storage/store.go | 64 ++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/backend/internal/backend/handlers.go b/backend/internal/backend/handlers.go index a5bd37d..789a788 100644 --- a/backend/internal/backend/handlers.go +++ b/backend/internal/backend/handlers.go @@ -569,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count}) } +// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}. +// Removes duplicate chapters_idx records for a book, keeping the latest record +// per chapter number. Returns the number of duplicate records deleted. +func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) { + slug := r.PathValue("slug") + if slug == "" { + jsonError(w, http.StatusBadRequest, "missing slug") + return + } + + deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug) + if err != nil { + s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + "deleted": deleted, + }) + return + } + + s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted) + writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted}) +} + // ── Audio ────────────────────────────────────────────────────────────────────── // handleAudioGenerate handles POST /api/audio/{slug}/{n}. diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index acba23a..8484f5e 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -204,6 +204,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription) mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription) + // Admin data repair endpoints + mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters) + // Voices list mux.HandleFunc("GET /api/voices", s.handleVoices) diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index 2b212be..bdcc75e 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -35,6 +35,11 @@ type BookWriter interface { // ChapterExists returns true if the markdown object for ref already exists. ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool + + // DeduplicateChapters removes duplicate chapters_idx records for slug, + // keeping only one record per chapter number (the one with the latest + // updated timestamp). Returns the number of duplicate records deleted. + DeduplicateChapters(ctx context.Context, slug string) (int, error) } // BookReader is the read side used by the backend to serve content. diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index 7616e3c..4a1fc9b 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -130,7 +130,16 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch return err } if len(items) == 0 { - return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil) + postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil) + if postErr == nil { + return nil + } + // POST failed — a concurrent writer may have inserted the same slug+number. + // Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern). + items, err = s.pb.listAll(ctx, "chapters_idx", filter, "") + if err != nil || len(items) == 0 { + return postErr // original POST error is more informative + } } var rec struct { ID string `json:"id"` @@ -139,6 +148,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload) } +// DeduplicateChapters removes duplicate chapters_idx records for slug. +// For each chapter number that has more than one record, it keeps the record +// with the latest "updated" timestamp and deletes the rest. +// Returns the number of records deleted. +func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) { + filter := fmt.Sprintf(`slug=%q`, slug) + items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number") + if err != nil { + return 0, fmt.Errorf("DeduplicateChapters: list: %w", err) + } + + type record struct { + ID string `json:"id"` + Number int `json:"number"` + Updated string `json:"updated"` + } + + // Group records by chapter number. + byNumber := make(map[int][]record) + for _, raw := range items { + var rec record + if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" { + continue + } + byNumber[rec.Number] = append(byNumber[rec.Number], rec) + } + + deleted := 0 + for _, recs := range byNumber { + if len(recs) <= 1 { + continue + } + // Keep the record with the latest Updated timestamp; delete the rest. + keep := 0 + for i := 1; i < len(recs); i++ { + if recs[i].Updated > recs[keep].Updated { + keep = i + } + } + for i, rec := range recs { + if i == keep { + continue + } + if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil { + s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr) + continue + } + deleted++ + } + } + return deleted, nil +} + // ── BookReader ──────────────────────────────────────────────────────────────── type pbBook struct {