feat(youtube): acquire captions via player/timedtext baseUrl (ADR-010)
The Data API captions.download endpoint is owner-only: every subscription video the user does not own returned HTTP 403, producing 0 summaries and a ~150-line error spew in the first live Stage-0 run. Captions-first (ADR-007) is sound; only the acquisition mechanism was wrong. FetchTranscript now resolves caption tracks from the InnerTube player response (ANDROID client, unauthenticated) and GETs the chosen track's timedtext baseUrl with a plain http.Client — no OAuth token, which can break the endpoint. The srv3 XML, json3, and legacy <transcript> formats all parse; non-asr tracks in a preferred language win. Watch-page ytInitialPlayerResponse scrape is the fallback when InnerTube returns no tracks. Degrade, don't error (explicit quick-fix): no captionTracks, empty baseUrl, a non-200 fetch, or an unparseable body yield Source=none, not an error. Only genuine transport faults error — this kills the spew. OAuth stays on ListSubscriptions/NewVideos (Data API); only transcript fetch goes unauthed. Validated live from koala: the ANDROID client returned working baseUrls and real transcript text for public videos the run identity does not own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ func newTestAdapter(t *testing.T, handler http.HandlerFunc) (*Adapter, *fakeSecr
|
||||
ConnectionID: "conn-1",
|
||||
PreferredLanguages: []string{"en"},
|
||||
BaseURL: srv.URL,
|
||||
PlayerBaseURL: srv.URL,
|
||||
}, secrets)
|
||||
a.transport = srv.Client().Transport
|
||||
return a, secrets
|
||||
@@ -150,24 +151,51 @@ func TestNewVideos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- transcript: captions present ------------------------------------------
|
||||
// --- transcript: captions present (player response + timedtext baseUrl) ------
|
||||
|
||||
// captionTracksJSON builds a player-response body whose captionTracks point their
|
||||
// baseUrls back at the test server (via the request Host), so the unauthenticated
|
||||
// baseUrl GET lands on the same httptest handler.
|
||||
func playerBody(host string) string {
|
||||
base := "http://" + host
|
||||
return `{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[
|
||||
{"baseUrl":"` + base + `/api/timedtext?lang=sv","languageCode":"sv"},
|
||||
{"baseUrl":"` + base + `/api/timedtext?lang=en&kind=asr","languageCode":"en","kind":"asr"},
|
||||
{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}
|
||||
]}}}`
|
||||
}
|
||||
|
||||
// TestFetchTranscriptWithCaptions: ANDROID player response yields tracks; the en
|
||||
// non-asr track is preferred over both the sv track and the en asr track; its
|
||||
// srv3 timedtext XML is fetched unauthenticated and reduced to plain text.
|
||||
func TestFetchTranscriptWithCaptions(t *testing.T) {
|
||||
a, secrets := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
a, _ := 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)
|
||||
case "/youtubei/v1/player":
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("player must be POST, got %s", r.Method)
|
||||
}
|
||||
_, _ = 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)
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
t.Errorf("player request must not be authenticated, got Authorization=%q", h)
|
||||
}
|
||||
_, _ = w.Write([]byte(playerBody(r.Host)))
|
||||
case "/api/timedtext":
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
t.Errorf("timedtext request must not be authenticated, got Authorization=%q", h)
|
||||
}
|
||||
switch r.URL.Query().Get("kind") {
|
||||
case "asr":
|
||||
_, _ = w.Write([]byte(`<timedtext format="3"><body><p t="0" d="1">WRONG asr track</p></body></timedtext>`))
|
||||
default:
|
||||
if got := r.URL.Query().Get("lang"); got != "en" {
|
||||
t.Errorf("expected en track selected, got lang=%q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8" ?><timedtext format="3"><body>` +
|
||||
`<p t="0" d="2000">Hello world</p>` +
|
||||
`<p t="2000" d="2000">Hello world</p>` +
|
||||
`<p t="4000" d="2000">Second line</p>` +
|
||||
`</body></timedtext>`))
|
||||
}
|
||||
_, _ = 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)
|
||||
}
|
||||
@@ -184,7 +212,7 @@ func TestFetchTranscriptWithCaptions(t *testing.T) {
|
||||
if tr.Language != "en" {
|
||||
t.Errorf("expected preferred language en, got %q", tr.Language)
|
||||
}
|
||||
// Markup stripped, timestamps/indices dropped, consecutive duplicate collapsed.
|
||||
// Consecutive duplicate cue collapsed; asr track not chosen.
|
||||
want := "Hello world\nSecond line"
|
||||
if tr.Content != want {
|
||||
t.Errorf("transcript text mismatch:\n got %q\nwant %q", tr.Content, want)
|
||||
@@ -192,8 +220,35 @@ func TestFetchTranscriptWithCaptions(t *testing.T) {
|
||||
if !tr.HasText() {
|
||||
t.Error("expected HasText() true")
|
||||
}
|
||||
if len(secrets.requested) == 0 {
|
||||
t.Error("expected SecretStore consulted for OAuth token")
|
||||
}
|
||||
|
||||
// TestFetchTranscriptJSON3: a json3 timedtext body parses to clean text.
|
||||
func TestFetchTranscriptJSON3(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/youtubei/v1/player":
|
||||
base := "http://" + r.Host
|
||||
_, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` +
|
||||
`{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`))
|
||||
case "/api/timedtext":
|
||||
_, _ = w.Write([]byte(`{"events":[` +
|
||||
`{"segs":[{"utf8":"Hello "},{"utf8":"world"}]},` +
|
||||
`{"segs":[{"utf8":"\n"}]},` +
|
||||
`{"segs":[{"utf8":"Second line"}]}]}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
})
|
||||
|
||||
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchTranscript: %v", err)
|
||||
}
|
||||
if tr.Source != domain.SourceCaptions {
|
||||
t.Fatalf("expected SourceCaptions, got %q", tr.Source)
|
||||
}
|
||||
if want := "Hello world\nSecond line"; tr.Content != want {
|
||||
t.Errorf("json3 text mismatch:\n got %q\nwant %q", tr.Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +256,11 @@ func TestFetchTranscriptWithCaptions(t *testing.T) {
|
||||
|
||||
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)
|
||||
if r.URL.Path == "/api/timedtext" {
|
||||
t.Errorf("timedtext must not be fetched when no track exists")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"items":[]}`))
|
||||
// Player response with no captions block at all.
|
||||
_, _ = w.Write([]byte(`{"videoDetails":{"videoId":"vid-nocaps"}}`))
|
||||
})
|
||||
|
||||
v := domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid-nocaps"}
|
||||
@@ -223,15 +279,44 @@ func TestFetchTranscriptNoCaptionsReturnsSourceNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 403/empty baseUrl fetch degrades to SourceNone, never an error (the explicit
|
||||
// quick-fix: a finicky unofficial endpoint must not produce an error spew).
|
||||
func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/youtubei/v1/player":
|
||||
base := "http://" + r.Host
|
||||
_, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` +
|
||||
`{"baseUrl":"` + base + `/api/timedtext?lang=en","languageCode":"en"}]}}}`))
|
||||
case "/api/timedtext":
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
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")
|
||||
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
|
||||
if err != nil {
|
||||
t.Fatalf("403 on baseUrl must degrade, not error: %v", err)
|
||||
}
|
||||
if tr.Source != domain.SourceNone {
|
||||
t.Fatalf("expected SourceNone on 403, got %q", tr.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty baseUrl on the selected track degrades to SourceNone, never an error.
|
||||
func TestFetchTranscriptEmptyBaseURLDegrades(t *testing.T) {
|
||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/timedtext" {
|
||||
t.Errorf("must not fetch an empty baseUrl")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[` +
|
||||
`{"baseUrl":"","languageCode":"en"}]}}}`))
|
||||
})
|
||||
|
||||
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
|
||||
if err != nil {
|
||||
t.Fatalf("empty baseUrl must degrade, not error: %v", err)
|
||||
}
|
||||
if tr.Source != domain.SourceNone {
|
||||
t.Fatalf("expected SourceNone on empty baseUrl, got %q", tr.Source)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user