feat(domain): add SourceRateLimited transcript source

429 from the caption endpoint means the IP is rate-limited (retry later),
not that the video has no captions. Distinguishing it from SourceNone is the
prerequisite for the runner's backoff/retry logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:45:29 +02:00
co-authored by Claude Opus 4.8
parent 61d4d5bc4a
commit 27aa319f1d
6 changed files with 26 additions and 7 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+6
View File
@@ -18,6 +18,12 @@ type TranscriptSource string
const ( const (
SourceCaptions TranscriptSource = "captions" SourceCaptions TranscriptSource = "captions"
SourceNone TranscriptSource = "none" SourceNone TranscriptSource = "none"
// SourceRateLimited records that the caption endpoint returned HTTP 429.
// Unlike SourceNone (a permanent absence), this is a transient "retry later":
// the IP is rate-limited, not the video caption-less. It carries no text
// (HasText is false), so the engine degrades the same as SourceNone, but the
// runner persists it distinctly to retry after a backoff window.
SourceRateLimited TranscriptSource = "rate_limited"
) )
// User is the Tapir-side profile. At Stage 0 there is exactly one. // User is the Tapir-side profile. At Stage 0 there is exactly one.
+5
View File
@@ -535,6 +535,11 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
.empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); } .empty { text-align: center; color: var(--muted); padding: var(--s5) var(--s4); border: 1px dashed var(--line); border-radius: var(--radius); background: var(--card); }
.empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); } .empty strong { display: block; color: var(--fg); font-size: 1.05rem; margin-bottom: var(--s2); }
.empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; } .empty code { background: var(--accent-weak); color: var(--accent); padding: .1rem .35rem; border-radius: .3rem; }
.empty p { margin: var(--s3) 0 0; }
/* connected-but-empty: a distinct accent callout, not a muted blank state, so a
fresh account knows the next step is to run tapir, not "something is broken". */
.empty-connected { border-style: solid; border-color: var(--accent); background: var(--accent-weak); color: var(--fg); }
.empty-connected strong { color: var(--accent); }
/* flash / notification banner */ /* flash / notification banner */
.flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); } .flash { padding: var(--s2) var(--s3); border-radius: var(--radius); margin-bottom: var(--s4); font-size: .92rem; border: 1px solid var(--line); }
+15 -7
View File
@@ -79,12 +79,12 @@ templ flashBanner(code string) {
// #summary-list region; a non-HTMX request renders the whole page. flash carries // #summary-list region; a non-HTMX request renders the whole page. flash carries
// a one-shot notification (e.g. "connected", "registered") surfaced on arrival // a one-shot notification (e.g. "connected", "registered") surfaced on arrival
// after a POST→redirect. // after a POST→redirect.
templ ListPage(rows []store.SummaryRow, f Filter, flash string) { templ ListPage(rows []store.SummaryRow, f Filter, flash string, hasConnected bool) {
@Layout("Tapir — Summaries") { @Layout("Tapir — Summaries") {
@flashBanner(flash) @flashBanner(flash)
@filterForm(f) @filterForm(f)
<div id="summary-list"> <div id="summary-list">
@summaryList(rows) @summaryList(rows, hasConnected)
</div> </div>
} }
} }
@@ -110,12 +110,20 @@ templ filterForm(f Filter) {
// summaryList is the swappable list fragment: one card per video (summarized or // summaryList is the swappable list fragment: one card per video (summarized or
// not). Cards reflow to a single column on mobile; an empty list shows a friendly // not). Cards reflow to a single column on mobile; an empty list shows a friendly
// first-run state instead of a blank table. // first-run state instead of a blank table.
templ summaryList(rows []store.SummaryRow) { templ summaryList(rows []store.SummaryRow, hasConnected bool) {
if len(rows) == 0 { if len(rows) == 0 {
<div class="empty"> if hasConnected {
<strong>No videos yet</strong> <div class="empty empty-connected">
<span>Videos appear here as your subscriptions are processed run <code>tapir run</code> to fetch them. In manual mode, use the Summarize button to queue one.</span> <strong>Your YouTube account is connected!</strong>
</div> <span>Run <code>tapir run</code> to discover your subscriptions. Videos will appear here once discovered. In manual mode, each new video gets a Summarize button.</span>
</div>
} else {
<div class="empty">
<strong>No videos yet</strong>
<span>Connect your YouTube account to get started.</span>
<p><a class="btn" href="/oauth/youtube/connect">Connect YouTube</a></p>
</div>
}
} else { } else {
<ul class="cards"> <ul class="cards">
for _, r := range rows { for _, r := range rows {