package store import ( "context" "fmt" "time" "github.com/jackc/pgx/v5" ) // CaptionlessChannels returns the set of channel ids currently suppressed for the // user — channels whose recent videos all yielded no captions, within their // suppression window (ADR-024). The runner skips caption fetches for these // channels' videos. A channel whose window has expired is not returned, so its // next video is re-probed (auto-recovery). func (s *Store) CaptionlessChannels(ctx context.Context, userID string) (map[string]bool, error) { out := map[string]bool{} err := s.withUser(ctx, userID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT channel_id FROM channel_caption_state WHERE user_id = $1 AND captionless_until IS NOT NULL AND captionless_until > now()`, userID) if err != nil { return fmt.Errorf("store: caption-less channels: %w", err) } defer rows.Close() for rows.Next() { var ch string if err := rows.Scan(&ch); err != nil { return fmt.Errorf("store: scan caption-less channel: %w", err) } out[ch] = true } return rows.Err() }) return out, err } // RecordChannelCaptionOutcome updates a channel's caption-availability memory // after a fetch attempt (ADR-024). hadCaptions resets the channel (consecutive // count to 0, suppression cleared). Otherwise the consecutive no-caption count is // incremented; once it reaches threshold the channel is suppressed for window. // threshold <= 0 is a no-op (feature disabled). An empty channelID is ignored // (some sources may not carry one). func (s *Store) RecordChannelCaptionOutcome(ctx context.Context, userID, channelID string, hadCaptions bool, threshold int, window time.Duration) error { if channelID == "" || threshold <= 0 { return nil } return s.withUser(ctx, userID, func(tx pgx.Tx) error { if hadCaptions { _, err := tx.Exec(ctx, ` INSERT INTO channel_caption_state (user_id, channel_id, consecutive_none, captionless_until, updated_at) VALUES ($1, $2, 0, NULL, now()) ON CONFLICT (user_id, channel_id) DO UPDATE SET consecutive_none = 0, captionless_until = NULL, updated_at = now()`, userID, channelID) if err != nil { return fmt.Errorf("store: reset channel caption state: %w", err) } return nil } // No captions: increment the streak; suppress once it reaches threshold. // captionless_until is set from the NEW count inside the same statement so // the decision is atomic with the increment. until := time.Now().Add(window) _, err := tx.Exec(ctx, ` INSERT INTO channel_caption_state (user_id, channel_id, consecutive_none, captionless_until, updated_at) VALUES ($1, $2, 1, CASE WHEN 1 >= $3 THEN $4::timestamptz ELSE NULL END, now()) ON CONFLICT (user_id, channel_id) DO UPDATE SET consecutive_none = channel_caption_state.consecutive_none + 1, captionless_until = CASE WHEN channel_caption_state.consecutive_none + 1 >= $3 THEN $4::timestamptz ELSE channel_caption_state.captionless_until END, updated_at = now()`, userID, channelID, threshold, until) if err != nil { return fmt.Errorf("store: record channel no-caption: %w", err) } return nil }) }