Compare commits
4 Commits
v2.5.69
...
0e5eb84097
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e5eb84097 | ||
|
|
6ef82a1d12 | ||
|
|
7a418ee62b | ||
|
|
d4f35a4899 |
@@ -569,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── Audio ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
|
||||||
|
|||||||
@@ -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", s.handleAdminTextGenDescription)
|
||||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
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
|
// Voices list
|
||||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ type BookWriter interface {
|
|||||||
|
|
||||||
// ChapterExists returns true if the markdown object for ref already exists.
|
// ChapterExists returns true if the markdown object for ref already exists.
|
||||||
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
|
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.
|
// BookReader is the read side used by the backend to serve content.
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
|
|||||||
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
|
||||||
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||||
|
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||||
|
|
||||||
// RankingStore
|
// RankingStore
|
||||||
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
|
||||||
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AudioStore
|
// AudioStore
|
||||||
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
|
||||||
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
|
||||||
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
|
||||||
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
|
||||||
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||||
|
|
||||||
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
|
||||||
type stubBookReader struct {
|
type stubBookReader struct {
|
||||||
text string
|
text string
|
||||||
|
|||||||
@@ -130,7 +130,16 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(items) == 0 {
|
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 {
|
var rec struct {
|
||||||
ID string `json:"id"`
|
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)
|
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 ────────────────────────────────────────────────────────────────
|
// ── BookReader ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type pbBook struct {
|
type pbBook struct {
|
||||||
|
|||||||
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
33
ui/src/routes/api/admin/dedup-chapters/[slug]/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/admin/dedup-chapters/[slug]
|
||||||
|
*
|
||||||
|
* Admin-only proxy to the Go backend's dedup endpoint.
|
||||||
|
* Removes duplicate chapters_idx records for a book, keeping the latest
|
||||||
|
* record per chapter number. Returns { slug, deleted }.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
import { backendFetch } from '$lib/server/scraper';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
if (!locals.user || locals.user.role !== 'admin') {
|
||||||
|
throw error(403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug } = params;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await backendFetch(`/api/admin/dedup-chapters/${encodeURIComponent(slug)}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
|
||||||
|
throw error(502, 'Could not reach backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return json(data, { status: res.status });
|
||||||
|
};
|
||||||
@@ -154,7 +154,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
||||||
}
|
}
|
||||||
const markdown = await res.text();
|
const markdown = await res.text();
|
||||||
html = marked(markdown) as string;
|
html = await marked(markdown);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && 'status' in e) throw e;
|
if (e instanceof Error && 'status' in e) throw e;
|
||||||
// Don't hard-fail — show empty content with error message
|
// Don't hard-fail — show empty content with error message
|
||||||
|
|||||||
Reference in New Issue
Block a user