feat(web): paste-a-URL handler — add an arbitrary video + summarize (Feature 2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, `<p class="paste-error" role="alert">`+template.HTMLEscapeString(msg)+`</p>`)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user