diff --git a/internal/audiobooks/abs/bookmarks.go b/internal/audiobooks/abs/bookmarks.go index b384780b..4f3d7dc0 100644 --- a/internal/audiobooks/abs/bookmarks.go +++ b/internal/audiobooks/abs/bookmarks.go @@ -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 diff --git a/internal/audiobooks/abs/bookmarks_handler_test.go b/internal/audiobooks/abs/bookmarks_handler_test.go index 2e5fec11..f18fbe74 100644 --- a/internal/audiobooks/abs/bookmarks_handler_test.go +++ b/internal/audiobooks/abs/bookmarks_handler_test.go @@ -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) diff --git a/internal/audiobooks/abs_bookmark_store.go b/internal/audiobooks/abs_bookmark_store.go index cbb53a5c..a4b2c8a2 100644 --- a/internal/audiobooks/abs_bookmark_store.go +++ b/internal/audiobooks/abs_bookmark_store.go @@ -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 +} diff --git a/migrations/153_abs_smart_collections.down.sql b/migrations/153_abs_smart_collections.down.sql new file mode 100644 index 00000000..c8232e09 --- /dev/null +++ b/migrations/153_abs_smart_collections.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx; +DROP TABLE IF EXISTS public.abs_smart_collections; diff --git a/migrations/153_abs_smart_collections.up.sql b/migrations/153_abs_smart_collections.up.sql new file mode 100644 index 00000000..520ffb07 --- /dev/null +++ b/migrations/153_abs_smart_collections.up.sql @@ -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) + );