feat(runner): bound auto-summarize to a recency window
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+4
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ 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) {
|
||||
@@ -60,6 +63,7 @@ func TestLoad_ParsesValues(t *testing.T) {
|
||||
"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) {
|
||||
|
||||
@@ -91,6 +91,7 @@ type Runner struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -411,9 +411,10 @@ templ AccountPage(displayName, email string, conns []store.Connection, autoSumma
|
||||
<section>
|
||||
<h2>Summarization</h2>
|
||||
<p class="muted">
|
||||
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.
|
||||
</p>
|
||||
@summarizeModeControl(autoSummarize)
|
||||
</section>
|
||||
|
||||
+18
-18
@@ -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, "</dl><section><h2>Summarization</h2><p class=\"muted\">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.</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</dl><section><h2>Summarization</h2><p class=\"muted\">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.</p>")
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user