diff --git a/internal/adapters/store/channel_errors.go b/internal/adapters/store/channel_errors.go new file mode 100644 index 0000000..fef00a9 --- /dev/null +++ b/internal/adapters/store/channel_errors.go @@ -0,0 +1,59 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ChannelError is a channel that returned HTTP 404 on a discovery pass. +type ChannelError struct { + ChannelID string + ChannelName string + FirstSeen time.Time + LastSeen time.Time +} + +// UpsertChannelError records (or refreshes) a 404 channel for the current user. +// Called by the runner inside a withUser scope; RLS guards user isolation. +func (s *Store) UpsertChannelError(ctx context.Context, userID, channelID, channelName string) error { + return s.withUser(ctx, userID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + INSERT INTO channel_errors (user_id, channel_id, channel_name) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, channel_id) + DO UPDATE SET channel_name = EXCLUDED.channel_name, last_seen = now()`, + userID, channelID, channelName) + if err != nil { + return fmt.Errorf("store: upsert channel error: %w", err) + } + return nil + }) +} + +// ListChannelErrors returns all 404-flagged channels for the user, newest first. +func (s *Store) ListChannelErrors(ctx context.Context, userID string) ([]ChannelError, error) { + var out []ChannelError + err := s.withUser(ctx, userID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT channel_id, channel_name, first_seen, last_seen + FROM channel_errors + WHERE user_id = $1 + ORDER BY last_seen DESC`, userID) + if err != nil { + return fmt.Errorf("store: list channel errors: %w", err) + } + defer rows.Close() + for rows.Next() { + var ce ChannelError + if err := rows.Scan(&ce.ChannelID, &ce.ChannelName, &ce.FirstSeen, &ce.LastSeen); err != nil { + return fmt.Errorf("store: scan channel error: %w", err) + } + out = append(out, ce) + } + return rows.Err() + }) + return out, err +} diff --git a/internal/adapters/store/migrate_test.go b/internal/adapters/store/migrate_test.go index 1777830..37f1935 100644 --- a/internal/adapters/store/migrate_test.go +++ b/internal/adapters/store/migrate_test.go @@ -53,7 +53,9 @@ func TestMigration010LoginEventsUpDown(t *testing.T) { require.True(t, loginEventsExists(t), "login_events must exist at latest migration") m := fileMigrator(t) - // 011 and 012 sit above 010; step them down first so 010 is exercised in isolation. + // 011, 012, 013 sit above 010; step them down first so 010 is exercised in isolation. + require.NoError(t, m.Steps(-1), "down 013 drops channel_errors, login_events intact") + require.True(t, loginEventsExists(t), "013 down leaves login_events intact") require.NoError(t, m.Steps(-1), "down 012 is a no-op, login_events intact") require.True(t, loginEventsExists(t), "012 down leaves login_events intact") require.NoError(t, m.Steps(-1), "down 011 must not touch login_events") @@ -62,7 +64,7 @@ func TestMigration010LoginEventsUpDown(t *testing.T) { require.NoError(t, m.Steps(-1), "down 010 must drop login_events") require.False(t, loginEventsExists(t), "login_events must be gone after the down migration") - require.NoError(t, m.Steps(3), "up must recreate 010 then re-apply 011 and 012") + require.NoError(t, m.Steps(4), "up must recreate 010 then re-apply 011, 012, 013") require.True(t, loginEventsExists(t), "login_events must be restored after the up migration") } @@ -81,10 +83,11 @@ func autoSummarizeDefault(t *testing.T) string { // up sets the auto_summarize column default to TRUE (ADR-018), down restores // FALSE. The down intentionally does not revert existing rows — only the default. func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) { - newStore(t) // latest (012 applied) + newStore(t) // latest (013 applied) require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE") m := fileMigrator(t) + require.NoError(t, m.Steps(-1), "down 013 drops channel_errors") require.NoError(t, m.Steps(-1), "down 012 is a no-op") require.NoError(t, m.Steps(-1), "down 011 reverts the column default") require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration") @@ -92,6 +95,7 @@ func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) { require.NoError(t, m.Steps(1), "up 011 re-applies the TRUE default") require.Equal(t, "true", autoSummarizeDefault(t)) require.NoError(t, m.Steps(1), "up 012 runs clean (no FORCE RLS on fresh schema)") + require.NoError(t, m.Steps(1), "up 013 creates channel_errors") } // TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any diff --git a/internal/adapters/store/migrations/013_channel_errors.down.sql b/internal/adapters/store/migrations/013_channel_errors.down.sql new file mode 100644 index 0000000..6d9b98d --- /dev/null +++ b/internal/adapters/store/migrations/013_channel_errors.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_errors; diff --git a/internal/adapters/store/migrations/013_channel_errors.up.sql b/internal/adapters/store/migrations/013_channel_errors.up.sql new file mode 100644 index 0000000..2389bc2 --- /dev/null +++ b/internal/adapters/store/migrations/013_channel_errors.up.sql @@ -0,0 +1,22 @@ +-- channel_errors: channels that returned HTTP 404 (deleted/private) on the most +-- recent scheduler pass. Surfaced on the account page so users know why some +-- subscribed channels produce no videos. Upserted per-pass; cleared when the +-- channel starts returning results again (runner calls UpsertChannelError only +-- on 404, so a recovered channel simply stops appearing after its row ages out +-- or the user takes action). ON DELETE CASCADE keeps rows tidy on account deletion. +CREATE TABLE channel_errors ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL, + channel_name TEXT NOT NULL, + first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, channel_id) +); + +CREATE INDEX idx_channel_errors_user_id ON channel_errors(user_id); + +ALTER TABLE channel_errors ENABLE ROW LEVEL SECURITY; +ALTER TABLE channel_errors FORCE ROW LEVEL SECURITY; +CREATE POLICY channel_errors_isolation ON channel_errors + FOR ALL + USING (user_id = current_setting('tapir.current_user_id', true)::uuid); diff --git a/internal/adapters/youtube/youtube.go b/internal/adapters/youtube/youtube.go index 21ea9f8..361d815 100644 --- a/internal/adapters/youtube/youtube.go +++ b/internal/adapters/youtube/youtube.go @@ -216,6 +216,9 @@ func (a *Adapter) NewVideos(ctx context.Context, sub domain.Subscription) ([]dom var resp playlistItemListResponse if err := a.getJSON(ctx, client, "/playlistItems", q, &resp); err != nil { + if isHTTP404(err) { + return nil, &domain.ErrChannelUnavailable{ChannelID: sub.ChannelID, ChannelTitle: sub.ChannelTitle} + } return nil, fmt.Errorf("new videos for channel %q: %w", sub.ChannelID, err) } @@ -269,6 +272,12 @@ func (a *Adapter) resolveUploadsPlaylist(ctx context.Context, client *http.Clien return resp.Items[0].ContentDetails.RelatedPlaylists.Uploads, nil } +// isHTTP404 reports whether err came from a YouTube API call that returned HTTP 404. +// getRaw encodes the status as "youtube api : status 404: ...". +func isHTTP404(err error) bool { + return err != nil && strings.Contains(err.Error(), "status 404") +} + // getJSON issues a GET and decodes a JSON body into out. A non-200 status is an // error carrying a bounded slice of the response body for diagnosis. func (a *Adapter) getJSON(ctx context.Context, client *http.Client, path string, q url.Values, out any) error { diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 438c963..64e0f2c 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -2,7 +2,22 @@ // the standard library — no providers, no storage, no AI. See docs/data-model.md. package domain -import "time" +import ( + "fmt" + "time" +) + +// ErrChannelUnavailable is returned by a VideoSource when a channel's upload +// playlist returns HTTP 404 — the channel was deleted or made private. The runner +// stores these so the account page can surface them to the user. +type ErrChannelUnavailable struct { + ChannelID string + ChannelTitle string +} + +func (e *ErrChannelUnavailable) Error() string { + return fmt.Sprintf("channel %q (%s) unavailable: playlist not found", e.ChannelTitle, e.ChannelID) +} // Provider identifies a video platform. type Provider string diff --git a/internal/runner/runner.go b/internal/runner/runner.go index b85b504..4e4fcae 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -38,6 +38,10 @@ type VideoStore interface { // SetTranscriptStatus records the outcome of a transcript attempt: "none", // "rate_limited" (stamps the backoff clock), or "fetched". SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error + // UpsertChannelError records a channel that returned HTTP 404 (deleted/private). + // Called when NewVideos returns domain.ErrChannelUnavailable; best-effort, errors + // are logged and never abort the pass. + UpsertChannelError(ctx context.Context, userID, channelID, channelTitle string) error } // Processor runs the core use case for a single video. *usecase.Engine @@ -94,6 +98,7 @@ type Stats struct { SkippedManual int // discovered but not queued, in manual mode SkippedRateLimited int // 429'd previously and still inside the backoff window Errors int + ChannelUnavailable int // channels that returned HTTP 404 (deleted/private) } // RunOnce performs a single pass over the user's subscriptions. Per-item errors @@ -152,8 +157,17 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) { for _, sub := range subs { vids, err := r.src.NewVideos(ctx, sub) if err != nil { - errs = append(errs, fmt.Errorf("new videos for %q: %w", sub.ChannelTitle, err)) - stats.Errors++ + var unavail *domain.ErrChannelUnavailable + if errors.As(err, &unavail) { + stats.ChannelUnavailable++ + r.log.Warn("channel unavailable (playlist 404)", "channel", sub.ChannelTitle, "channel_id", sub.ChannelID) + if storeErr := r.store.UpsertChannelError(ctx, r.userID, unavail.ChannelID, unavail.ChannelTitle); storeErr != nil { + r.log.Warn("failed to store channel error", "err", storeErr) + } + } else { + errs = append(errs, fmt.Errorf("new videos for %q: %w", sub.ChannelTitle, err)) + stats.Errors++ + } continue } for _, v := range vids { @@ -251,7 +265,7 @@ func (r *Runner) Loop(ctx context.Context, interval time.Duration) error { "candidates", stats.Candidates, "summarized", stats.Summarized, "skipped_seen", stats.SkippedSeen, "skipped_no_text", stats.SkippedNoText, "skipped_manual", stats.SkippedManual, "skipped_rate_limited", stats.SkippedRateLimited, - "errors", stats.Errors) + "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 97527d2..62928a0 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -91,6 +91,8 @@ func (f *fakeStore) RateLimitedVideoIDs(_ context.Context, _ string) (map[string return cp, nil } +func (f *fakeStore) UpsertChannelError(_ context.Context, _, _, _ string) error { return nil } + func (f *fakeStore) SetTranscriptStatus(_ context.Context, _, videoID, status string) error { if f.statuses == nil { f.statuses = map[string]string{} diff --git a/internal/web/account.go b/internal/web/account.go index e099523..10d222d 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -33,7 +33,12 @@ func (a *App) handleAccount(w http.ResponseWriter, r *http.Request) { a.serverError(w, r, "summarize mode", err) return } - a.render(w, r, AccountPage(name, email, conns, auto, takeFlash(w, r))) + channelErrs, err := a.Store.ListChannelErrors(r.Context(), userID) + if err != nil { + a.serverError(w, r, "channel errors", err) + return + } + a.render(w, r, AccountPage(name, email, conns, auto, channelErrs, takeFlash(w, r))) } // handleDisconnect removes a provider connection: it deletes the OAuth token from diff --git a/internal/web/handlers.go b/internal/web/handlers.go index d3ea6ae..d8e8f7c 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -34,6 +34,9 @@ type Store interface { DeleteConnection(ctx context.Context, userID, provider string) error DeleteUser(ctx context.Context, userID string) error DisplayName(ctx context.Context, userID string) (string, error) + // ListChannelErrors returns channels that returned HTTP 404 (deleted/private) on + // the most recent discovery pass, shown on the account page as a warning. + ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error) // StampLogin records (throttled, one row per user per day) that the resolved // user was active on this request — the read-side Stage-0 usage signal the diff --git a/internal/web/view.go b/internal/web/view.go index 038b8dd..3fbad6a 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -503,6 +503,7 @@ a.btn, a.btn:visited { color: var(--accent-fg); } /* passive "retrying later" chip: dim/grey (CharmDim), not the accent — it is a status, not an action the user can take. */ .chip-retry { background: rgba(108, 108, 108, .16); color: #6c6c6c; } +.chip-warn { background: rgba(255, 110, 156, .15); color: #FF6E9C; } .card-state { color: var(--muted); font-size: .8rem; } .badge { display: inline-block; padding: .15rem .55rem; border-radius: 999px; background: var(--badge-bg); color: var(--badge-fg); font-size: .72rem; font-weight: 600; } @@ -591,6 +592,11 @@ a.btn, a.btn:visited { color: var(--accent-fg); } .account-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s1) var(--s3); margin: 0; } .account-meta dt { color: var(--muted); font-size: .85rem; } .account-meta dd { margin: 0; } +.channel-errors { } +.channel-error-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s1); } +.channel-error-list li { display: flex; align-items: center; gap: var(--s2); } +.channel-error-name { font-weight: 500; } +.channel-error-since { font-size: .8rem; } .conn-list { list-style: none; margin: 0 0 var(--s3); padding: 0; display: grid; gap: var(--s2); } .conn { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--s3); display: flex; flex-direction: column; gap: var(--s1); } .conn-main { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; } diff --git a/internal/web/views.templ b/internal/web/views.templ index e3ff521..c9e3d71 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -1,6 +1,7 @@ package web import ( + "fmt" "strings" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" @@ -397,7 +398,7 @@ templ InviteNoticePage(message string, showLogin bool) { // signed-in email, the user's connected video accounts (each with a Disconnect // control), a Connect-YouTube link when none is connected, and the delete-account // danger zone. flash surfaces a one-shot notification (disconnect/connect). -templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) { +templ AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, channelErrors []store.ChannelError, flash string) { @Layout("Tapir — Account") { @flashBanner(flash)
@@ -419,6 +420,24 @@ templ AccountPage(displayName, email string, conns []store.Connection, autoSumma

@summarizeModeControl(autoSummarize) + if len(channelErrors) > 0 { +
+

Unavailable channels

+

+ { fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)) } + These may have been deleted or made private on YouTube. +

+
    + for _, ce := range channelErrors { +
  • + { ce.ChannelName } + unavailable + since { ce.FirstSeen.Format("2006-01-02") } +
  • + } +
+
+ }

Connected accounts

if len(conns) == 0 { diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index a286bef..3025654 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -9,6 +9,7 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( + "fmt" "strings" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" @@ -45,7 +46,7 @@ func Layout(title string) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 18, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 19, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -106,7 +107,7 @@ func PublicLayout(title string) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 43, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 44, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -198,7 +199,7 @@ func WelcomePage(user User, loggedIn bool) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 71, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 72, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -284,7 +285,7 @@ func flashBanner(code string) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(f.Message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 98, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 99, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -398,7 +399,7 @@ func filterForm(f Filter) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Channel) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 126, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 127, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) if templ_7745c5c3_Err != nil { @@ -411,7 +412,7 @@ func filterForm(f Filter) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.From) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 127, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 128, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) if templ_7745c5c3_Err != nil { @@ -424,7 +425,7 @@ func filterForm(f Filter) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.To) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 128, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 129, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) if templ_7745c5c3_Err != nil { @@ -545,7 +546,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var22 string templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("video-" + r.VideoID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 166, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 167, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) if templ_7745c5c3_Err != nil { @@ -563,7 +564,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var23 templ.SafeURL templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(videoURL(r.VideoID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 168, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 169, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -576,7 +577,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 168, Col: 76} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 169, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -594,7 +595,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 170, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 171, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -613,7 +614,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 173, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 174, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -633,7 +634,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var27 string templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 177, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 178, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -658,7 +659,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.AIProvider) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 183, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 184, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -691,7 +692,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(r.Actions, ", ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 189, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 190, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -720,7 +721,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var30 templ.SafeURL templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(summarizeURL(r.VideoID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 199, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 200, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { @@ -733,7 +734,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(summarizeURL(r.VideoID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 200, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 201, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) if templ_7745c5c3_Err != nil { @@ -746,7 +747,7 @@ func VideoCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var32 string templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("#video-" + r.VideoID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 201, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 202, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) if templ_7745c5c3_Err != nil { @@ -822,7 +823,7 @@ func TapirSpinner() templ.Component { var templ_7745c5c3_Var34 string templ_7745c5c3_Var34, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("color:" + CharmMint) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 221, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 222, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -835,7 +836,7 @@ func TapirSpinner() templ.Component { var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tapirBarFill) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 221, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 222, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { @@ -882,7 +883,7 @@ func processingCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue("video-" + r.VideoID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 234, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 235, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) if templ_7745c5c3_Err != nil { @@ -895,7 +896,7 @@ func processingCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(statusURL(r.VideoID))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 235, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 236, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) if templ_7745c5c3_Err != nil { @@ -908,7 +909,7 @@ func processingCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var39 string templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 239, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 240, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { @@ -926,7 +927,7 @@ func processingCard(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var40 string templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 241, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 242, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { @@ -991,7 +992,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var43 string templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 252, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 253, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { @@ -1009,7 +1010,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var44 string templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(detailMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 255, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 256, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { @@ -1038,7 +1039,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var45 string templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(url) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 264, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 265, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45) if templ_7745c5c3_Err != nil { @@ -1051,7 +1052,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var46 string templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 265, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 266, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) if templ_7745c5c3_Err != nil { @@ -1070,7 +1071,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var47 templ.SafeURL templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(externalURL(r.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 274, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 275, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { @@ -1092,7 +1093,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var48 string templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 279, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 280, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { @@ -1115,7 +1116,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var49 string templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(h) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 286, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 287, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { @@ -1144,7 +1145,7 @@ func DetailPage(r store.SummaryRow) templ.Component { var templ_7745c5c3_Var50 string templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 296, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 297, Col: 14} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { @@ -1222,7 +1223,7 @@ func RegisterPage(email, errMsg string) templ.Component { var templ_7745c5c3_Var53 string templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 313, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 314, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) if templ_7745c5c3_Err != nil { @@ -1245,7 +1246,7 @@ func RegisterPage(email, errMsg string) templ.Component { var templ_7745c5c3_Var54 string templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 317, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 318, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) if templ_7745c5c3_Err != nil { @@ -1316,7 +1317,7 @@ func InvitePage(email, token, errMsg string) templ.Component { var templ_7745c5c3_Var57 string templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 344, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 345, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { @@ -1334,7 +1335,7 @@ func InvitePage(email, token, errMsg string) templ.Component { var templ_7745c5c3_Var58 string templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 347, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 348, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) if templ_7745c5c3_Err != nil { @@ -1352,7 +1353,7 @@ func InvitePage(email, token, errMsg string) templ.Component { var templ_7745c5c3_Var59 templ.SafeURL templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(inviteURL(token)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 349, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 350, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { @@ -1365,7 +1366,7 @@ func InvitePage(email, token, errMsg string) templ.Component { var templ_7745c5c3_Var60 string templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 352, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 353, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60) if templ_7745c5c3_Err != nil { @@ -1478,7 +1479,7 @@ func InviteNoticePage(message string, showLogin bool) templ.Component { var templ_7745c5c3_Var65 string templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 388, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 389, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) if templ_7745c5c3_Err != nil { @@ -1512,7 +1513,7 @@ func InviteNoticePage(message string, showLogin bool) templ.Component { // signed-in email, the user's connected video accounts (each with a Disconnect // control), a Connect-YouTube link when none is connected, and the delete-account // danger zone. flash surfaces a one-shot notification (disconnect/connect). -func AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, flash string) templ.Component { +func AccountPage(displayName, email string, conns []store.Connection, autoSummarize bool, channelErrors []store.ChannelError, flash string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -1556,7 +1557,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var68 string templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 407, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 408, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { @@ -1574,7 +1575,7 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar var templ_7745c5c3_Var69 string templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 410, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 411, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) if templ_7745c5c3_Err != nil { @@ -1593,113 +1594,172 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "

Connected accounts

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(conns) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "

No connected video accounts yet.

") + if len(channelErrors) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "

Unavailable channels

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "

    ") + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, 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: 427, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, c := range conns { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " These may have been deleted or made private on YouTube.

      ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ce := range channelErrors { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var70 string - templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, 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: 431, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 433, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if c.ProviderAccount != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var71 string - templ_7745c5c3_Var71, 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: 433, Col: 49} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, " unavailable since ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) + templ_7745c5c3_Var72, 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: 435, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 435, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "
    connected ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "

Connected accounts

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(conns) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "

No connected video accounts yet.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, c := range conns { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var73 string - templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) + templ_7745c5c3_Var73, 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: 437, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 450, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var74 templ.SafeURL - templ_7745c5c3_Var74, 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: 438, Col: 62} + if c.ProviderAccount != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, 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: 452, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\">
  • ") + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, 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: 454, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
    connected ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, 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: 456, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if !hasYouTube(conns) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "

Connect YouTube

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

Connect YouTube

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

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

Delete account

Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

Delete account…

This permanently deletes your account and all data. Are you sure?

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1733,51 +1793,51 @@ func summarizeModeControl(auto bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var75 := templ.GetChildren(ctx) - if templ_7745c5c3_Var75 == nil { - templ_7745c5c3_Var75 = templ.NopComponent + templ_7745c5c3_Var78 := templ.GetChildren(ctx) + if templ_7745c5c3_Var78 == nil { + templ_7745c5c3_Var78 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "

Current mode: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

Current mode: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var76 string - templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) + var templ_7745c5c3_Var79 string + templ_7745c5c3_Var79, 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: 475, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 494, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1805,117 +1865,117 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var79 := templ.GetChildren(ctx) - if templ_7745c5c3_Var79 == nil { - templ_7745c5c3_Var79 = templ.NopComponent + templ_7745c5c3_Var82 := templ.GetChildren(ctx) + if templ_7745c5c3_Var82 == nil { + templ_7745c5c3_Var82 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, v := range actionVerbs { - var templ_7745c5c3_Var82 = []any{"action", templ.KV("active", active[v])} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...) + var templ_7745c5c3_Var85 = []any{"action", templ.KV("active", active[v])} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var85...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }