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.
447 lines
17 KiB
Go
447 lines
17 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
)
|
|
|
|
// Store is the read/write surface the web handlers depend on — a narrow port over
|
|
// the Postgres store (Clean Architecture: handlers depend on this interface, not
|
|
// the concrete *store.Store). *store.Store satisfies it; tests can substitute a
|
|
// fake without a database.
|
|
type Store interface {
|
|
ListVideos(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error)
|
|
GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
|
GetVideoRow(ctx context.Context, userID, videoID string) (*store.SummaryRow, error)
|
|
ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error)
|
|
SetAction(ctx context.Context, userID, videoID, action string) error
|
|
ClearAction(ctx context.Context, userID, videoID, action string) error
|
|
|
|
// Summarization mode: the per-user auto/manual toggle and the per-video
|
|
// manual queue (the "Summarize" button). The runner consumes the queue.
|
|
GetAutoSummarize(ctx context.Context, userID string) (bool, error)
|
|
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
|
|
RequestSummarize(ctx context.Context, userID, videoID string) error
|
|
|
|
// Account management (the /account page, disconnect, delete-account).
|
|
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
|
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)
|
|
|
|
// 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.
|
|
StampLogin(ctx context.Context, userID string) error
|
|
}
|
|
|
|
// SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
|
|
// satisfies it; account tests use a fake. The account handlers depend only on
|
|
// this narrow capability (not the read-side ports.SecretStore), mirroring how the
|
|
// connect flow depends on auth.TokenWriter for the write side.
|
|
type SecretRemover interface {
|
|
Delete(ref string) error
|
|
}
|
|
|
|
// App is the Stage-1 web surface: handlers over the store, gated by an Auth
|
|
// implementation (authentication) and a registration gate (which resolves the
|
|
// authenticated subject to its tapir user_id and stashes it per request). Every
|
|
// data handler scopes by that resolved id — CurrentUserID(r) — not by a single
|
|
// configured user (ADR-012, multi-user with enforced isolation).
|
|
type App struct {
|
|
Store Store
|
|
Identity Identity
|
|
Auth Auth
|
|
Log *slog.Logger
|
|
// Connect runs the web-initiated YouTube OAuth connect flow. Optional: when
|
|
// nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
|
|
// routes are not mounted.
|
|
Connect *ConnectHandler
|
|
// Secrets removes a user's OAuth tokens on disconnect / delete-account. The
|
|
// account routes require it; cmd/tapir wires the file-backed store.
|
|
Secrets SecretRemover
|
|
// Processor, when non-nil, summarizes a queued video immediately in a
|
|
// background goroutine (the "Summarize" button kicks it off). Nil = queue-only:
|
|
// the button flips the DB flag and the next `tapir run` does the work.
|
|
Processor Processor
|
|
// Processing tracks in-flight immediate summarizations so the status endpoint
|
|
// shows the animation until the summary lands. The zero value is ready to use.
|
|
Processing ProcessingSet
|
|
// Invitations validates and consumes email-invite tokens for the public
|
|
// /invite/{token} flow. Nil = the invite routes report "invalid" (the flow is
|
|
// effectively off). *store.Store satisfies it.
|
|
Invitations InvitationStore
|
|
// Dex creates the Dex local-password account when an invite is claimed. Nil =
|
|
// not in-cluster (dev): the submit handler degrades to a clear "deployed-only"
|
|
// message instead of creating an account. *dex.PasswordClient satisfies it.
|
|
Dex DexPasswordCreator
|
|
}
|
|
|
|
func (a *App) logger() *slog.Logger {
|
|
if a.Log != nil {
|
|
return a.Log
|
|
}
|
|
return slog.Default()
|
|
}
|
|
|
|
// Router wires the routes: /healthz (no auth) and /auth/* (the Auth impl's own
|
|
// endpoints) sit outside the guard; everything else is wrapped by
|
|
// Auth.Middleware. Go 1.22+ method+path patterns route directly.
|
|
func (a *App) Router() http.Handler {
|
|
root := http.NewServeMux()
|
|
root.HandleFunc("GET /healthz", a.handleHealthz)
|
|
root.HandleFunc("GET /welcome", a.handleWelcome)
|
|
root.Handle("GET /static/", staticHandler())
|
|
root.Handle("/auth/", a.Auth.Routes())
|
|
|
|
// Email invitation claim (public — the visitor has no Dex session yet, so this
|
|
// sits OUTSIDE Auth.Middleware). The token in the path is the capability.
|
|
root.HandleFunc("GET /invite/{token}", a.handleInviteForm)
|
|
root.HandleFunc("POST /invite/{token}", a.handleInviteSubmit)
|
|
|
|
app := http.NewServeMux()
|
|
app.HandleFunc("GET /{$}", a.handleList)
|
|
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)
|
|
|
|
// Account management: view connections, disconnect a provider, delete the
|
|
// account. Gated like every app route, so CurrentUserID is set.
|
|
app.HandleFunc("GET /account", a.handleAccount)
|
|
app.HandleFunc("POST /account/disconnect/{provider}", a.handleDisconnect)
|
|
app.HandleFunc("POST /account/delete", a.handleDeleteAccount)
|
|
app.HandleFunc("POST /account/summarize-mode", a.handleSummarizeMode)
|
|
|
|
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
|
|
// CurrentUserID is set and the connection binds to the authenticated user.
|
|
if a.Connect != nil {
|
|
app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect)
|
|
app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback)
|
|
}
|
|
|
|
// Two layers: Auth.Middleware requires a Dex session (you must be logged in);
|
|
// registrationGate requires a tapir user (else → /register) and stashes the
|
|
// resolved user_id. /register lives inside the auth guard but is exempt from
|
|
// the registration gate (you must be able to reach it before you have a user).
|
|
root.Handle("/", a.Auth.Middleware(a.registrationGate(app)))
|
|
return root
|
|
}
|
|
|
|
// handleWelcome renders the public landing page (/welcome). It is mounted outside
|
|
// Auth.Middleware, so it must not assume a session: CurrentUser peeks the cookie
|
|
// without redirecting and the page renders the logged-out or logged-in variant
|
|
// accordingly.
|
|
func (a *App) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := a.Auth.CurrentUser(r)
|
|
a.render(w, r, WelcomePage(user, ok))
|
|
}
|
|
|
|
// handleHealthz is the unauthenticated liveness/readiness probe.
|
|
func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
|
|
// handleList renders the summary list, applying the channel/date filters from the
|
|
// query string. An HTMX request gets only the table fragment so the filter form
|
|
// can swap #summary-list in place; a plain request gets the full page.
|
|
func (a *App) handleList(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := a.currentUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
f := Filter{
|
|
Channel: q.Get("channel"),
|
|
From: q.Get("from"),
|
|
To: q.Get("to"),
|
|
OnlySummarized: q.Get("summarized") == "1",
|
|
}
|
|
|
|
allRows, err := a.Store.ListVideos(r.Context(), userID, 0)
|
|
if err != nil {
|
|
a.serverError(w, r, "list videos", err)
|
|
return
|
|
}
|
|
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
|
|
// tapir" rather than "nothing here". Only needed when the list is empty.
|
|
hasConnected := false
|
|
if len(rows) == 0 {
|
|
conns, err := a.Store.ConnectionsForUser(r.Context(), userID)
|
|
if err != nil {
|
|
a.serverError(w, r, "connections for user", err)
|
|
return
|
|
}
|
|
hasConnected = len(conns) > 0
|
|
}
|
|
|
|
if isHTMX(r) {
|
|
a.render(w, r, summaryList(rows, hasConnected))
|
|
return
|
|
}
|
|
a.render(w, r, ListPage(rows, f, stats, takeFlash(w, r), hasConnected))
|
|
}
|
|
|
|
// handleDetail renders one summary in full (highlights, takeaways, action group).
|
|
func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := a.currentUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
videoID := r.PathValue("videoId")
|
|
row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
a.serverError(w, r, "get summary", err)
|
|
return
|
|
}
|
|
a.render(w, r, DetailPage(*row))
|
|
}
|
|
|
|
// handleAction toggles one action: re-clicking an active verb clears it, else it
|
|
// is set (the store enforces watched↔skipped exclusion atomically). It returns
|
|
// the refreshed button-group fragment for HTMX; without JS it redirects back to
|
|
// the detail page (POST→redirect→GET).
|
|
func (a *App) handleAction(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := a.currentUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
videoID := r.PathValue("videoId")
|
|
action := r.FormValue("action")
|
|
if !isActionVerb(action) {
|
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
|
|
if err != nil {
|
|
a.serverError(w, r, "read actions", err)
|
|
return
|
|
}
|
|
|
|
if actionSet(current[videoID])[action] {
|
|
err = a.Store.ClearAction(r.Context(), userID, videoID, action)
|
|
} else {
|
|
err = a.Store.SetAction(r.Context(), userID, videoID, action)
|
|
}
|
|
if err != nil {
|
|
a.serverError(w, r, "toggle action", err)
|
|
return
|
|
}
|
|
|
|
updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID})
|
|
if err != nil {
|
|
a.serverError(w, r, "read actions", err)
|
|
return
|
|
}
|
|
|
|
if isHTMX(r) {
|
|
a.render(w, r, ActionButtons(videoID, actionSet(updated[videoID])))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther)
|
|
}
|
|
|
|
// handleRequestSummarize handles the "Summarize" button. It always flips the DB
|
|
// flag (summarize_requested) so the work is durable. With a Processor wired it
|
|
// then summarizes immediately in the background and answers with the animated
|
|
// processing card that polls /status until done; without one (queue-only) it
|
|
// answers with the "Queued" card — the next `tapir run` does the work. Without
|
|
// JS it redirects back to the list (POST→redirect→GET).
|
|
func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := a.currentUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
videoID := r.PathValue("videoId")
|
|
|
|
err := a.Store.RequestSummarize(r.Context(), userID, videoID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
a.serverError(w, r, "request summarize", 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))
|
|
}
|
|
|
|
// 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
|
|
// error the DB flag stays set, so the video remains queued for the next
|
|
// `tapir run`; a successful Processor.ProcessVideo clears it itself.
|
|
func (a *App) startProcessing(userID, videoID string) {
|
|
key := processingKey(userID, videoID)
|
|
a.Processing.Add(key)
|
|
go func() {
|
|
defer a.Processing.Remove(key)
|
|
if err := a.Processor.ProcessVideo(context.Background(), userID, videoID); err != nil {
|
|
a.logger().Error("background summarize", "user", userID, "video", videoID, "err", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// handleStatus is the HTMX poll target for an in-flight summarization. It returns
|
|
// the card in its current state: the full summary card once the summary exists,
|
|
// otherwise the animated processing card while still in-flight (which keeps
|
|
// polling), or the queued/button card when neither holds. VideoCard carries no
|
|
// polling attributes, so HTMX stops polling once it swaps in.
|
|
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := a.currentUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
videoID := r.PathValue("videoId")
|
|
|
|
row, err := a.Store.GetVideoRow(r.Context(), userID, videoID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
a.serverError(w, r, "get video", err)
|
|
return
|
|
}
|
|
|
|
if row.Summarized || !a.Processing.Has(processingKey(userID, videoID)) {
|
|
a.render(w, r, VideoCard(*row))
|
|
return
|
|
}
|
|
a.render(w, r, processingCard(*row))
|
|
}
|
|
|
|
// handleSummarizeMode toggles the user's auto/manual summarization mode. The form
|
|
// submits the desired new value (enabled=true|false). For HTMX it returns the
|
|
// refreshed mode control; without JS it redirects back to the account page.
|
|
func (a *App) handleSummarizeMode(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := a.currentUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
enabled := r.FormValue("enabled") == "true"
|
|
if err := a.Store.SetAutoSummarize(r.Context(), userID, enabled); err != nil {
|
|
a.serverError(w, r, "set summarize mode", err)
|
|
return
|
|
}
|
|
if !isHTMX(r) {
|
|
http.Redirect(w, r, "/account", http.StatusSeeOther)
|
|
return
|
|
}
|
|
a.render(w, r, summarizeModeControl(enabled))
|
|
}
|
|
|
|
// currentUserID returns the tapir user_id the registration gate resolved for this
|
|
// request. Behind the gate it is always present; a miss means a handler was
|
|
// reached without scoping (a wiring bug), so it answers 500 and reports false.
|
|
func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
id, ok := CurrentUserID(r)
|
|
if !ok {
|
|
a.serverError(w, r, "current user", errNoCurrentUser)
|
|
}
|
|
return id, ok
|
|
}
|
|
|
|
// render writes a templ component as HTML. A render error is logged, not retried:
|
|
// headers may already be flushed, so there is nothing useful to send the client.
|
|
func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|
a.renderStatus(w, r, http.StatusOK, c)
|
|
}
|
|
|
|
// renderStatus writes a templ component as HTML with an explicit status code (the
|
|
// Content-Type must be set before WriteHeader, so this is the single place that
|
|
// orders them correctly).
|
|
func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if err := c.Render(r.Context(), w); err != nil {
|
|
a.logger().Error("render", "path", r.URL.Path, "err", err)
|
|
}
|
|
}
|
|
|
|
func (a *App) serverError(w http.ResponseWriter, r *http.Request, op string, err error) {
|
|
a.logger().Error("handler error", "op", op, "path", r.URL.Path, "err", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
|
|
// isHTMX reports whether the request came from HTMX, which sets HX-Request: true.
|
|
func isHTMX(r *http.Request) bool {
|
|
return r.Header.Get("HX-Request") == "true"
|
|
}
|