From 62acfee2ed10971ba535b739b58fb6fd4d7ea3f1 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 9 Jun 2026 21:54:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20paste-a-URL=20handler=20=E2=80=94?= =?UTF-8?q?=20add=20an=20arbitrary=20video=20+=20summarize=20(Feature=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /paste: parse the video id, fetch metadata via the VideoFetcher port (Data API, ungated), upsert a subscription-less row scoped to the user (idempotent = dedup), and — unless already summarized — RequestSummarize + start immediate processing through the SAME globalFetchGate as the Summarize button. Explicit paste overrides the recency window; a captionless video degrades to the honest 'no transcript' terminal state via the engine (ADR-010). Invalid URL -> 400, not-found -> 404, both add nothing. Route mounts only when a Fetcher is wired. Moves the video-not-found sentinel to domain (shared by adapter + web, no cross-adapter coupling). Tests: valid add+queue, invalid, not-found, dedup. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/youtube/videobyid_test.go | 4 +- internal/adapters/youtube/youtube.go | 7 +- internal/domain/domain.go | 6 ++ internal/web/handlers.go | 82 ++++++++++++++ internal/web/paste_handler_test.go | 113 ++++++++++++++++++++ internal/web/processing.go | 10 ++ 6 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 internal/web/paste_handler_test.go diff --git a/internal/adapters/youtube/videobyid_test.go b/internal/adapters/youtube/videobyid_test.go index f436b4e..31762fe 100644 --- a/internal/adapters/youtube/videobyid_test.go +++ b/internal/adapters/youtube/videobyid_test.go @@ -53,7 +53,7 @@ func TestVideoByIDNotFound(t *testing.T) { _, _ = w.Write([]byte(`{"items":[]}`)) }) _, err := a.VideoByID(context.Background(), "u1", "missingvid0") - if !errors.Is(err, ErrVideoNotFound) { - t.Fatalf("VideoByID for missing id = %v, want ErrVideoNotFound", err) + if !errors.Is(err, domain.ErrVideoNotFound) { + t.Fatalf("VideoByID for missing id = %v, want domain.ErrVideoNotFound", err) } } diff --git a/internal/adapters/youtube/youtube.go b/internal/adapters/youtube/youtube.go index 954fb04..96137f7 100644 --- a/internal/adapters/youtube/youtube.go +++ b/internal/adapters/youtube/youtube.go @@ -18,7 +18,6 @@ package youtube import ( "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -32,10 +31,6 @@ import ( "gitea.d-ma.be/mathias/tapir/internal/ports" ) -// ErrVideoNotFound is returned by VideoByID when an id resolves to no video -// (deleted, private, or a typo'd paste). Callers map it to an honest user message. -var ErrVideoNotFound = errors.New("youtube: video not found") - // defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL // (tests point it at an httptest server). const defaultBaseURL = "https://www.googleapis.com/youtube/v3" @@ -267,7 +262,7 @@ func (a *Adapter) VideoByID(ctx context.Context, userID, videoID string) (domain return domain.Video{}, fmt.Errorf("video by id %q: %w", videoID, err) } if len(resp.Items) == 0 { - return domain.Video{}, fmt.Errorf("video %q: %w", videoID, ErrVideoNotFound) + return domain.Video{}, fmt.Errorf("video %q: %w", videoID, domain.ErrVideoNotFound) } it := resp.Items[0] return domain.Video{ diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 64e0f2c..c02c626 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -3,10 +3,16 @@ package domain import ( + "errors" "fmt" "time" ) +// ErrVideoNotFound is returned when a video id resolves to no video (deleted, +// private, or a typo'd paste). Defined in domain so adapters and the web layer +// share one sentinel without coupling to each other. +var ErrVideoNotFound = errors.New("video not found") + // 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. diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 0ba3762..95c4549 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -3,6 +3,8 @@ package web import ( "context" "errors" + "html/template" + "io" "log/slog" "net/http" "time" @@ -10,6 +12,7 @@ import ( "github.com/a-h/templ" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" + "gitea.d-ma.be/mathias/tapir/internal/domain" ) // Store is the read/write surface the web handlers depend on — a narrow port over @@ -30,6 +33,10 @@ type Store interface { SetAutoSummarize(ctx context.Context, userID string, enabled bool) error RequestSummarize(ctx context.Context, userID, videoID string) error + // UpsertVideo persists a pasted video (idempotent on user+provider+video id, + // so it also dedups) and returns its durable store id. + UpsertVideo(ctx context.Context, v domain.Video) (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 @@ -78,6 +85,9 @@ type App struct { // 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 + // Fetcher, when non-nil, resolves an arbitrary YouTube video id to metadata for + // the paste-a-URL flow (Feature 2). Nil = the /paste route is not mounted. + Fetcher VideoFetcher // 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 @@ -132,6 +142,9 @@ func (a *App) Router() http.Handler { 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) + if a.Fetcher != nil { + app.HandleFunc("POST /paste", a.handlePaste) + } app.HandleFunc("GET /v/{videoId}/status", a.handleStatus) app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("POST /register", a.handleRegister) @@ -324,6 +337,75 @@ func (a *App) handleRequestSummarize(w http.ResponseWriter, r *http.Request) { a.render(w, r, VideoCard(*row)) } +// handlePaste handles "paste a YouTube URL" (Feature 2). It parses the video id, +// fetches metadata (Data API — ungated), upserts a subscription-less video row +// scoped to the user (idempotent, so it also dedups), and — if the video isn't +// already summarized — requests a summary and kicks off immediate processing +// through the SAME rate gate as the Summarize button. An explicit paste is a +// manual request, so it summarizes regardless of the recency window. A video that +// turns out to have no captions resolves to the honest "no transcript" terminal +// state via the engine (ADR-010), not an error here. +func (a *App) handlePaste(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + videoID, err := parseYouTubeVideoID(r.FormValue("url")) + if err != nil { + a.pasteFailure(w, http.StatusBadRequest, "That doesn't look like a YouTube video link.") + return + } + + v, err := a.Fetcher.FetchVideo(r.Context(), userID, videoID) + if errors.Is(err, domain.ErrVideoNotFound) { + a.pasteFailure(w, http.StatusNotFound, "That video couldn't be found — it may be private or removed.") + return + } + if err != nil { + a.serverError(w, r, "paste fetch", err) + return + } + + id, err := a.Store.UpsertVideo(r.Context(), v) + if err != nil { + a.serverError(w, r, "paste upsert", err) + return + } + row, err := a.Store.GetVideoRow(r.Context(), userID, id) + if err != nil { + a.serverError(w, r, "paste get video", err) + return + } + + // Dedup: already in the feed with a summary — surface the existing entry, + // don't re-summarize. + if row.Summarized { + a.render(w, r, VideoCard(*row)) + return + } + + // New or unsummarized: queue + (if a Processor is wired) summarize now, through + // the shared gate. RequestSummarize makes it durable even if the process dies. + if err := a.Store.RequestSummarize(r.Context(), userID, id); err != nil { + a.serverError(w, r, "paste request summarize", err) + return + } + if a.Processor != nil { + a.startProcessing(userID, id) + a.render(w, r, processingCard(*row)) + return + } + a.render(w, r, VideoCard(*row)) +} + +// pasteFailure renders a minimal inline error fragment for the paste form (HTMX +// swaps it in). No templ dependency so it renders even on a bad-input fast path. +func (a *App) pasteFailure(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = io.WriteString(w, ``) +} + // 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. diff --git a/internal/web/paste_handler_test.go b/internal/web/paste_handler_test.go new file mode 100644 index 0000000..4702078 --- /dev/null +++ b/internal/web/paste_handler_test.go @@ -0,0 +1,113 @@ +package web_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/domain" +) + +// fakeFetcher is a web.VideoFetcher returning a fixed video (or an error), +// scoped to whatever (userID, videoID) the handler asks for. +type fakeFetcher struct { + title string + err error + calls int +} + +func (f *fakeFetcher) FetchVideo(_ context.Context, userID, videoID string) (domain.Video, error) { + f.calls++ + if f.err != nil { + return domain.Video{}, f.err + } + return domain.Video{ + UserID: userID, + Provider: domain.ProviderYouTube, + ProviderVideoID: videoID, + Title: f.title, + URL: "https://www.youtube.com/watch?v=" + videoID, + }, nil +} + +func pasteReq(rawURL string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/paste", + strings.NewReader("url="+url.QueryEscape(rawURL))) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req +} + +func TestPasteValidURLAddsAndRequests(t *testing.T) { + ctx := context.Background() + app := newApp(t) + resetDB(t, rawPool(t)) + app.Fetcher = &fakeFetcher{title: "Pasted Talk"} + p := rawPool(t) + + rec := do(t, app, pasteReq("https://youtu.be/dQw4w9WgXcQ")) + require.Equal(t, http.StatusOK, rec.Code) + + var ( + count, requested int + title string + ) + require.NoError(t, p.QueryRow(ctx, + `SELECT count(*), coalesce(max(title),'') FROM videos + WHERE user_id=$1 AND provider_video_id='dQw4w9WgXcQ'`, userID).Scan(&count, &title)) + require.Equal(t, 1, count, "pasted video added once, scoped to the user") + require.Equal(t, "Pasted Talk", title) + + require.NoError(t, p.QueryRow(ctx, + `SELECT count(*) FROM videos + WHERE user_id=$1 AND provider_video_id='dQw4w9WgXcQ' AND summarize_requested`, + userID).Scan(&requested)) + require.Equal(t, 1, requested, "pasted video is queued for summarization (through the gate)") +} + +func TestPasteInvalidURLRejected(t *testing.T) { + app := newApp(t) + resetDB(t, rawPool(t)) + app.Fetcher = &fakeFetcher{title: "x"} + + rec := do(t, app, pasteReq("definitely not a url")) + require.Equal(t, http.StatusBadRequest, rec.Code) + + var count int + require.NoError(t, rawPool(t).QueryRow(context.Background(), + `SELECT count(*) FROM videos WHERE user_id=$1`, userID).Scan(&count)) + require.Equal(t, 0, count, "invalid input adds nothing") +} + +func TestPasteVideoNotFound(t *testing.T) { + app := newApp(t) + resetDB(t, rawPool(t)) + app.Fetcher = &fakeFetcher{err: domain.ErrVideoNotFound} + + rec := do(t, app, pasteReq("https://youtu.be/dQw4w9WgXcQ")) + require.Equal(t, http.StatusNotFound, rec.Code) + + var count int + require.NoError(t, rawPool(t).QueryRow(context.Background(), + `SELECT count(*) FROM videos WHERE user_id=$1`, userID).Scan(&count)) + require.Equal(t, 0, count, "a not-found video adds nothing") +} + +func TestPasteDedupNoDuplicate(t *testing.T) { + app := newApp(t) + resetDB(t, rawPool(t)) + app.Fetcher = &fakeFetcher{title: "Pasted Talk"} + + require.Equal(t, http.StatusOK, do(t, app, pasteReq("https://youtu.be/dQw4w9WgXcQ")).Code) + require.Equal(t, http.StatusOK, do(t, app, pasteReq("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).Code) + + var count int + require.NoError(t, rawPool(t).QueryRow(context.Background(), + `SELECT count(*) FROM videos WHERE user_id=$1 AND provider_video_id='dQw4w9WgXcQ'`, + userID).Scan(&count)) + require.Equal(t, 1, count, "pasting the same video twice must not duplicate the row") +} diff --git a/internal/web/processing.go b/internal/web/processing.go index 0ed2609..c968396 100644 --- a/internal/web/processing.go +++ b/internal/web/processing.go @@ -3,6 +3,8 @@ package web import ( "context" "sync" + + "gitea.d-ma.be/mathias/tapir/internal/domain" ) // Processor runs the core summarization use case for a single already-discovered @@ -14,6 +16,14 @@ type Processor interface { ProcessVideo(ctx context.Context, userID, videoID string) error } +// VideoFetcher resolves an arbitrary YouTube video id to its metadata for the +// paste-a-URL flow (Feature 2). It is a Data API call, NOT the rate-limited +// caption path. Returns domain.ErrVideoNotFound for a deleted/private/typo'd id. +// cmd/tapir wires a per-user YouTube adapter; nil disables the paste route. +type VideoFetcher interface { + FetchVideo(ctx context.Context, userID, videoID string) (domain.Video, error) +} + // ProcessingSet tracks the (user, video) ids currently being summarized in-process // so the status endpoint can show the animation until the summary lands. It is // ephemeral (single-instance Stage-1): a restart drops it, and the DB holds the