feat(audiobooks): migration 153 + BookmarkStore.CountByUser extension

Migration 153 backs the upcoming smart-collections surface.
BookmarkStore.CountByUser returns per-item counts in one SQL pass —
used by the smart-collection items evaluator to hydrate the
bookmark_count personalized rule without N+1 queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 20:22:04 +02:00
co-authored by Claude Opus 4.7
parent ba6eaf0c05
commit 502f0d10d6
5 changed files with 93 additions and 0 deletions
+5
View File
@@ -20,6 +20,11 @@ type BookmarkStore interface {
// Returns nil when no row matched — DELETE is idempotent (a UX
// convenience, not a 404 surface). See spec §6.
Delete(ctx context.Context, userID, profileID, itemID string, timeSeconds float64) error
// CountByUser returns a map of library_item_id -> bookmark count
// for the given (user, profile). Empty map (never nil) when none.
// Used by the smart-collection items evaluator to hydrate the
// `bookmark_count` personalized rule in one SQL pass.
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
}
// Bookmark is the in-memory representation of an abs_bookmarks row as
@@ -94,6 +94,31 @@ func (m *memBookmarkStore) Delete(_ context.Context, userID, profileID, itemID s
return nil
}
func (m *memBookmarkStore) CountByUser(_ context.Context, userID, profileID string) (map[string]int, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := map[string]int{}
prefix := userID + "|" + profileID + "|"
for k := range m.rows {
if strings.HasPrefix(k, prefix) {
rest := k[len(prefix):]
sep := -1
for i, c := range rest {
if c == '|' {
sep = i
break
}
}
if sep < 0 {
continue
}
itemID := rest[:sep]
out[itemID]++
}
}
return out, nil
}
func formatSeq(n int) string {
b, _ := json.Marshal(n)
return string(b)
+36
View File
@@ -122,3 +122,39 @@ func (s *ABSBookmarkStore) Delete(ctx context.Context, userID, profileID, itemID
}
return nil
}
// CountByUser returns a map of library_item_id -> bookmark count for
// the given (user, profile). One SQL query; used by the
// smart-collection items evaluator for batch hydration.
func (s *ABSBookmarkStore) CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error) {
uid, err := strconv.Atoi(userID)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: invalid user id %q: %w", userID, err)
}
rows, err := s.Pool.Query(ctx, `
SELECT library_item_id, COUNT(*)
FROM abs_bookmarks
WHERE user_id = $1
AND COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
= COALESCE($2::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
GROUP BY library_item_id`,
uid, profileArg(profileID),
)
if err != nil {
return nil, fmt.Errorf("abs_bookmark_store: count-by-user: %w", err)
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var itemID string
var count int
if err := rows.Scan(&itemID, &count); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: count-by-user scan: %w", err)
}
out[itemID] = count
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("abs_bookmark_store: count-by-user rows: %w", err)
}
return out, nil
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
DROP TABLE IF EXISTS public.abs_smart_collections;
@@ -0,0 +1,25 @@
-- Smart Collections — rule-based dynamic groupings of audiobooks.
-- The query_def JSONB column stores the DSL tree (see
-- internal/audiobooks/smartcoll/query.go). Profile-scoped per the
-- established convention; is_public allows cross-user reads with
-- personalization stripped at eval time.
CREATE TABLE IF NOT EXISTS public.abs_smart_collections (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
is_pinned boolean NOT NULL DEFAULT false,
query_def jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
ON public.abs_smart_collections (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);