diff --git a/cmd/tapir/discovery.go b/cmd/tapir/discovery.go index 7d5cf7b..86df12f 100644 --- a/cmd/tapir/discovery.go +++ b/cmd/tapir/discovery.go @@ -32,7 +32,10 @@ func serialize(mu *sync.Mutex, run discoveryRunner) discoveryRunner { type discoveryTrigger struct { ctx context.Context run discoveryRunner - log *slog.Logger + // onboard, when set, runs after the discovery pass to summarize a capped number + // of the user's newest videos (Feature 1). Optional. + onboard func(ctx context.Context, userID string) + log *slog.Logger } func (t *discoveryTrigger) Enqueue(userID string) { @@ -40,5 +43,8 @@ func (t *discoveryTrigger) Enqueue(userID string) { if _, err := t.run(t.ctx, userID); err != nil { t.log.Warn("discovery: connect-triggered pass had errors", "user", userID, "err", err) } + if t.onboard != nil { + t.onboard(t.ctx, userID) + } }() } diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 4b8ed23..33ef6e8 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -210,7 +210,10 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { ClientSecret: cfg.YTClientSecret, RedirectURL: cfg.YTConnectRedirectURL, }, secretStore, st, log) - log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL) + // Paste-a-URL (Feature 2): same YouTube credentials, per-user adapter built + // per request. Mounting the /paste route keys off app.Fetcher being set. + app.Fetcher = videoFetcher{cfg: cfg, secrets: secretStore} + log.Info("web youtube connect + paste enabled", "redirect", cfg.YTConnectRedirectURL) } // Immediate summarization for the web "Summarize" button. When the engine can @@ -249,9 +252,33 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { // One lock shared by the scheduler and connect-triggered passes (#6) so // they never fetch concurrently — the single-fetcher invariant (ADR-018). runUser := serialize(&sync.Mutex{}, rawRunUser) + // Onboarding burst (Feature 1): after the connect-triggered discovery pass, + // summarize up to OnboardSummarizeCount of the user's NEWEST unsummarized + // videos so a fresh account gets real summaries in its first session. Hard + // cap; explicit, so it bypasses the recency window — but every fetch still + // goes through globalFetchGate via the Processor. No-op when disabled + // (count 0) or queue-only (no Processor). + onboard := func(ctx context.Context, userID string) { + if cfg.OnboardSummarizeCount <= 0 || app.Processor == nil { + return + } + ids, err := st.NewestUnsummarizedVideoIDs(ctx, userID, cfg.OnboardSummarizeCount) + if err != nil { + log.Warn("onboarding: list newest unsummarized", "user", userID, "err", err) + return + } + for _, id := range ids { + if err := app.Processor.ProcessVideo(ctx, userID, id); err != nil { + log.Warn("onboarding: summarize", "user", userID, "video", id, "err", err) + } + } + if len(ids) > 0 { + log.Info("onboarding burst complete", "user", userID, "summarized", len(ids), "cap", cfg.OnboardSummarizeCount) + } + } if app.Connect != nil { - app.Connect.Discovery = &discoveryTrigger{ctx: ctx, run: runUser, log: log} - log.Info("connect-triggered discovery enabled") + app.Connect.Discovery = &discoveryTrigger{ctx: ctx, run: runUser, onboard: onboard, log: log} + log.Info("connect-triggered discovery enabled", "onboard_cap", cfg.OnboardSummarizeCount) } go runScheduler(ctx, cfg.DiscoveryInterval, st, runUser, log) } else { diff --git a/cmd/tapir/processor.go b/cmd/tapir/processor.go index 7e979cd..f4dadea 100644 --- a/cmd/tapir/processor.go +++ b/cmd/tapir/processor.go @@ -11,9 +11,29 @@ import ( "gitea.d-ma.be/mathias/tapir/internal/adapters/youtube" "gitea.d-ma.be/mathias/tapir/internal/config" "gitea.d-ma.be/mathias/tapir/internal/domain" + "gitea.d-ma.be/mathias/tapir/internal/ports" "gitea.d-ma.be/mathias/tapir/internal/usecase" + "gitea.d-ma.be/mathias/tapir/internal/web" ) +// videoFetcher adapts the YouTube adapter to web.VideoFetcher for the paste flow +// (Feature 2). It builds a per-user adapter bound to that user's token ref and +// resolves a single video's metadata via the Data API — ungated; only the later +// transcript fetch goes through globalFetchGate. +type videoFetcher struct { + cfg config.Config + secrets ports.SecretStore +} + +func (f videoFetcher) FetchVideo(ctx context.Context, userID, videoID string) (domain.Video, error) { + a := youtube.New(youtube.Config{ + ClientID: f.cfg.YTClientID, + ClientSecret: f.cfg.YTClientSecret, + TokenSecretRef: web.YouTubeTokenRef(userID), + }, f.secrets) + return a.VideoByID(ctx, userID, videoID) +} + // buildProcessor wires the summarization engine — YouTube source (captions-first), // AI-router summarizer, store sink — shared by `tapir run` and the web // "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —