The scarce resource is the per-IP timedtext caption fetch (ADR-014); the pilot's candidate set was mostly Shorts/clips/livestreams, each burning a fetch (a "none" result is a completed fetch — it costs budget even when it yields nothing). NewVideos now enriches candidates with one cheap Data API videos.list call (contentDetails.duration + snippet.liveBroadcastContent — the quota API, a DIFFERENT limit from the timedtext 429) and drops, before returning: videos shorter than TAPIR_MIN_VIDEO_SECONDS (default 60) and any live/upcoming broadcast. Dropped videos are never persisted, so the list declutters too. Degrade-open: MinVideoSeconds=0 disables it (no quota call); a videos.list error returns candidates unfiltered so discovery never breaks on a metadata hiccup. The paste-a-URL path (VideoByID) is not filtered — an explicit request is honoured. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
7.5 KiB
Go
195 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
|
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
|
"gitea.d-ma.be/mathias/tapir/internal/runner"
|
|
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
|
)
|
|
|
|
// buildUserRunner constructs a runner.Runner for one user, reusing the same
|
|
// engine wiring as buildProcessor but bound to that user's own YouTube refresh
|
|
// token (web.YouTubeTokenRef(userID)) — the Stage-1 per-tenant ref, not the
|
|
// Stage-0 single ref. It returns an error (not nil) when the global config can't
|
|
// support live summarization (gateway, YouTube client creds, secrets file), so
|
|
// the scheduler can skip that user gracefully. A user who simply hasn't connected
|
|
// YouTube yet builds fine here; their token ref fails to resolve at RunOnce time,
|
|
// surfacing as a per-user error the scheduler logs and skips.
|
|
func buildUserRunner(cfg config.Config, st *store.Store, secretStore ports.SecretStore, userID string, log *slog.Logger) (*runner.Runner, error) {
|
|
if cfg.GatewayURL == "" || cfg.YTClientID == "" || cfg.YTClientSecret == "" || cfg.SecretsFile == "" {
|
|
return nil, fmt.Errorf("buildUserRunner: incomplete summarization config (gateway, youtube credentials, secrets file)")
|
|
}
|
|
|
|
src := youtube.New(youtube.Config{
|
|
ClientID: cfg.YTClientID,
|
|
ClientSecret: cfg.YTClientSecret,
|
|
TokenSecretRef: web.YouTubeTokenRef(userID),
|
|
PreferredLanguages: []string{"en"},
|
|
MinVideoSeconds: cfg.MinVideoSeconds,
|
|
}, secretStore)
|
|
|
|
engine := usecase.NewEngine(src, buildSummarizer(cfg), st)
|
|
// Share the transcript cache (ADR-021) on the scheduler path too — without
|
|
// this every scheduled pass re-fetches transcripts it already had, burning the
|
|
// scarce per-IP caption budget (ADR-014) and starving other users. The web
|
|
// "Summarize now" path already sets this; the scheduler omitting it was a bug.
|
|
engine.Transcripts = st
|
|
|
|
return runner.New(src, st, engine, userID, log,
|
|
runner.WithBackoff(cfg.FetchBackoff),
|
|
runner.WithAutoWindow(cfg.AutoSummarizeWindow)), nil
|
|
}
|
|
|
|
// userLister enumerates every registered user and reports a user's video
|
|
// connections. *store.Store satisfies it via ListAllUsers + ConnectionsForUser.
|
|
// A small local interface keeps the scheduler testable with a fake.
|
|
type userLister interface {
|
|
ListAllUsers(ctx context.Context) ([]store.UserIdentity, error)
|
|
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
|
}
|
|
|
|
// runDiscoveryPass runs one discovery pass for every user. runUser performs a
|
|
// single user's pass (production: build a runner and RunOnce). Per-user failures
|
|
// — including a buildUserRunner error or a RunOnce error — are logged and skipped
|
|
// so one bad user, channel, or video never aborts the others (ADR-018 failure
|
|
// isolation). Returns the stats summed across users.
|
|
func runDiscoveryPass(
|
|
ctx context.Context,
|
|
pass int,
|
|
lister userLister,
|
|
runUser func(context.Context, string) (runner.Stats, error),
|
|
log *slog.Logger,
|
|
) runner.Stats {
|
|
users, err := lister.ListAllUsers(ctx)
|
|
if err != nil {
|
|
log.Error("scheduler: list users failed", "err", err)
|
|
return runner.Stats{}
|
|
}
|
|
|
|
// Keep only users with a video connection. A pass for a connectionless user
|
|
// (e.g. a stale Dex-era orphan identity) only tries to resolve a token that
|
|
// was never minted, logging a spurious "ref not found" every tick. Filtering
|
|
// here — BEFORE rotation — also keeps fairness honest: rotation is over the
|
|
// users that actually consume the caption budget, so a dead identity can't eat
|
|
// a rotation slot and skew the lead share.
|
|
var connected []store.UserIdentity
|
|
for _, u := range users {
|
|
if ctx.Err() != nil {
|
|
return runner.Stats{} // shutting down
|
|
}
|
|
conns, err := lister.ConnectionsForUser(ctx, u.UserID)
|
|
if err != nil {
|
|
log.Warn("scheduler: list connections failed", "user", u.UserID, "err", err)
|
|
continue
|
|
}
|
|
if len(conns) == 0 {
|
|
log.Debug("scheduler: skipping user with no video connections", "user", u.UserID)
|
|
continue
|
|
}
|
|
connected = append(connected, u)
|
|
}
|
|
|
|
// Rotate who goes first each pass. Caption fetches share one per-egress-IP
|
|
// rate budget (ADR-014); whoever runs first each pass spends the pre-throttle
|
|
// window, so a FIXED order permanently starves whoever is last (a new pilot
|
|
// user got 0 fetches for 12h while the first-listed user got all of them).
|
|
// Rotation over the connected set gives each real user the lead in turn.
|
|
connected = rotateUsers(connected, pass)
|
|
|
|
log.Info("scheduler: starting discovery pass", "users", len(connected))
|
|
var total runner.Stats
|
|
for _, u := range connected {
|
|
if ctx.Err() != nil {
|
|
break // shutting down: stop enumerating
|
|
}
|
|
stats, err := runUser(ctx, u.UserID)
|
|
total = sumStats(total, stats)
|
|
if err != nil {
|
|
log.Warn("scheduler: user discovery pass had errors", "user", u.UserID, "err", err)
|
|
}
|
|
}
|
|
log.Info("scheduler: pass complete",
|
|
"candidates", total.Candidates, "summarized", total.Summarized,
|
|
"skipped_seen", total.SkippedSeen, "skipped_no_text", total.SkippedNoText,
|
|
"skipped_manual", total.SkippedManual, "skipped_too_old", total.SkippedTooOld,
|
|
"skipped_rate_limited", total.SkippedRateLimited,
|
|
"channel_unavailable", total.ChannelUnavailable, "errors", total.Errors)
|
|
return total
|
|
}
|
|
|
|
// runScheduler runs a discovery pass on startup, then once every interval until
|
|
// ctx is cancelled (pod SIGTERM exits the loop cleanly). A non-positive interval
|
|
// disables scheduling entirely (no startup pass) so dev/tests never auto-fetch.
|
|
// It reuses the existing runner.Runner via runUser — the only new behaviour over
|
|
// runner.Loop is iterating all users per tick (ADR-018).
|
|
func runScheduler(
|
|
ctx context.Context,
|
|
interval time.Duration,
|
|
lister userLister,
|
|
runUser func(context.Context, string) (runner.Stats, error),
|
|
log *slog.Logger,
|
|
) {
|
|
if interval <= 0 {
|
|
return // disabled
|
|
}
|
|
|
|
pass := 0
|
|
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
pass++
|
|
runDiscoveryPass(ctx, pass, lister, runUser, log)
|
|
}
|
|
}
|
|
}
|
|
|
|
// rotateUsers left-rotates users by pass positions so a different user leads each
|
|
// pass. With n users, user i leads on every pass where pass ≡ i (mod n). A pass
|
|
// offset that is negative or exceeds n is normalised. Order within the rotation
|
|
// is otherwise preserved, so the set of users run is unchanged — only who is
|
|
// first (and thus wins the scarce caption-fetch budget) rotates.
|
|
func rotateUsers(users []store.UserIdentity, pass int) []store.UserIdentity {
|
|
n := len(users)
|
|
if n <= 1 {
|
|
return users
|
|
}
|
|
off := ((pass % n) + n) % n
|
|
if off == 0 {
|
|
return users
|
|
}
|
|
out := make([]store.UserIdentity, 0, n)
|
|
out = append(out, users[off:]...)
|
|
out = append(out, users[:off]...)
|
|
return out
|
|
}
|
|
|
|
// sumStats adds two passes' stats field-wise, so runDiscoveryPass can report a
|
|
// per-tick aggregate across all users.
|
|
func sumStats(a, b runner.Stats) runner.Stats {
|
|
return runner.Stats{
|
|
Candidates: a.Candidates + b.Candidates,
|
|
Summarized: a.Summarized + b.Summarized,
|
|
SkippedSeen: a.SkippedSeen + b.SkippedSeen,
|
|
SkippedNoText: a.SkippedNoText + b.SkippedNoText,
|
|
SkippedManual: a.SkippedManual + b.SkippedManual,
|
|
SkippedTooOld: a.SkippedTooOld + b.SkippedTooOld,
|
|
SkippedRateLimited: a.SkippedRateLimited + b.SkippedRateLimited,
|
|
ChannelUnavailable: a.ChannelUnavailable + b.ChannelUnavailable,
|
|
Errors: a.Errors + b.Errors,
|
|
}
|
|
}
|