From 582c1a2065c6a8845e5e99305d916fa5bb27e18d Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 11 Jun 2026 18:41:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(store):=20OnboardBurstVideoIDs=20=E2=80=94?= =?UTF-8?q?=20junk-avoiding=20burst=20selection=20(ADR-028)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newest-first but quality-aware: excludes a video when its duration is KNOWN and outside [minSeconds, maxSeconds], dropping Shorts and multi-hour livestream VODs that waste a scarce caption fetch on a poor first impression. NULL/unknown duration is kept (degrade-open) but ranked after known-good rows. 0/0 bounds disable the filter (pure newest-first, the reversibility lever). RLS-scoped. NewestUnsummarizedVideoIDs is left in place for callers that want pure-newest. --- internal/adapters/store/videos.go | 47 ++++++++++++++++++++++++ internal/adapters/store/videos_test.go | 49 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/internal/adapters/store/videos.go b/internal/adapters/store/videos.go index 29b6b2d..fa73768 100644 --- a/internal/adapters/store/videos.go +++ b/internal/adapters/store/videos.go @@ -124,6 +124,53 @@ func (s *Store) NewestUnsummarizedVideoIDs(ctx context.Context, userID string, l return ids, nil } +// OnboardBurstVideoIDs returns up to limit of the user's unsummarized videos for +// the connect-time onboarding burst (ADR-028), newest-first but quality-aware: a +// video is excluded when its duration is KNOWN and outside [minSeconds, maxSeconds] +// — dropping Shorts (below min) and multi-hour livestream VODs (above max) that +// would waste a scarce caption fetch on a poor first impression. A NULL/unknown +// duration is kept (degrade-open) but ranked AFTER known-good rows, so a freshly +// enriched good pick wins when both exist. minSeconds<=0 / maxSeconds<=0 each +// disable that bound (0/0 == pure newest-first, the reversibility lever). +// RLS-scoped via withUser; limit <= 0 returns nil. +func (s *Store) OnboardBurstVideoIDs(ctx context.Context, userID string, limit, minSeconds, maxSeconds int) ([]string, error) { + if limit <= 0 { + return nil, nil + } + var ids []string + if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, + `SELECT v.id + FROM videos v + WHERE v.user_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM summaries su + WHERE su.user_id = v.user_id AND su.video_id = v.id) + AND NOT ( + v.duration_s IS NOT NULL + AND ( ($3 > 0 AND v.duration_s < $3) + OR ($4 > 0 AND v.duration_s > $4) )) + ORDER BY (v.duration_s IS NOT NULL) DESC, + v.published_at DESC NULLS LAST, v.seen_at DESC + LIMIT $2`, userID, limit, minSeconds, maxSeconds) + if err != nil { + return fmt.Errorf("store: onboard burst videos: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("store: scan onboard burst video: %w", err) + } + ids = append(ids, id) + } + return rows.Err() + }); err != nil { + return nil, err + } + return ids, nil +} + // DistinctChannels returns the user's distinct, non-empty source channel titles // (the channels they have videos from), alphabetically — the option list for the // feed's channel filter. RLS-scoped via withUser. diff --git a/internal/adapters/store/videos_test.go b/internal/adapters/store/videos_test.go index 954f79a..bb396be 100644 --- a/internal/adapters/store/videos_test.go +++ b/internal/adapters/store/videos_test.go @@ -144,6 +144,55 @@ func TestNewestUnsummarizedVideoIDs(t *testing.T) { require.Empty(t, none, "limit 0 returns nothing") } +func TestOnboardBurstVideoIDs(t *testing.T) { + ctx := context.Background() + s := newStore(t) + resetDB(t, rawPool(t)) + + mk := func(user, pid string, day, dur int) string { + v := ytVideo(user, pid, pid) + v.PublishedAt = time.Date(2026, 6, day, 12, 0, 0, 0, time.UTC) + v.DurationSeconds = dur // 0 == unknown (NULL) + id, err := s.UpsertVideo(ctx, v) + require.NoError(t, err) + return id + } + + good1 := mk(userA, "good0000001", 5, 600) // 10m, newest known-good + tooLong := mk(userA, "toolong0001", 4, 20000) // > maxSeconds -> dropped + _ = tooLong + tooShort := mk(userA, "tooshort001", 3, 30) // < minSeconds -> dropped + _ = tooShort + unknown := mk(userA, "unknown0001", 2, 0) // NULL duration -> kept, ranked last + good2 := mk(userA, "good0000002", 1, 800) // known-good but oldest + mk(userB, "bvid0000009", 9, 600) // userB -> must not leak via RLS + + const minSec, maxSec = 60, 14400 + + // Known-good ranked before unknown, each newest-first within its group; the + // too-long and too-short videos are excluded by their known duration. + got, err := s.OnboardBurstVideoIDs(ctx, userA, 5, minSec, maxSec) + require.NoError(t, err) + require.Equal(t, []string{good1, good2, unknown}, got, + "known-good first (newest-first), then unknown-duration; junk excluded") + + // Cap is honoured. + capped, err := s.OnboardBurstVideoIDs(ctx, userA, 2, minSec, maxSec) + require.NoError(t, err) + require.Equal(t, []string{good1, good2}, capped) + + // Bounds disabled (0/0) == pure newest-first, nothing excluded. + all, err := s.OnboardBurstVideoIDs(ctx, userA, 10, 0, 0) + require.NoError(t, err) + require.ElementsMatch(t, []string{good1, tooLong, tooShort, unknown, good2}, all, + "0/0 bounds disable the duration filter (prior newest-first behaviour)") + + // limit <= 0 returns nothing. + none, err := s.OnboardBurstVideoIDs(ctx, userA, 0, minSec, maxSec) + require.NoError(t, err) + require.Empty(t, none) +} + func TestUpsertVideoPersistsChannelAndDistinctChannels(t *testing.T) { ctx := context.Background() s := newStore(t)