package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // validTranscriptStatuses bounds SetTranscriptStatus input. "" clears the status // (column NULL); the three named states mirror migration 007's documented values. var validTranscriptStatuses = map[string]bool{ "": true, "none": true, "rate_limited": true, "fetched": true, } // SetTranscriptStatus records the outcome of the last transcript attempt for a // video (migration 007). When status is "rate_limited" it also stamps // rate_limited_at = NOW() so the runner can back off; every other status clears // that timestamp. "" unsets the status (column NULL). An unknown status is // rejected. Scoped via withUser, so RLS confines the UPDATE to the caller's own // video; ErrNotFound when the user has no such video. func (s *Store) SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error { if !validTranscriptStatuses[status] { return fmt.Errorf("store: invalid transcript status %q", status) } return s.withUser(ctx, userID, func(tx pgx.Tx) error { ct, err := tx.Exec(ctx, `UPDATE videos SET transcript_status = NULLIF($1, ''), rate_limited_at = CASE WHEN $1 = 'rate_limited' THEN NOW() ELSE NULL END WHERE id = $2`, status, videoID) if err != nil { return fmt.Errorf("store: set transcript status: %w", err) } if ct.RowsAffected() == 0 { return ErrNotFound } return nil }) } // GetTranscriptStatus returns a video's transcript_status ("" when unset/NULL). // Returns ErrNotFound when the user has no such video. Scoped via withUser. func (s *Store) GetTranscriptStatus(ctx context.Context, userID, videoID string) (string, error) { var status string if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { err := tx.QueryRow(ctx, `SELECT COALESCE(transcript_status, '') FROM videos WHERE id = $1`, videoID).Scan(&status) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } return err }); err != nil { if errors.Is(err, ErrNotFound) { return "", ErrNotFound } return "", fmt.Errorf("store: get transcript status: %w", err) } return status, nil } // RateLimitedVideoIDs returns the user's videos currently in the "rate_limited" // state, mapped to when the 429 was stamped (rate_limited_at). The run loop loads // it once per pass (mirroring SeenVideoIDs) to skip re-fetching a video still // inside the backoff window, saving caption requests. Scoped by user_id. func (s *Store) RateLimitedVideoIDs(ctx context.Context, userID string) (map[string]time.Time, error) { out := make(map[string]time.Time) if err := s.withUser(ctx, userID, func(tx pgx.Tx) error { rows, err := tx.Query(ctx, `SELECT id, rate_limited_at FROM videos WHERE user_id = $1 AND transcript_status = 'rate_limited' AND rate_limited_at IS NOT NULL`, userID) if err != nil { return fmt.Errorf("store: rate limited video ids: %w", err) } defer rows.Close() for rows.Next() { var ( id string at time.Time ) if err := rows.Scan(&id, &at); err != nil { return fmt.Errorf("store: scan rate limited id: %w", err) } out[id] = at } if err := rows.Err(); err != nil { return fmt.Errorf("store: iterate rate limited ids: %w", err) } return nil }); err != nil { return nil, err } return out, nil }