feat(store): OnboardBurstVideoIDs — junk-avoiding burst selection (ADR-028)
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.
This commit is contained in:
@@ -124,6 +124,53 @@ func (s *Store) NewestUnsummarizedVideoIDs(ctx context.Context, userID string, l
|
|||||||
return ids, nil
|
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
|
// DistinctChannels returns the user's distinct, non-empty source channel titles
|
||||||
// (the channels they have videos from), alphabetically — the option list for the
|
// (the channels they have videos from), alphabetically — the option list for the
|
||||||
// feed's channel filter. RLS-scoped via withUser.
|
// feed's channel filter. RLS-scoped via withUser.
|
||||||
|
|||||||
@@ -144,6 +144,55 @@ func TestNewestUnsummarizedVideoIDs(t *testing.T) {
|
|||||||
require.Empty(t, none, "limit 0 returns nothing")
|
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) {
|
func TestUpsertVideoPersistsChannelAndDistinctChannels(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s := newStore(t)
|
s := newStore(t)
|
||||||
|
|||||||
Reference in New Issue
Block a user