From 2384c47b81e3b1fd2e8a4d12142a054d500d84af Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 8 Jun 2026 13:48:04 +0200 Subject: [PATCH] feat(runner): bound auto-summarize to a recency window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In automatic mode the scheduler now only summarizes videos published within TAPIR_AUTO_SUMMARIZE_WINDOW (default ~7d). Older videos are still discovered and listed — they keep the manual "Summarize" affordance — but are not auto-processed, so a large back-catalogue (the maintainer's ~256-deep queue) stops self-inflicting 429s against the per-IP caption gate each cycle (UX review B1, recency design). - runner.WithAutoWindow + Stats.SkippedTooOld; tooOld() treats a zero window as disabled and an undated video as never-aged-out (processed, not stranded). - An explicit manual request bypasses the bound even in auto mode (requested videos are loaded in auto mode when a window is active). - Wired through cmdRun, the scheduler's per-user runner, sumStats, and pass logging. config: TAPIR_AUTO_SUMMARIZE_WINDOW (default 168h), .env.example. - Account copy (A7) updated to match: "Automatic summarizes new videos from about the last week; older videos stay browsable — summarize on demand." The rate gate is untouched; the manual path still serialises through it. This bounds auto LOAD, it does not fetch harder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 6 +++ cmd/tapir/main.go | 6 ++- cmd/tapir/scheduler.go | 8 ++- internal/config/config.go | 15 ++++++ internal/config/config_test.go | 23 ++++++--- internal/runner/runner.go | 50 +++++++++++++++---- internal/runner/runner_test.go | 90 ++++++++++++++++++++++++++++++++++ internal/web/views.templ | 7 +-- internal/web/views_templ.go | 36 +++++++------- 9 files changed, 199 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 608e080..c816dc5 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,12 @@ TAPIR_POLL_INTERVAL= # caption endpoint; after it expires the video is retried. 0 = always retry. # Go duration; default 1h. TAPIR_FETCH_BACKOFF= +# Recency bound for AUTO summarization: in automatic mode only videos published +# within this window of now are summarized; older ones are discovered + listed +# but wait for a manual "Summarize" (so a back-catalogue doesn't self-inflict +# 429s). An explicit request bypasses it. Go duration; default 168h (~7d). +# 0 = no bound (summarize every unseen video). +TAPIR_AUTO_SUMMARIZE_WINDOW= # Minimum interval between outbound caption fetches across the WHOLE process — # the shared per-egress-IP rate gate (ADR-014). Scheduler runners and the web # "Summarize" click-path serialise through it so they cannot collectively trip diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 247daab..14dabf2 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -133,11 +133,13 @@ func cmdRun(ctx context.Context, log *slog.Logger) error { // the same per-egress-IP limiter as the web click-path. youtube.SetFetchRate(cfg.FetchRate) - r := runner.New(engine.Source, st, engine, cfg.UserID, log, runner.WithBackoff(cfg.FetchBackoff)) + r := runner.New(engine.Source, st, engine, cfg.UserID, log, + runner.WithBackoff(cfg.FetchBackoff), + runner.WithAutoWindow(cfg.AutoSummarizeWindow)) log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel, "gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff, - "fetch_rate", cfg.FetchRate) + "fetch_rate", cfg.FetchRate, "auto_window", cfg.AutoSummarizeWindow) return r.Loop(ctx, cfg.PollInterval) } diff --git a/cmd/tapir/scheduler.go b/cmd/tapir/scheduler.go index 4ff0471..f7cd560 100644 --- a/cmd/tapir/scheduler.go +++ b/cmd/tapir/scheduler.go @@ -44,7 +44,9 @@ func buildUserRunner(cfg config.Config, st *store.Store, secretStore ports.Secre } engine := usecase.NewEngine(src, summarizer.New(primary, nil), st) - return runner.New(src, st, engine, userID, log, runner.WithBackoff(cfg.FetchBackoff)), nil + return runner.New(src, st, engine, userID, log, + runner.WithBackoff(cfg.FetchBackoff), + runner.WithAutoWindow(cfg.AutoSummarizeWindow)), nil } // userLister enumerates every registered user. *store.Store satisfies it via @@ -85,7 +87,8 @@ func runDiscoveryPass( 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_rate_limited", total.SkippedRateLimited, + "skipped_manual", total.SkippedManual, "skipped_too_old", total.SkippedTooOld, + "skipped_rate_limited", total.SkippedRateLimited, "channel_unavailable", total.ChannelUnavailable, "errors", total.Errors) return total } @@ -129,6 +132,7 @@ func sumStats(a, b runner.Stats) runner.Stats { 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, diff --git a/internal/config/config.go b/internal/config/config.go index e81b3e8..6b9df8c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,6 +71,14 @@ type Config struct { // the web click-path serialise through it. Zero = unlimited (dev/tests). FetchRate time.Duration + // AutoSummarizeWindow bounds auto-summarization to recent videos: in automatic + // mode the scheduler only summarizes videos published within this window of now. + // Older videos are still discovered and listed, but wait for an explicit manual + // "Summarize" — so a large back-catalogue does not self-inflict 429s against the + // caption rate gate. Zero disables the bound (summarize every unseen video, the + // pre-recency behaviour). Default ~7 days. + AutoSummarizeWindow time.Duration + // DiscoveryInterval, when > 0, makes `serve` run in-process scheduled discovery // for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and // tests never auto-fetch. Single-replica assumption — see cmdServe. @@ -110,6 +118,7 @@ const ( defaultFetchBackoff = time.Hour defaultFetchRate = 2 * time.Second defaultPublicURL = "https://tapir.d-ma.be" + defaultAutoSummarizeWindow = 7 * 24 * time.Hour ) // Load reads the environment into a Config, applying defaults. It does not @@ -168,6 +177,12 @@ func Load() (Config, error) { } c.DiscoveryInterval = discovery + autoWindow, err := durationOr("TAPIR_AUTO_SUMMARIZE_WINDOW", defaultAutoSummarizeWindow) + if err != nil { + return Config{}, err + } + c.AutoSummarizeWindow = autoWindow + return c, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 59e0849..8b2451b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,18 +48,22 @@ func TestLoad_AppliesDefaults(t *testing.T) { if c.FetchBackoff != defaultFetchBackoff { t.Errorf("FetchBackoff = %v, want default %v", c.FetchBackoff, defaultFetchBackoff) } + if c.AutoSummarizeWindow != defaultAutoSummarizeWindow { + t.Errorf("AutoSummarizeWindow = %v, want default %v", c.AutoSummarizeWindow, defaultAutoSummarizeWindow) + } } func TestLoad_ParsesValues(t *testing.T) { setEnv(t, map[string]string{ - "TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111", - "TAPIR_GATEWAY_URL": "http://example/v1", - "TAPIR_GATEWAY_KEY": "sk-test", - "TAPIR_SUMMARIZER_MODEL": "iguana/deepseek-r1-14b", - "TAPIR_SUMMARIZER_TIMEOUT": "90s", - "TAPIR_DB_DSN": "postgres://x", - "TAPIR_POLL_INTERVAL": "10m", - "TAPIR_FETCH_BACKOFF": "30m", + "TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111", + "TAPIR_GATEWAY_URL": "http://example/v1", + "TAPIR_GATEWAY_KEY": "sk-test", + "TAPIR_SUMMARIZER_MODEL": "iguana/deepseek-r1-14b", + "TAPIR_SUMMARIZER_TIMEOUT": "90s", + "TAPIR_DB_DSN": "postgres://x", + "TAPIR_POLL_INTERVAL": "10m", + "TAPIR_FETCH_BACKOFF": "30m", + "TAPIR_AUTO_SUMMARIZE_WINDOW": "48h", }) c, err := Load() @@ -84,6 +88,9 @@ func TestLoad_ParsesValues(t *testing.T) { if c.FetchBackoff != 30*time.Minute { t.Errorf("FetchBackoff = %v, want 30m", c.FetchBackoff) } + if c.AutoSummarizeWindow != 48*time.Hour { + t.Errorf("AutoSummarizeWindow = %v, want 48h", c.AutoSummarizeWindow) + } } func TestLoad_RejectsBadDuration(t *testing.T) { diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 1d3b17c..1059d47 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -85,13 +85,14 @@ type Processor interface { // Runner walks a user's subscriptions, persists each candidate video, skips the // ones already summarized (durably), and processes the rest through the engine. type Runner struct { - src ports.VideoSource - store VideoStore - engine Processor - userID string - log *slog.Logger - backoff time.Duration // rate-limit retry window; 0 = always retry - now func() time.Time // injectable clock (tests); defaults to time.Now + src ports.VideoSource + store VideoStore + engine Processor + userID string + log *slog.Logger + backoff time.Duration // rate-limit retry window; 0 = always retry + autoWindow time.Duration // recency bound for auto-summarize; 0 = no bound + now func() time.Time // injectable clock (tests); defaults to time.Now } // Option configures a Runner at construction. Variadic so existing call sites @@ -106,6 +107,13 @@ func WithBackoff(d time.Duration) Option { return func(r *Runner) { r.backoff = // fixed time; production leaves the time.Now default. func WithClock(now func() time.Time) Option { return func(r *Runner) { r.now = now } } +// WithAutoWindow bounds auto-summarization to videos published within d of now. +// In auto mode a video older than d is discovered and listed but not summarized +// automatically — it waits for an explicit manual request — so a large +// back-catalogue does not self-inflict 429s. An explicitly requested video +// bypasses the bound. 0 (the default) disables it (summarize every unseen video). +func WithAutoWindow(d time.Duration) Option { return func(r *Runner) { r.autoWindow = d } } + // New builds a Runner. A nil logger falls back to slog.Default. func New(src ports.VideoSource, store VideoStore, engine Processor, userID string, log *slog.Logger, opts ...Option) *Runner { if log == nil { @@ -128,11 +136,23 @@ type Stats struct { SkippedSeen int SkippedNoText int SkippedManual int // discovered but not queued, in manual mode + SkippedTooOld int // auto mode: published outside the recency window (not requested) SkippedRateLimited int // 429'd previously and still inside the backoff window Errors int ChannelUnavailable int // channels that returned HTTP 404 (deleted/private) } +// tooOld reports whether a video published at publishedAt falls outside the +// auto-summarize recency window. A zero window disables the bound, and a zero +// publishedAt (undated video) is never aged out — it cannot be dated, so it is +// processed rather than silently stranded. +func (r *Runner) tooOld(publishedAt time.Time) bool { + if r.autoWindow <= 0 || publishedAt.IsZero() { + return false + } + return r.now().Sub(publishedAt) > r.autoWindow +} + // RunOnce performs a single pass over the user's subscriptions in three phases: // // 1. Discovery: walk all channels, persist each candidate video (UpsertVideo), @@ -172,8 +192,10 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { if err != nil { return stats, fmt.Errorf("runner: load summarize mode: %w", err) } + // requested is needed in manual mode (the queue) and in auto mode when a + // recency window is active (an explicit request bypasses the bound). var requested map[string]bool - if !auto { + if !auto || r.autoWindow > 0 { requested, err = r.store.RequestedVideoIDs(ctx, r.userID) if err != nil { return stats, fmt.Errorf("runner: load requested videos: %w", err) @@ -242,6 +264,15 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { continue } + // Recency bound (auto mode): summarize only recent videos automatically; + // older ones are discovered + listed (UpsertVideo above) but wait for an + // explicit manual request, so a large back-catalogue does not self-inflict + // 429s against the caption rate gate. A requested video bypasses the bound. + if auto && !requested[id] && r.tooOld(v.PublishedAt) { + stats.SkippedTooOld++ + continue + } + // Still inside the rate-limit backoff window: skip without fetching. if at, ok := rateLimited[id]; ok && r.now().Sub(at) < r.backoff { stats.SkippedRateLimited++ @@ -321,7 +352,8 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error { r.log.Info("run pass complete", "candidates", stats.Candidates, "summarized", stats.Summarized, "skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText, - "skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited, + "skipped_manual", stats.SkippedManual, "skipped_too_old", stats.SkippedTooOld, + "skipped_rate_limited", stats.SkippedRateLimited, "channel_unavailable", stats.ChannelUnavailable, "errors", stats.Errors) if err != nil { r.log.Warn("run pass had errors", "err", err) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 371d3c6..9d8a278 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -234,6 +234,96 @@ func TestRunOnce_ManualMode_ProcessesRequested(t *testing.T) { require.Equal(t, []string{"id-v1"}, st.cleared, "the queue flag is cleared after summarizing") } +// --- recency window (B1) --------------------------------------------------- + +// TestRunOnce_AutoMode_SkipsOldVideos: with a recency window set, auto mode +// summarizes only videos published within the window; older ones are discovered +// (upserted) but not auto-summarized — they wait for a manual request. +func TestRunOnce_AutoMode_SkipsOldVideos(t *testing.T) { + base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + src := &fakeSource{ + subs: []domain.Subscription{sub("chan1", "Channel One")}, + videos: map[string][]domain.Video{"chan1": { + vidAt("recent", "Recent", base.Add(-24*time.Hour)), // 1d old → in window + vidAt("old", "Old", base.Add(-30*24*time.Hour)), // 30d old → out of window + }}, + } + st := &fakeStore{seen: map[string]bool{}, auto: true} + sink := &recordingSink{} + eng := usecase.NewEngine(src, fakeSummarizer{}, sink) + r := runner.New(src, st, eng, testUser, quietLogger(), + runner.WithAutoWindow(7*24*time.Hour), runner.WithClock(func() time.Time { return base })) + + stats, err := r.RunOnce(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, stats.Summarized, "only the recent video is auto-summarized") + require.Equal(t, 1, stats.SkippedTooOld, "the old video is skipped by the recency bound") + require.Len(t, sink.delivered, 1) + require.Equal(t, "id-recent", sink.delivered[0].VideoID) + require.Len(t, st.upserted, 2, "both videos are still discovered and listed") +} + +// TestRunOnce_AutoMode_OldVideoRequestedBypassesWindow: an explicit manual +// request (summarize_requested) overrides the recency bound even in auto mode. +func TestRunOnce_AutoMode_OldVideoRequestedBypassesWindow(t *testing.T) { + base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + src := &fakeSource{ + subs: []domain.Subscription{sub("chan1", "Channel One")}, + videos: map[string][]domain.Video{"chan1": {vidAt("old", "Old", base.Add(-30*24*time.Hour))}}, + } + st := &fakeStore{seen: map[string]bool{}, auto: true, requested: map[string]bool{"id-old": true}} + sink := &recordingSink{} + eng := usecase.NewEngine(src, fakeSummarizer{}, sink) + r := runner.New(src, st, eng, testUser, quietLogger(), + runner.WithAutoWindow(7*24*time.Hour), runner.WithClock(func() time.Time { return base })) + + stats, err := r.RunOnce(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, stats.Summarized, "a requested old video is summarized despite the window") + require.Equal(t, 0, stats.SkippedTooOld) + require.Len(t, sink.delivered, 1) +} + +// TestRunOnce_AutoWindowZero_SummarizesOld: a zero window disables the bound — +// the pre-recency behaviour (summarize every unseen video) is preserved. +func TestRunOnce_AutoWindowZero_SummarizesOld(t *testing.T) { + base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + src := &fakeSource{ + subs: []domain.Subscription{sub("chan1", "Channel One")}, + videos: map[string][]domain.Video{"chan1": {vidAt("old", "Old", base.Add(-365*24*time.Hour))}}, + } + st := &fakeStore{seen: map[string]bool{}, auto: true} + sink := &recordingSink{} + eng := usecase.NewEngine(src, fakeSummarizer{}, sink) + r := runner.New(src, st, eng, testUser, quietLogger(), + runner.WithClock(func() time.Time { return base })) // no WithAutoWindow → 0 + + stats, err := r.RunOnce(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, stats.Summarized, "window disabled → old video summarized") + require.Equal(t, 0, stats.SkippedTooOld) +} + +// TestRunOnce_AutoMode_UndatedVideoSummarized: a video with no published_at +// cannot be aged out — it is processed, not silently stranded. +func TestRunOnce_AutoMode_UndatedVideoSummarized(t *testing.T) { + base := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC) + src := &fakeSource{ + subs: []domain.Subscription{sub("chan1", "Channel One")}, + videos: map[string][]domain.Video{"chan1": {vid("undated", "Undated")}}, // zero PublishedAt + } + st := &fakeStore{seen: map[string]bool{}, auto: true} + sink := &recordingSink{} + eng := usecase.NewEngine(src, fakeSummarizer{}, sink) + r := runner.New(src, st, eng, testUser, quietLogger(), + runner.WithAutoWindow(7*24*time.Hour), runner.WithClock(func() time.Time { return base })) + + stats, err := r.RunOnce(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, stats.Summarized, "an undated video is processed, not aged out") + require.Equal(t, 0, stats.SkippedTooOld) +} + // noFetchSource fails the test if a transcript fetch happens — used to prove the // runner skips a rate-limited video before touching the caption endpoint. type noFetchSource struct{ *fakeSource } diff --git a/internal/web/views.templ b/internal/web/views.templ index 70b2ac3..398c3bb 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -411,9 +411,10 @@ templ AccountPage(displayName, email string, conns []store.Connection, autoSumma

Summarization

- Automatic summarizes every new video as it is discovered. Manual lets you - pick which videos to summarize — new videos appear in your list with a - Summarize button. + Automatic summarizes new videos from about the last week as they are + discovered. Older videos stay browsable — summarize them on demand. + Manual lets you pick which videos to summarize — every new video appears + in your list with a Summarize button.

@summarizeModeControl(autoSummarize)
diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 7967bdd..ec7134f 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -1521,7 +1521,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "

Summarization

Automatic summarizes every new video as it is discovered. Manual lets you pick which videos to summarize — new videos appear in your list with a Summarize button.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "

Summarization

Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1541,7 +1541,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var66 string templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 424, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 425, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { @@ -1559,7 +1559,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var67 string templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 430, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 431, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) if templ_7745c5c3_Err != nil { @@ -1572,7 +1572,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var68 string templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 432, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 433, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { @@ -1610,7 +1610,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var69 string templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 447, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 448, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) if templ_7745c5c3_Err != nil { @@ -1628,7 +1628,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var70 string templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 449, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 450, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { @@ -1646,7 +1646,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var71 string templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 451, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 452, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { @@ -1659,7 +1659,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var72 string templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 453, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 454, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { @@ -1672,7 +1672,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var73 templ.SafeURL templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 454, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 455, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { @@ -1740,7 +1740,7 @@ func summarizeModeControl(auto bool) templ.Component { var templ_7745c5c3_Var75 string templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 491, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 492, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) if templ_7745c5c3_Err != nil { @@ -1753,7 +1753,7 @@ func summarizeModeControl(auto bool) templ.Component { var templ_7745c5c3_Var76 string templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 499, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 500, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76) if templ_7745c5c3_Err != nil { @@ -1766,7 +1766,7 @@ func summarizeModeControl(auto bool) templ.Component { var templ_7745c5c3_Var77 string templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 500, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 501, Col: 79} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) if templ_7745c5c3_Err != nil { @@ -1812,7 +1812,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { var templ_7745c5c3_Var79 templ.SafeURL templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 514, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 515, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) if templ_7745c5c3_Err != nil { @@ -1825,7 +1825,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { var templ_7745c5c3_Var80 string templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 515, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 516, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80) if templ_7745c5c3_Err != nil { @@ -1848,7 +1848,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { var templ_7745c5c3_Var82 string templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(v) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 523, Col: 13} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 524, Col: 13} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82) if templ_7745c5c3_Err != nil { @@ -1874,7 +1874,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { var templ_7745c5c3_Var84 string templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(active[v])) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 525, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 526, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84) if templ_7745c5c3_Err != nil { @@ -1888,7 +1888,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { var templ_7745c5c3_Var85 string templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(v)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 528, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 529, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) if templ_7745c5c3_Err != nil { @@ -1898,7 +1898,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { var templ_7745c5c3_Var86 string templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(v)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 530, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 531, Col: 21} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) if templ_7745c5c3_Err != nil {