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":[]}`))
|
_, _ = w.Write([]byte(`{"items":[]}`))
|
||||||
})
|
})
|
||||||
_, err := a.VideoByID(context.Background(), "u1", "missingvid0")
|
_, err := a.VideoByID(context.Background(), "u1", "missingvid0")
|
||||||
if !errors.Is(err, ErrVideoNotFound) {
|
if !errors.Is(err, domain.ErrVideoNotFound) {
|
||||||
t.Fatalf("VideoByID for missing id = %v, want ErrVideoNotFound", err)
|
t.Fatalf("VideoByID for missing id = %v, want domain.ErrVideoNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ package youtube
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,10 +31,6 @@ import (
|
|||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"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
|
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
|
||||||
// (tests point it at an httptest server).
|
// (tests point it at an httptest server).
|
||||||
const defaultBaseURL = "https://www.googleapis.com/youtube/v3"
|
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)
|
return domain.Video{}, fmt.Errorf("video by id %q: %w", videoID, err)
|
||||||
}
|
}
|
||||||
if len(resp.Items) == 0 {
|
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]
|
it := resp.Items[0]
|
||||||
return domain.Video{
|
return domain.Video{
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"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
|
// ErrChannelUnavailable is returned by a VideoSource when a channel's upload
|
||||||
// playlist returns HTTP 404 — the channel was deleted or made private. The runner
|
// 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.
|
// stores these so the account page can surface them to the user.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
"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
|
// 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
|
SetAutoSummarize(ctx context.Context, userID string, enabled bool) error
|
||||||
RequestSummarize(ctx context.Context, userID, videoID string) 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).
|
// Account management (the /account page, disconnect, delete-account).
|
||||||
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
ConnectionsForUser(ctx context.Context, userID string) ([]store.Connection, error)
|
||||||
DeleteConnection(ctx context.Context, userID, provider string) 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:
|
// 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.
|
// the button flips the DB flag and the next `tapir run` does the work.
|
||||||
Processor Processor
|
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
|
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||||
// shows the animation until the summary lands. The zero value is ready to use.
|
// shows the animation until the summary lands. The zero value is ready to use.
|
||||||
Processing ProcessingSet
|
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}/action", a.handleAction)
|
||||||
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
app.HandleFunc("POST /v/{videoId}/summarize", a.handleRequestSummarize)
|
||||||
app.HandleFunc("POST /v/{videoId}/retry-now", a.handleRetryNow)
|
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 /v/{videoId}/status", a.handleStatus)
|
||||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||||
app.HandleFunc("POST /register", a.handleRegister)
|
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))
|
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
|
// 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
|
// clears the rate_limited_at backoff so the scheduler won't skip the video, then
|
||||||
// triggers an immediate ProcessVideo — same background path as handleRequestSummarize.
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Processor runs the core summarization use case for a single already-discovered
|
// 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
|
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
|
// 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
|
// 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
|
// ephemeral (single-instance Stage-1): a restart drops it, and the DB holds the
|
||||||
|
|||||||
Reference in New Issue
Block a user