feat(adapters): add captions-first YouTube VideoSource
Implements ports.VideoSource against the YouTube Data API v3: ListSubscriptions (paginated), NewVideos (recent per channel), and captions-first FetchTranscript — an absent caption track yields domain.SourceNone (not an error) per ADR-007, with no audio download or speech-to-text. OAuth is written fresh on golang.org/x/oauth2 (ADR-006, distinct from ingestion's inbound MCP auth); the Google token endpoint is inlined to avoid the heavy x/oauth2/google dep. The per-connection refresh token is resolved through the SecretStore port from an opaque TokenSecretRef and is never stored on the adapter or logged. Unit-tested against an httptest server + fake SecretStore (no live googleapis egress): subscriptions list/pagination, new-video detection, captions present -> Source set, captions absent -> SourceNone no error, and secret-ref resolution failure surfacing as an error. oauth2 pinned to v0.30.0 to keep the go directive at 1.23.x (koala runner), not the v0.36 line that requires a newer toolchain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
package youtube
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||
)
|
||||
|
||||
// --- fakes ------------------------------------------------------------------
|
||||
|
||||
// fakeSecrets records the references it was asked to resolve, proving the
|
||||
// adapter resolves OAuth material by reference (never holding the token itself).
|
||||
type fakeSecrets struct {
|
||||
byRef map[string]string
|
||||
requested []string
|
||||
}
|
||||
|
||||
func (f *fakeSecrets) Get(_ context.Context, ref string) (string, error) {
|
||||
f.requested = append(f.requested, ref)
|
||||
v, ok := f.byRef[ref]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no secret for ref %q", ref)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// newTestAdapter wires an adapter against an httptest server: BaseURL points at
|
||||
// the server and the server's transport is injected so the live OAuth2 exchange
|
||||
// is skipped while SecretStore resolution still runs.
|
||||
func newTestAdapter(t *testing.T, handler http.HandlerFunc) (*Adapter, *fakeSecrets) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
secrets := &fakeSecrets{byRef: map[string]string{
|
||||
"op://HomeLab/tapir-youtube#refresh": "super-secret-refresh-token",
|
||||
}}
|
||||
a := New(Config{
|
||||
ClientID: "cid",
|
||||
ClientSecret: "csecret",
|
||||
TokenSecretRef: "op://HomeLab/tapir-youtube#refresh",
|
||||
ConnectionID: "conn-1",
|
||||
PreferredLanguages: []string{"en"},
|
||||
BaseURL: srv.URL,
|
||||
}, secrets)
|
||||
a.transport = srv.Client().Transport
|
||||
return a, secrets
|
||||
}
|
||||
|
||||
// --- subscriptions ----------------------------------------------------------
|
||||
|
||||
func TestListSubscriptions(t *testing.T) {
|
||||
a, secrets := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/subscriptions" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("mine"); got != "true" {
|
||||
t.Errorf("expected mine=true, got %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"items": [
|
||||
{"id": "sub-a", "snippet": {"title": "Acme Talks", "resourceId": {"channelId": "UC_acme"}}},
|
||||
{"id": "sub-b", "snippet": {"title": "Beta Lab", "resourceId": {"channelId": "UC_beta"}}}
|
||||
]
|
||||
}`))
|
||||
})
|
||||
|
||||
subs, err := a.ListSubscriptions(context.Background(), "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 2 {
|
||||
t.Fatalf("expected 2 subscriptions, got %d", len(subs))
|
||||
}
|
||||
if subs[0].ChannelID != "UC_acme" || subs[0].ChannelTitle != "Acme Talks" {
|
||||
t.Errorf("unexpected first subscription: %+v", subs[0])
|
||||
}
|
||||
if subs[0].UserID != "u1" || subs[0].ConnectionID != "conn-1" || !subs[0].Active {
|
||||
t.Errorf("subscription not wired to user/connection: %+v", subs[0])
|
||||
}
|
||||
// The refresh token was resolved by reference, never read from config directly.
|
||||
if len(secrets.requested) == 0 || secrets.requested[0] != "op://HomeLab/tapir-youtube#refresh" {
|
||||
t.Errorf("expected token resolved by ref, got %v", secrets.requested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSubscriptionsPaginates(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("pageToken") {
|
||||
case "":
|
||||
_, _ = w.Write([]byte(`{"nextPageToken":"p2","items":[{"id":"s1","snippet":{"title":"One","resourceId":{"channelId":"c1"}}}]}`))
|
||||
case "p2":
|
||||
_, _ = w.Write([]byte(`{"items":[{"id":"s2","snippet":{"title":"Two","resourceId":{"channelId":"c2"}}}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected pageToken %q", r.URL.Query().Get("pageToken"))
|
||||
}
|
||||
})
|
||||
|
||||
subs, err := a.ListSubscriptions(context.Background(), "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubscriptions: %v", err)
|
||||
}
|
||||
if len(subs) != 2 {
|
||||
t.Fatalf("expected 2 subscriptions across pages, got %d", len(subs))
|
||||
}
|
||||
}
|
||||
|
||||
// --- new videos -------------------------------------------------------------
|
||||
|
||||
func TestNewVideos(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/search" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("channelId"); got != "UC_acme" {
|
||||
t.Errorf("expected channelId=UC_acme, got %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{
|
||||
"items": [
|
||||
{"id": {"videoId": "vid1"}, "snippet": {"title": "Designing for Attention", "publishedAt": "2026-06-01T10:00:00Z"}},
|
||||
{"id": {"videoId": "vid2"}, "snippet": {"title": "Second", "publishedAt": "2026-05-31T10:00:00Z"}}
|
||||
]
|
||||
}`))
|
||||
})
|
||||
|
||||
sub := domain.Subscription{ID: "s1", UserID: "u1", ChannelID: "UC_acme"}
|
||||
vids, err := a.NewVideos(context.Background(), sub)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVideos: %v", err)
|
||||
}
|
||||
if len(vids) != 2 {
|
||||
t.Fatalf("expected 2 videos, got %d", len(vids))
|
||||
}
|
||||
v := vids[0]
|
||||
if v.ProviderVideoID != "vid1" || v.Title != "Designing for Attention" {
|
||||
t.Errorf("unexpected video: %+v", v)
|
||||
}
|
||||
if v.Provider != domain.ProviderYouTube || v.URL != "https://www.youtube.com/watch?v=vid1" {
|
||||
t.Errorf("video not wired correctly: %+v", v)
|
||||
}
|
||||
if v.UserID != "u1" || v.SubscriptionID != "s1" {
|
||||
t.Errorf("video not scoped to user/subscription: %+v", v)
|
||||
}
|
||||
if v.PublishedAt.IsZero() {
|
||||
t.Errorf("expected publishedAt parsed, got zero")
|
||||
}
|
||||
}
|
||||
|
||||
// --- transcript: captions present ------------------------------------------
|
||||
|
||||
func TestFetchTranscriptWithCaptions(t *testing.T) {
|
||||
a, secrets := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/captions":
|
||||
if got := r.URL.Query().Get("videoId"); got != "vid1" {
|
||||
t.Errorf("expected videoId=vid1, got %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"items":[
|
||||
{"id":"cap-sv","snippet":{"language":"sv","trackKind":"standard","status":"serving"}},
|
||||
{"id":"cap-en","snippet":{"language":"en","trackKind":"standard","status":"serving"}}
|
||||
]}`))
|
||||
case "/captions/cap-en":
|
||||
if got := r.URL.Query().Get("tfmt"); got != "vtt" {
|
||||
t.Errorf("expected tfmt=vtt, got %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte("WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.000\nHello <c>world</c>\n\n2\n00:00:02.000 --> 00:00:04.000\nHello world\n\n3\n00:00:04.000 --> 00:00:06.000\nSecond line\n"))
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
})
|
||||
|
||||
v := domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"}
|
||||
tr, err := a.FetchTranscript(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchTranscript: %v", err)
|
||||
}
|
||||
if tr.Source != domain.SourceCaptions {
|
||||
t.Fatalf("expected SourceCaptions, got %q", tr.Source)
|
||||
}
|
||||
if tr.Language != "en" {
|
||||
t.Errorf("expected preferred language en, got %q", tr.Language)
|
||||
}
|
||||
// Markup stripped, timestamps/indices dropped, consecutive duplicate collapsed.
|
||||
want := "Hello world\nSecond line"
|
||||
if tr.Content != want {
|
||||
t.Errorf("transcript text mismatch:\n got %q\nwant %q", tr.Content, want)
|
||||
}
|
||||
if !tr.HasText() {
|
||||
t.Error("expected HasText() true")
|
||||
}
|
||||
if len(secrets.requested) == 0 {
|
||||
t.Error("expected SecretStore consulted for OAuth token")
|
||||
}
|
||||
}
|
||||
|
||||
// --- transcript: no captions => SourceNone, no error ------------------------
|
||||
|
||||
func TestFetchTranscriptNoCaptionsReturnsSourceNone(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/captions" {
|
||||
t.Errorf("download must not be attempted when no track exists; got path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"items":[]}`))
|
||||
})
|
||||
|
||||
v := domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid-nocaps"}
|
||||
tr, err := a.FetchTranscript(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for missing captions, got %v", err)
|
||||
}
|
||||
if tr.Source != domain.SourceNone {
|
||||
t.Fatalf("expected SourceNone, got %q", tr.Source)
|
||||
}
|
||||
if tr.HasText() {
|
||||
t.Error("expected HasText() false for SourceNone")
|
||||
}
|
||||
if tr.Content != "" {
|
||||
t.Errorf("expected empty content, got %q", tr.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing-token reference surfaces as an error rather than a silent skip.
|
||||
func TestFetchTranscriptSecretResolutionError(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"items":[]}`))
|
||||
})
|
||||
a.cfg.TokenSecretRef = "op://HomeLab/missing#refresh"
|
||||
|
||||
_, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", ProviderVideoID: "x"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when secret ref cannot be resolved")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user