From f1e973990052630dc07e3935d9305c21a96187c6 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 6 Jun 2026 10:09:52 +0200 Subject: [PATCH] feat(store,runner,web): channel unavailability notice (migration 013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTube channels that 404 on playlist discovery (deleted/private) are now: 1. Wrapped in domain.ErrChannelUnavailable by the YouTube adapter (instead of a generic error), so the runner can identify them without string-matching. 2. Stored per-user in channel_errors (migration 013, RLS-guarded) via runner's new UpsertChannelError path — removed from the generic Errors counter, counted separately as ChannelUnavailable. 3. Shown on the account page under "Unavailable channels" with name, chip-warn badge, and first-seen date, so users know why some subscribed channels produce no videos. --- internal/adapters/store/channel_errors.go | 59 +++ internal/adapters/store/migrate_test.go | 10 +- .../migrations/013_channel_errors.down.sql | 1 + .../migrations/013_channel_errors.up.sql | 22 ++ internal/adapters/youtube/youtube.go | 9 + internal/domain/domain.go | 17 +- internal/runner/runner.go | 20 +- internal/runner/runner_test.go | 2 + internal/web/account.go | 7 +- internal/web/handlers.go | 3 + internal/web/view.go | 6 + internal/web/views.templ | 21 +- internal/web/views_templ.go | 364 ++++++++++-------- 13 files changed, 380 insertions(+), 161 deletions(-) create mode 100644 internal/adapters/store/channel_errors.go create mode 100644 internal/adapters/store/migrations/013_channel_errors.down.sql create mode 100644 internal/adapters/store/migrations/013_channel_errors.up.sql 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) ") + 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 }