feat(web): pipeline stats bar, summarized-first sort, Try now button for rate-limited videos
Three UX improvements for the pending-transcript state:
1. Summarized videos sort to top (ORDER BY (s.id IS NOT NULL) DESC, seen_at DESC)
so completed summaries are always immediately visible without filtering.
ListVideos default limit raised from 50 to 500 to show the full backlog.
2. Pipeline stats bar above the video list: '2 summarized · 256 fetching soon · 12
no captions' — computed from the unfiltered row set, hidden when everything is
summarized.
3. 'Try now' button on rate-limited cards replaces the passive 'Retrying later'
chip. POST /v/{id}/retry-now clears rate_limited_at then calls ProcessVideo
through the shared globalFetchGate — same rate limiting as the scheduler, safe
under concurrent use.
This commit is contained in:
@@ -38,6 +38,10 @@ type Store interface {
|
||||
// the most recent discovery pass, shown on the account page as a warning.
|
||||
ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)
|
||||
|
||||
// SetTranscriptStatus clears or updates a video's transcript backoff state.
|
||||
// Used by handleRetryNow to clear rate_limited_at before immediate processing.
|
||||
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) 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
|
||||
// registration gate stamps for every authenticated request.
|
||||
@@ -113,6 +117,7 @@ func (a *App) Router() http.Handler {
|
||||
app.HandleFunc("GET /v/{videoId}", a.handleDetail)
|
||||
app.HandleFunc("POST /v/{videoId}/action", a.handleAction)
|
||||
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
|
||||
app.HandleFunc("GET /v/{videoId}/status", a.handleStatus)
|
||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||
app.HandleFunc("POST /register", a.handleRegister)
|
||||
@@ -170,12 +175,13 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
OnlySummarized: q.Get("summarized") == "1",
|
||||
}
|
||||
|
||||
rows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
||||
allRows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "list videos", err)
|
||||
return
|
||||
}
|
||||
rows = f.apply(rows)
|
||||
stats := pipelineStats(allRows)
|
||||
rows := f.apply(allRows)
|
||||
|
||||
// hasConnected drives the empty state: a fresh account with a connection but
|
||||
// no `tapir run` yet has zero rows, and we want it to read "connected, run
|
||||
@@ -194,7 +200,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, summaryList(rows, hasConnected))
|
||||
return
|
||||
}
|
||||
a.render(w, r, ListPage(rows, f, takeFlash(w, r), hasConnected))
|
||||
a.render(w, r, ListPage(rows, f, stats, takeFlash(w, r), hasConnected))
|
||||
}
|
||||
|
||||
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
||||
@@ -302,6 +308,40 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, VideoCard(*row))
|
||||
}
|
||||
|
||||
// handleRetryNow handles the "Try now" button on rate-limited video cards. It
|
||||
// clears the rate_limited_at backoff so the scheduler won't skip the video, then
|
||||
// triggers an immediate ProcessVideo — same background path as handleRequestSummarize.
|
||||
// The rate gate (globalFetchGate) still applies, so this is safe under concurrent use.
|
||||
func (a *App) handleRetryNow(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
videoID := r.PathValue("videoId")
|
||||
|
||||
// Clear the backoff so the scheduler won't skip this video on the next pass.
|
||||
if err := a.Store.SetTranscriptStatus(r.Context(), userID, videoID, "none"); err != nil {
|
||||
a.serverError(w, r, "clear rate limit", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !isHTMX(r) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "get video", err)
|
||||
return
|
||||
}
|
||||
if a.Processor != nil {
|
||||
a.startProcessing(userID, videoID)
|
||||
a.render(w, r, processingCard(*row))
|
||||
return
|
||||
}
|
||||
a.render(w, r, VideoCard(*row))
|
||||
}
|
||||
|
||||
// startProcessing marks a video in-flight and summarizes it in the background.
|
||||
// The goroutine uses a detached context — not the request's, which is cancelled
|
||||
// when the handler returns — and clears the in-flight mark on completion. On
|
||||
|
||||
Reference in New Issue
Block a user