After transcript caching (ADR-021) and the Shorts filter (ADR-023), the remaining caption waste is the first fetch on every new video of a channel that never has English captions — each costs one rate-limited fetch to resolve to "none", and on a throttled IP churns the backoff machinery first. Remember, per (user, channel), a streak of consecutive no-caption outcomes (channel_caption_state, migration 016, RLS-scoped). Once it reaches TAPIR_CHANNEL_CAPTIONLESS_THRESHOLD (default 5) the channel is suppressed — videos discovered/listed but not caption-fetched — for TAPIR_CHANNEL_CAPTIONLESS_WINDOW (default 14d), then one is re-probed (auto-recovery). A successful fetch resets the streak; a 429 does not count; an explicit manual request bypasses suppression. threshold=0 disables. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
3.1 KiB
Go
84 lines
3.1 KiB
Go
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
|
|
})
|
|
}
|