Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c978774f | ||
|
|
70a9f1d4cd | ||
|
|
1d5b2c6365 | ||
|
|
62acfee2ed | ||
|
|
2907801aca | ||
|
|
59050c4db6 | ||
|
|
c320ed88aa | ||
|
|
bddd75d92e |
@@ -32,7 +32,10 @@ func serialize(mu *sync.Mutex, run discoveryRunner) discoveryRunner {
|
|||||||
type discoveryTrigger struct {
|
type discoveryTrigger struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
run discoveryRunner
|
run discoveryRunner
|
||||||
log *slog.Logger
|
// onboard, when set, runs after the discovery pass to summarize a capped number
|
||||||
|
// of the user's newest videos (Feature 1). Optional.
|
||||||
|
onboard func(ctx context.Context, userID string)
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *discoveryTrigger) Enqueue(userID string) {
|
func (t *discoveryTrigger) Enqueue(userID string) {
|
||||||
@@ -40,5 +43,8 @@ func (t *discoveryTrigger) Enqueue(userID string) {
|
|||||||
if _, err := t.run(t.ctx, userID); err != nil {
|
if _, err := t.run(t.ctx, userID); err != nil {
|
||||||
t.log.Warn("discovery: connect-triggered pass had errors", "user", userID, "err", err)
|
t.log.Warn("discovery: connect-triggered pass had errors", "user", userID, "err", err)
|
||||||
}
|
}
|
||||||
|
if t.onboard != nil {
|
||||||
|
t.onboard(t.ctx, userID)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-3
@@ -210,7 +210,10 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
ClientSecret: cfg.YTClientSecret,
|
ClientSecret: cfg.YTClientSecret,
|
||||||
RedirectURL: cfg.YTConnectRedirectURL,
|
RedirectURL: cfg.YTConnectRedirectURL,
|
||||||
}, secretStore, st, log)
|
}, secretStore, st, log)
|
||||||
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
|
// Paste-a-URL (Feature 2): same YouTube credentials, per-user adapter built
|
||||||
|
// per request. Mounting the /paste route keys off app.Fetcher being set.
|
||||||
|
app.Fetcher = videoFetcher{cfg: cfg, secrets: secretStore}
|
||||||
|
log.Info("web youtube connect + paste enabled", "redirect", cfg.YTConnectRedirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immediate summarization for the web "Summarize" button. When the engine can
|
// Immediate summarization for the web "Summarize" button. When the engine can
|
||||||
@@ -249,9 +252,33 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
|||||||
// One lock shared by the scheduler and connect-triggered passes (#6) so
|
// One lock shared by the scheduler and connect-triggered passes (#6) so
|
||||||
// they never fetch concurrently — the single-fetcher invariant (ADR-018).
|
// they never fetch concurrently — the single-fetcher invariant (ADR-018).
|
||||||
runUser := serialize(&sync.Mutex{}, rawRunUser)
|
runUser := serialize(&sync.Mutex{}, rawRunUser)
|
||||||
|
// Onboarding burst (Feature 1): after the connect-triggered discovery pass,
|
||||||
|
// summarize up to OnboardSummarizeCount of the user's NEWEST unsummarized
|
||||||
|
// videos so a fresh account gets real summaries in its first session. Hard
|
||||||
|
// cap; explicit, so it bypasses the recency window — but every fetch still
|
||||||
|
// goes through globalFetchGate via the Processor. No-op when disabled
|
||||||
|
// (count 0) or queue-only (no Processor).
|
||||||
|
onboard := func(ctx context.Context, userID string) {
|
||||||
|
if cfg.OnboardSummarizeCount <= 0 || app.Processor == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := st.NewestUnsummarizedVideoIDs(ctx, userID, cfg.OnboardSummarizeCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("onboarding: list newest unsummarized", "user", userID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := app.Processor.ProcessVideo(ctx, userID, id); err != nil {
|
||||||
|
log.Warn("onboarding: summarize", "user", userID, "video", id, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
log.Info("onboarding burst complete", "user", userID, "summarized", len(ids), "cap", cfg.OnboardSummarizeCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
if app.Connect != nil {
|
if app.Connect != nil {
|
||||||
app.Connect.Discovery = &discoveryTrigger{ctx: ctx, run: runUser, log: log}
|
app.Connect.Discovery = &discoveryTrigger{ctx: ctx, run: runUser, onboard: onboard, log: log}
|
||||||
log.Info("connect-triggered discovery enabled")
|
log.Info("connect-triggered discovery enabled", "onboard_cap", cfg.OnboardSummarizeCount)
|
||||||
}
|
}
|
||||||
go runScheduler(ctx, cfg.DiscoveryInterval, st, runUser, log)
|
go runScheduler(ctx, cfg.DiscoveryInterval, st, runUser, log)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,9 +11,29 @@ import (
|
|||||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
"gitea.d-ma.be/mathias/tapir/internal/usecase"
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// videoFetcher adapts the YouTube adapter to web.VideoFetcher for the paste flow
|
||||||
|
// (Feature 2). It builds a per-user adapter bound to that user's token ref and
|
||||||
|
// resolves a single video's metadata via the Data API — ungated; only the later
|
||||||
|
// transcript fetch goes through globalFetchGate.
|
||||||
|
type videoFetcher struct {
|
||||||
|
cfg config.Config
|
||||||
|
secrets ports.SecretStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f videoFetcher) FetchVideo(ctx context.Context, userID, videoID string) (domain.Video, error) {
|
||||||
|
a := youtube.New(youtube.Config{
|
||||||
|
ClientID: f.cfg.YTClientID,
|
||||||
|
ClientSecret: f.cfg.YTClientSecret,
|
||||||
|
TokenSecretRef: web.YouTubeTokenRef(userID),
|
||||||
|
}, f.secrets)
|
||||||
|
return a.VideoByID(ctx, userID, videoID)
|
||||||
|
}
|
||||||
|
|
||||||
// buildProcessor wires the summarization engine — YouTube source (captions-first),
|
// buildProcessor wires the summarization engine — YouTube source (captions-first),
|
||||||
// AI-router summarizer, store sink — shared by `tapir run` and the web
|
// AI-router summarizer, store sink — shared by `tapir run` and the web
|
||||||
// "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —
|
// "Summarize now" path so the wiring lives in one place. It returns (nil, nil) —
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ Feature: Connect and manage video accounts
|
|||||||
Then a discovery pass for my account is triggered right away
|
Then a discovery pass for my account is triggered right away
|
||||||
And I do not have to wait for the next scheduled pass to see my videos
|
And I do not have to wait for the next scheduled pass to see my videos
|
||||||
|
|
||||||
|
Scenario: Connecting summarizes my newest videos right away
|
||||||
|
Given I have no connected video accounts
|
||||||
|
When I connect my YouTube account
|
||||||
|
Then up to the onboarding cap of my newest videos are summarized through the rate gate
|
||||||
|
And the rest are left to the scheduled recency-bounded pass
|
||||||
|
|
||||||
Scenario: Tokens are never stored in the clear
|
Scenario: Tokens are never stored in the clear
|
||||||
When I connect any video account
|
When I connect any video account
|
||||||
Then no OAuth token value is stored in the database
|
Then no OAuth token value is stored in the database
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
Feature: Paste a YouTube URL to summarize any video
|
||||||
|
As a user
|
||||||
|
I want to paste a YouTube link and get a summary
|
||||||
|
So that I can pull the specific video I want now, even from channels I don't follow
|
||||||
|
|
||||||
|
Scenario: Paste a valid YouTube URL
|
||||||
|
Given I am connected
|
||||||
|
When I paste a valid YouTube video URL
|
||||||
|
Then the video is added to my feed scoped to me
|
||||||
|
And it is queued for summarization through the shared rate gate
|
||||||
|
|
||||||
|
Scenario: Pasting an invalid link is rejected
|
||||||
|
When I paste something that is not a YouTube video URL
|
||||||
|
Then I get a clear error and nothing is added
|
||||||
|
|
||||||
|
Scenario: Pasting a video that cannot be found is honest
|
||||||
|
When I paste a URL whose video cannot be found
|
||||||
|
Then I am told it couldn't be found and nothing is added
|
||||||
|
|
||||||
|
Scenario: Pasting the same video twice does not duplicate it
|
||||||
|
Given I have pasted a video
|
||||||
|
When I paste the same video again
|
||||||
|
Then my feed still has exactly one entry for it
|
||||||
|
|
||||||
|
@pending
|
||||||
|
# Covered by the engine's ADR-010 no-transcript terminal state (degrade-never-error);
|
||||||
|
# there is no paste-specific test for it.
|
||||||
|
Scenario: A pasted video with no captions resolves honestly
|
||||||
|
When I paste a video that has no captions
|
||||||
|
Then it resolves to the "no transcript available" terminal state
|
||||||
@@ -72,3 +72,41 @@ func nullTime(t time.Time) *time.Time {
|
|||||||
}
|
}
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewestUnsummarizedVideoIDs returns up to limit of the user's videos that have
|
||||||
|
// no summary yet, newest first (published_at DESC, NULLS LAST). It caps the
|
||||||
|
// connect-time onboarding burst (Feature 1) at a fixed count: the caller marks
|
||||||
|
// these for summarization through the shared rate gate. RLS-scoped via withUser,
|
||||||
|
// so it only ever sees the requesting user's rows. limit <= 0 returns nil.
|
||||||
|
func (s *Store) NewestUnsummarizedVideoIDs(ctx context.Context, userID string, limit int) ([]string, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(ctx,
|
||||||
|
`SELECT v.id
|
||||||
|
FROM videos v
|
||||||
|
WHERE v.user_id = $1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM summaries su
|
||||||
|
WHERE su.user_id = v.user_id AND su.video_id = v.id)
|
||||||
|
ORDER BY v.published_at DESC NULLS LAST, v.seen_at DESC
|
||||||
|
LIMIT $2`, userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: newest unsummarized: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return fmt.Errorf("store: scan newest unsummarized: %w", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,3 +81,35 @@ func TestUpsertVideo_PerUserIsolation(t *testing.T) {
|
|||||||
|
|
||||||
require.NotEqual(t, idA, idB, "same provider video for two users must be two distinct rows")
|
require.NotEqual(t, idA, idB, "same provider video for two users must be two distinct rows")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewestUnsummarizedVideoIDs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newStore(t)
|
||||||
|
resetDB(t, rawPool(t))
|
||||||
|
|
||||||
|
mk := func(user, pid string, day int) string {
|
||||||
|
v := ytVideo(user, pid, pid)
|
||||||
|
v.PublishedAt = time.Date(2026, 6, day, 12, 0, 0, 0, time.UTC)
|
||||||
|
id, err := s.UpsertVideo(ctx, v)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = mk(userA, "a1vid000001", 1)
|
||||||
|
id2 := mk(userA, "a2vid000002", 2)
|
||||||
|
id3 := mk(userA, "a3vid000003", 3)
|
||||||
|
id4 := mk(userA, "a4vid000004", 4)
|
||||||
|
mk(userB, "b1vid000009", 9) // userB's newest — must never leak via RLS
|
||||||
|
|
||||||
|
// The newest (v4) is summarized, so it's excluded from "unsummarized".
|
||||||
|
require.NoError(t, s.Deliver(ctx, summary(userA, id4, "done")))
|
||||||
|
|
||||||
|
// Cap 2, newest-first unsummarized: v3 then v2 (v4 excluded; userB excluded).
|
||||||
|
got, err := s.NewestUnsummarizedVideoIDs(ctx, userA, 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{id3, id2}, got)
|
||||||
|
|
||||||
|
none, err := s.NewestUnsummarizedVideoIDs(ctx, userA, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, none, "limit 0 returns nothing")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package youtube
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVideoByID(t *testing.T) {
|
||||||
|
const id = "dQw4w9WgXcQ"
|
||||||
|
a, secrets := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/videos" {
|
||||||
|
t.Errorf("unexpected path %q (must use videos.list)", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("id"); got != id {
|
||||||
|
t.Errorf("expected id=%s, got %q", id, got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("part"); got != "snippet" {
|
||||||
|
t.Errorf("expected part=snippet, got %q", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"items":[{"snippet":{"title":"Never Gonna Give You Up","publishedAt":"2026-05-20T09:00:00Z"}}]}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
v, err := a.VideoByID(context.Background(), "u1", id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VideoByID: %v", err)
|
||||||
|
}
|
||||||
|
if v.UserID != "u1" {
|
||||||
|
t.Errorf("UserID = %q, want u1", v.UserID)
|
||||||
|
}
|
||||||
|
if v.ProviderVideoID != id || v.Title != "Never Gonna Give You Up" {
|
||||||
|
t.Errorf("unexpected video: %+v", v)
|
||||||
|
}
|
||||||
|
if v.Provider != domain.ProviderYouTube || v.URL != "https://www.youtube.com/watch?v="+id {
|
||||||
|
t.Errorf("video not wired correctly: %+v", v)
|
||||||
|
}
|
||||||
|
if v.PublishedAt.IsZero() {
|
||||||
|
t.Errorf("expected publishedAt parsed, got zero")
|
||||||
|
}
|
||||||
|
if v.SubscriptionID != "" {
|
||||||
|
t.Errorf("a pasted video must have no subscription, got %q", v.SubscriptionID)
|
||||||
|
}
|
||||||
|
if secrets.byRef == nil {
|
||||||
|
t.Errorf("token must be resolved by reference through the SecretStore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoByIDNotFound(t *testing.T) {
|
||||||
|
a, _ := newTestAdapter(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"items":[]}`))
|
||||||
|
})
|
||||||
|
_, err := a.VideoByID(context.Background(), "u1", "missingvid0")
|
||||||
|
if !errors.Is(err, domain.ErrVideoNotFound) {
|
||||||
|
t.Fatalf("VideoByID for missing id = %v, want domain.ErrVideoNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,6 +244,37 @@ func (a *Adapter) NewVideos(ctx context.Context, sub domain.Subscription) ([]dom
|
|||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VideoByID fetches a single video's metadata (videos.list, snippet) for an
|
||||||
|
// arbitrary video id — including channels the user does not follow (paste-a-URL,
|
||||||
|
// Feature 2). This is a Data API call (1 quota unit), NOT the rate-limited
|
||||||
|
// caption path, so it is not gated: only the later transcript fetch goes through
|
||||||
|
// globalFetchGate. UserID is set on the result and SubscriptionID is left empty
|
||||||
|
// (a pasted video has no subscription parent). Returns ErrVideoNotFound when the
|
||||||
|
// id resolves to no video.
|
||||||
|
func (a *Adapter) VideoByID(ctx context.Context, userID, videoID string) (domain.Video, error) {
|
||||||
|
client, err := a.httpClient(ctx, a.cfg.TokenSecretRef)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Video{}, err
|
||||||
|
}
|
||||||
|
q := url.Values{"part": {"snippet"}, "id": {videoID}}
|
||||||
|
var resp videoListResponse
|
||||||
|
if err := a.getJSON(ctx, client, "/videos", q, &resp); err != nil {
|
||||||
|
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, domain.ErrVideoNotFound)
|
||||||
|
}
|
||||||
|
it := resp.Items[0]
|
||||||
|
return domain.Video{
|
||||||
|
UserID: userID,
|
||||||
|
Provider: domain.ProviderYouTube,
|
||||||
|
ProviderVideoID: videoID,
|
||||||
|
Title: it.Snippet.Title,
|
||||||
|
URL: "https://www.youtube.com/watch?v=" + videoID,
|
||||||
|
PublishedAt: it.Snippet.PublishedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// uploadsPlaylistID derives a channel's uploads playlist id at zero API cost:
|
// uploadsPlaylistID derives a channel's uploads playlist id at zero API cost:
|
||||||
// a standard channel id "UCxxxx" maps to uploads playlist "UUxxxx". Returns
|
// a standard channel id "UCxxxx" maps to uploads playlist "UUxxxx". Returns
|
||||||
// ok=false for ids that don't follow this convention (caller falls back to
|
// ok=false for ids that don't follow this convention (caller falls back to
|
||||||
@@ -342,6 +373,15 @@ type playlistItemListResponse struct {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type videoListResponse struct {
|
||||||
|
Items []struct {
|
||||||
|
Snippet struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
PublishedAt time.Time `json:"publishedAt"`
|
||||||
|
} `json:"snippet"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
type channelListResponse struct {
|
type channelListResponse struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ContentDetails struct {
|
ContentDetails struct {
|
||||||
|
|||||||
+45
-11
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -79,6 +80,13 @@ type Config struct {
|
|||||||
// pre-recency behaviour). Default ~7 days.
|
// pre-recency behaviour). Default ~7 days.
|
||||||
AutoSummarizeWindow time.Duration
|
AutoSummarizeWindow time.Duration
|
||||||
|
|
||||||
|
// OnboardSummarizeCount caps how many of a freshly-connected user's newest
|
||||||
|
// videos are summarized immediately on connect (the onboarding "it works"
|
||||||
|
// burst). HARD-capped at maxOnboardSummarizeCount so onboarding can never
|
||||||
|
// bulk-fetch; 0 disables the burst. Every fetch still flows through the shared
|
||||||
|
// caption rate gate (ADR-014) — the cap bounds count, never the pacing. Default 3.
|
||||||
|
OnboardSummarizeCount int
|
||||||
|
|
||||||
// DiscoveryInterval, when > 0, makes `serve` run in-process scheduled discovery
|
// DiscoveryInterval, when > 0, makes `serve` run in-process scheduled discovery
|
||||||
// for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and
|
// for ALL users on that cadence (ADR-018). Zero/unset = disabled, so dev and
|
||||||
// tests never auto-fetch. Single-replica assumption — see cmdServe.
|
// tests never auto-fetch. Single-replica assumption — see cmdServe.
|
||||||
@@ -108,17 +116,19 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
|
|||||||
|
|
||||||
// Defaults (see docs/homelab-integration.md). All overridable via env.
|
// Defaults (see docs/homelab-integration.md). All overridable via env.
|
||||||
const (
|
const (
|
||||||
defaultGatewayURL = "http://koala:30401/v1"
|
defaultGatewayURL = "http://koala:30401/v1"
|
||||||
defaultSummarizerModel = "koala/phi4-mini"
|
defaultSummarizerModel = "koala/phi4-mini"
|
||||||
defaultSummarizerTimeout = 5 * time.Minute
|
defaultSummarizerTimeout = 5 * time.Minute
|
||||||
defaultYTTokenRef = "youtube/refresh_token"
|
defaultYTTokenRef = "youtube/refresh_token"
|
||||||
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||||
defaultOAuthRedirectAddr = "localhost:8080"
|
defaultOAuthRedirectAddr = "localhost:8080"
|
||||||
defaultHTTPAddr = ":8080"
|
defaultHTTPAddr = ":8080"
|
||||||
defaultFetchBackoff = time.Hour
|
defaultFetchBackoff = time.Hour
|
||||||
defaultFetchRate = 2 * time.Second
|
defaultFetchRate = 2 * time.Second
|
||||||
defaultPublicURL = "https://tapir.d-ma.be"
|
defaultPublicURL = "https://tapir.d-ma.be"
|
||||||
defaultAutoSummarizeWindow = 7 * 24 * time.Hour
|
defaultAutoSummarizeWindow = 7 * 24 * time.Hour
|
||||||
|
defaultOnboardSummarizeCount = 3
|
||||||
|
maxOnboardSummarizeCount = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the environment into a Config, applying defaults. It does not
|
// Load reads the environment into a Config, applying defaults. It does not
|
||||||
@@ -183,6 +193,18 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
c.AutoSummarizeWindow = autoWindow
|
c.AutoSummarizeWindow = autoWindow
|
||||||
|
|
||||||
|
onboard, err := intOr("TAPIR_ONBOARD_SUMMARIZE_COUNT", defaultOnboardSummarizeCount)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if onboard < 0 {
|
||||||
|
onboard = 0
|
||||||
|
}
|
||||||
|
if onboard > maxOnboardSummarizeCount {
|
||||||
|
onboard = maxOnboardSummarizeCount
|
||||||
|
}
|
||||||
|
c.OnboardSummarizeCount = onboard
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +269,18 @@ func envOr(key, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func intOr(key string, fallback int) (int, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config: %s=%q: %w", key, v, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
func durationOr(key string, fallback time.Duration) (time.Duration, error) {
|
func durationOr(key string, fallback time.Duration) (time.Duration, error) {
|
||||||
v := os.Getenv(key)
|
v := os.Getenv(key)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
|
|||||||
@@ -53,6 +53,38 @@ func TestLoad_AppliesDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_OnboardSummarizeCount(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, env string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"default", "", defaultOnboardSummarizeCount},
|
||||||
|
{"explicit", "4", 4},
|
||||||
|
{"zero disables", "0", 0},
|
||||||
|
{"clamped to hard cap", "50", maxOnboardSummarizeCount},
|
||||||
|
{"negative clamps to zero", "-3", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
setEnv(t, map[string]string{"TAPIR_ONBOARD_SUMMARIZE_COUNT": c.env})
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.OnboardSummarizeCount != c.want {
|
||||||
|
t.Fatalf("OnboardSummarizeCount = %d, want %d", cfg.OnboardSummarizeCount, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_OnboardSummarizeCountInvalid(t *testing.T) {
|
||||||
|
setEnv(t, map[string]string{"TAPIR_ONBOARD_SUMMARIZE_COUNT": "three"})
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("Load: want error for non-numeric TAPIR_ONBOARD_SUMMARIZE_COUNT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_ParsesValues(t *testing.T) {
|
func TestLoad_ParsesValues(t *testing.T) {
|
||||||
setEnv(t, map[string]string{
|
setEnv(t, map[string]string{
|
||||||
"TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111",
|
"TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111",
|
||||||
|
|||||||
@@ -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,62 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// youtubeVideoID matches a canonical YouTube video id: exactly 11 URL-safe chars.
|
||||||
|
var youtubeVideoID = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
|
||||||
|
|
||||||
|
// parseYouTubeVideoID extracts the 11-character video id from a pasted YouTube
|
||||||
|
// URL (watch?v=, youtu.be/, shorts/, embed/) or a bare id. It rejects non-YouTube
|
||||||
|
// hosts and anything that doesn't yield a valid id, so the paste flow never tries
|
||||||
|
// to fetch a video that can't exist (Feature 2).
|
||||||
|
func parseYouTubeVideoID(raw string) (string, error) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("empty input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare id (no URL) — accept directly.
|
||||||
|
if youtubeVideoID.MatchString(s) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept scheme-less URLs (youtube.com/watch?v=...) by giving url.Parse a host.
|
||||||
|
if !strings.Contains(s, "://") {
|
||||||
|
s = "https://" + s
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("not a URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.ToLower(u.Hostname())
|
||||||
|
isYouTube := host == "youtu.be" || host == "youtube.com" || strings.HasSuffix(host, ".youtube.com")
|
||||||
|
if !isYouTube {
|
||||||
|
return "", fmt.Errorf("not a YouTube URL: %q", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
switch {
|
||||||
|
case host == "youtu.be":
|
||||||
|
// youtu.be/<id>
|
||||||
|
id = strings.Trim(u.Path, "/")
|
||||||
|
case u.Path == "/watch":
|
||||||
|
id = u.Query().Get("v")
|
||||||
|
default:
|
||||||
|
// /shorts/<id>, /embed/<id>
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
if len(parts) == 2 && (parts[0] == "shorts" || parts[0] == "embed") {
|
||||||
|
id = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !youtubeVideoID.MatchString(id) {
|
||||||
|
return "", fmt.Errorf("no YouTube video id in %q", raw)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseYouTubeVideoID(t *testing.T) {
|
||||||
|
const id = "dQw4w9WgXcQ"
|
||||||
|
ok := []struct {
|
||||||
|
name, in string
|
||||||
|
}{
|
||||||
|
{"watch", "https://www.youtube.com/watch?v=" + id},
|
||||||
|
{"watch no www", "https://youtube.com/watch?v=" + id},
|
||||||
|
{"watch m", "https://m.youtube.com/watch?v=" + id},
|
||||||
|
{"watch extra params", "https://www.youtube.com/watch?v=" + id + "&t=42s&list=PLxyz"},
|
||||||
|
{"watch param after", "https://www.youtube.com/watch?list=PLxyz&v=" + id},
|
||||||
|
{"short link", "https://youtu.be/" + id},
|
||||||
|
{"short link param", "https://youtu.be/" + id + "?si=abcd&t=1"},
|
||||||
|
{"shorts", "https://www.youtube.com/shorts/" + id},
|
||||||
|
{"embed", "https://www.youtube.com/embed/" + id},
|
||||||
|
{"bare id", id},
|
||||||
|
{"http scheme", "http://youtube.com/watch?v=" + id},
|
||||||
|
{"no scheme", "youtube.com/watch?v=" + id},
|
||||||
|
{"trailing space", " https://youtu.be/" + id + " "},
|
||||||
|
}
|
||||||
|
for _, c := range ok {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := parseYouTubeVideoID(c.in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseYouTubeVideoID(%q) error: %v", c.in, err)
|
||||||
|
}
|
||||||
|
if got != id {
|
||||||
|
t.Fatalf("parseYouTubeVideoID(%q) = %q, want %q", c.in, got, id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := []struct {
|
||||||
|
name, in string
|
||||||
|
}{
|
||||||
|
{"empty", ""},
|
||||||
|
{"blank", " "},
|
||||||
|
{"vimeo", "https://vimeo.com/123456789"},
|
||||||
|
{"other host", "https://example.com/watch?v=" + id},
|
||||||
|
{"watch no id", "https://www.youtube.com/watch?v="},
|
||||||
|
{"short id", "https://youtu.be/abc"},
|
||||||
|
{"long id", "https://youtu.be/" + id + "extra"},
|
||||||
|
{"bad chars", "https://youtu.be/dQw4w9Wg!cQ"},
|
||||||
|
{"not a url", "just some text"},
|
||||||
|
{"channel url", "https://www.youtube.com/@somechannel"},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
t.Run("reject "+c.name, func(t *testing.T) {
|
||||||
|
if got, err := parseYouTubeVideoID(c.in); err == nil {
|
||||||
|
t.Fatalf("parseYouTubeVideoID(%q) = %q, want error", c.in, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) {
|
||||||
|
render := func(connected bool) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected).Render(context.Background(), &buf); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
html := render(true)
|
||||||
|
if !strings.Contains(html, `name="url"`) || !strings.Contains(html, `action="/paste"`) {
|
||||||
|
t.Errorf("connected feed must show the paste form")
|
||||||
|
}
|
||||||
|
if strings.Contains(render(false), `name="url"`) {
|
||||||
|
t.Errorf("disconnected feed must not show the paste form")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ type flashView struct {
|
|||||||
// flashMessages maps each flash code to its banner. An unknown code renders no
|
// flashMessages maps each flash code to its banner. An unknown code renders no
|
||||||
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
// banner (flashFor returns ok=false), so a forged cookie value is inert.
|
||||||
var flashMessages = map[string]flashView{
|
var flashMessages = map[string]flashView{
|
||||||
flashConnected: {"success", "YouTube account connected."},
|
flashConnected: {"success", "YouTube account connected — finding your subscriptions. Your newest videos will appear below as they're summarized."},
|
||||||
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
flashConnectFailed: {"error", "Could not connect your YouTube account. Please try again."},
|
||||||
flashDisconnected: {"success", "Account disconnected."},
|
flashDisconnected: {"success", "Account disconnected."},
|
||||||
flashDeleted: {"success", "Your account and all its data were deleted."},
|
flashDeleted: {"success", "Your account and all its data were deleted."},
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ templ flashBanner(code string) {
|
|||||||
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool) {
|
templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool) {
|
||||||
@Layout("Tapir — Summaries") {
|
@Layout("Tapir — Summaries") {
|
||||||
@flashBanner(flash)
|
@flashBanner(flash)
|
||||||
|
if hasConnected {
|
||||||
|
@pasteForm()
|
||||||
|
}
|
||||||
if !b.empty() || f.active() {
|
if !b.empty() || f.active() {
|
||||||
@filterForm(f)
|
@filterForm(f)
|
||||||
}
|
}
|
||||||
@@ -143,6 +146,28 @@ templ pipelineBar(s PipelineStats) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pasteForm lets a connected user summarize any YouTube video by pasting its URL
|
||||||
|
// (Feature 2). The result (a video card, or an inline error) swaps into
|
||||||
|
// #paste-result; the next list refresh shows it inline. Summarization runs
|
||||||
|
// through the shared caption rate gate like every other fetch.
|
||||||
|
templ pasteForm() {
|
||||||
|
<form
|
||||||
|
class="paste"
|
||||||
|
method="post"
|
||||||
|
action="/paste"
|
||||||
|
hx-post="/paste"
|
||||||
|
hx-target="#paste-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Summarize any video
|
||||||
|
<input type="url" name="url" placeholder="Paste a YouTube link…" required/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
<div id="paste-result"></div>
|
||||||
|
}
|
||||||
|
|
||||||
templ filterForm(f Filter) {
|
templ filterForm(f Filter) {
|
||||||
<form
|
<form
|
||||||
class="filters"
|
class="filters"
|
||||||
|
|||||||
+595
-552
File diff suppressed because it is too large
Load Diff
@@ -37,9 +37,16 @@ var scenarioCoverage = map[string]string{
|
|||||||
"An authenticated user on the welcome page sees their way in and out": "TestWelcomeLoggedIn",
|
"An authenticated user on the welcome page sees their way in and out": "TestWelcomeLoggedIn",
|
||||||
|
|
||||||
// connect_account.feature
|
// connect_account.feature
|
||||||
"Connect a YouTube account": "TestCallbackExchangesAndRecordsConnection",
|
"Connect a YouTube account": "TestCallbackExchangesAndRecordsConnection",
|
||||||
"Connecting an account discovers videos immediately": "TestCallbackTriggersDiscovery",
|
"Connecting an account discovers videos immediately": "TestCallbackTriggersDiscovery",
|
||||||
"Tokens are never stored in the clear": "TestCallbackExchangesAndRecordsConnection",
|
"Connecting summarizes my newest videos right away": "TestNewestUnsummarizedVideoIDs",
|
||||||
|
"Tokens are never stored in the clear": "TestCallbackExchangesAndRecordsConnection",
|
||||||
|
|
||||||
|
// paste_url.feature
|
||||||
|
"Paste a valid YouTube URL": "TestPasteValidURLAddsAndRequests",
|
||||||
|
"Pasting an invalid link is rejected": "TestPasteInvalidURLRejected",
|
||||||
|
"Pasting a video that cannot be found is honest": "TestPasteVideoNotFound",
|
||||||
|
"Pasting the same video twice does not duplicate it": "TestPasteDedupNoDuplicate",
|
||||||
"Revoking a connection stops watching but keeps history": "TestDisconnectRemovesTokenAndConnectionKeepsAccount",
|
"Revoking a connection stops watching but keeps history": "TestDisconnectRemovesTokenAndConnectionKeepsAccount",
|
||||||
|
|
||||||
// summarize_mode.feature
|
// summarize_mode.feature
|
||||||
|
|||||||
Reference in New Issue
Block a user