From 8b14ef4add087eac9217316f81024cb82472d33f Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 23:08:13 +0200 Subject: [PATCH] feat(youtube): acquire captions via player/timedtext baseUrl (ADR-010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- DECISIONS.md | 52 ++- internal/adapters/youtube/captions.go | 443 +++++++++++++++++----- internal/adapters/youtube/youtube.go | 23 +- internal/adapters/youtube/youtube_test.go | 139 +++++-- 4 files changed, 530 insertions(+), 127 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 643cbc7..6fb51dd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -120,7 +120,8 @@ secret surface (see VISION Stage 2); it gets the existing, vetted secret path. ## ADR-007 — Captions-first; audio-download + speech-to-text deferred -**Status:** Accepted (2026-06-02) +**Status:** Accepted (2026-06-02). Acquisition mechanism superseded by ADR-010 (Data API +`captions.download` → player/timedtext baseUrl); captions-first stance and STT deferral stand. **Context.** YouTube's Data API does not expose transcripts. Options: official captions (clean, limited coverage) vs. audio download + local Whisper (broad coverage, ToS-grey, @@ -153,6 +154,55 @@ governs advancement. Reversible: if demand appears, a new ADR opens the Future C --- +## ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl + +**Status:** Accepted (2026-06-02) + +**Context.** ADR-007 settled *captions-first*. The first build used the YouTube Data API +`captions.list` + `captions.download` endpoints to acquire them. A live Stage-0 run proved that +`captions.download` is **owner-only**: it requires the OAuth identity to own the video, so every +subscription video the user does *not* own returns HTTP 403. Result: 0 summaries produced and a +~150-line error spew. The captions-first decision is sound; only the *acquisition mechanism* was +wrong. + +**Decision.** Acquire captions from the **player response + timedtext baseUrl**, not the Data API +`captions` endpoints: + +1. `POST https://www.youtube.com/youtubei/v1/player` with an **InnerTube `ANDROID` client + context** (no API key, no OAuth). Read + `captions.playerCaptionsTracklistRenderer.captionTracks[]`. Each track carries `baseUrl`, + `languageCode`, and `kind` (`"asr"` = auto-generated). +2. Select by `PreferredLanguages`, preferring non-`asr` when both exist. +3. **GET the track's `baseUrl` unauthenticated** (plain `http.Client`, no OAuth token attached — + the token can break the timedtext endpoint). The ANDROID `baseUrl` is pinned to `fmt=srv3` + (timedtext XML); the parser also accepts `json3` and the legacy `` XML. + +A **watch-page scrape** of `ytInitialPlayerResponse` is the documented fallback if InnerTube +returns no `captionTracks`. + +**Live validation (from koala, 2026-06-02):** the `ANDROID` InnerTube client returned 6 +`captionTracks` with working `baseUrl`s for a public video the run identity does not own, and the +unauthenticated `baseUrl` GET returned real transcript text. `ANDROID` is the client of record +(historically returns baseUrls without a PoToken). The `WEB` client and watch-page scrape are +fallbacks. + +**Consequences.** + +- Works for **any public captioned video**, not just owned ones — this is the fix for the 403 wall. +- **ToS-grey:** `youtubei`/`timedtext` are unofficial endpoints. They can break when Google shifts + InnerTube client requirements or introduces PoToken gating. Mitigation: degrade, never error — a + missing/empty/403/unparseable caption yields `domain.Transcript{Source: SourceNone}`, so a future + breakage produces "no transcript" rather than a crash or error spew. Only genuine transport + (network) faults error. +- **No OAuth** is needed for the transcript fetch. OAuth is still required for `ListSubscriptions` + and `NewVideos` (Data API) — only the transcript path goes unauthenticated. +- **Still no Whisper.** Speech-to-text stays deferred (ADR-007 unchanged). + +**Supersedes:** the *acquisition mechanism* of ADR-007 (Data API `captions.download` → +player/timedtext baseUrl) only. ADR-007's captions-first stance and the STT deferral stand. + +--- + ## ADR-009 — Trunk-Based Development **Status:** Accepted (2026-06-02) diff --git a/internal/adapters/youtube/captions.go b/internal/adapters/youtube/captions.go index d63af30..5797bdc 100644 --- a/internal/adapters/youtube/captions.go +++ b/internal/adapters/youtube/captions.go @@ -1,150 +1,365 @@ package youtube import ( + "bytes" "context" + "encoding/json" + "encoding/xml" "fmt" + "io" "net/http" - "net/url" - "regexp" "strings" "gitea.d-ma.be/mathias/tapir/internal/domain" ) -// FetchTranscript resolves a transcript captions-first (ADR-007): -// -// - List the video's caption tracks. No track => domain.SourceNone, no error. -// - Select a track (preferred language first, else the first track) and -// download it as WebVTT, stripping cue timing/markup to plain text. -// -// Audio download and speech-to-text are deliberately absent: no captions means -// SourceNone, full stop. -func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) { - client, err := a.httpClient(ctx, a.cfg.TokenSecretRef) - if err != nil { - return domain.Transcript{}, err - } +// defaultPlayerBaseURL is the InnerTube / watch-page host. Overridable via +// Config.PlayerBaseURL (tests point it at an httptest server). +const defaultPlayerBaseURL = "https://www.youtube.com" - tracks, err := a.listCaptionTracks(ctx, client, v.ProviderVideoID) +// androidUserAgent identifies the InnerTube ANDROID client. ANDROID historically +// returns caption baseUrls without a PoToken requirement (ADR-010). +const androidUserAgent = "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip" + +// maxCaptionBytes bounds a single timedtext/player body read. +const maxCaptionBytes = 16 << 20 // 16 MiB + +// FetchTranscript resolves a transcript captions-first (ADR-007) via the player +// response + timedtext baseUrl, unauthenticated (ADR-010 — Data API +// captions.download is owner-only and 403s for videos the user does not own): +// +// - POST the InnerTube player endpoint (ANDROID client) and read +// captions.playerCaptionsTracklistRenderer.captionTracks[]. None => SourceNone. +// - Select a track (preferred language, non-asr first) and GET its baseUrl with +// a plain http.Client (no OAuth token — it can break the timedtext endpoint), +// parsing srv3 XML / json3 / legacy XML to plain text. +// +// Degrade, don't error (ADR-010): no captionTracks, empty baseUrl, a non-200 +// fetch, or an unparseable body all yield SourceNone rather than an error. Only +// genuine transport (network) faults return an error. Audio download and +// speech-to-text remain absent (ADR-007). +func (a *Adapter) FetchTranscript(ctx context.Context, v domain.Video) (domain.Transcript, error) { + client := a.plainClient() + + tracks, err := a.captionTracks(ctx, client, v.ProviderVideoID) if err != nil { - return domain.Transcript{}, fmt.Errorf("list captions for %q: %w", v.ProviderVideoID, err) + return domain.Transcript{}, fmt.Errorf("resolve caption tracks for %q: %w", v.ProviderVideoID, err) } track, ok := a.selectTrack(tracks) - if !ok { - // No usable caption track: a recorded "checked, none available", not an error. - return domain.Transcript{ - VideoID: v.ID, - UserID: v.UserID, - Source: domain.SourceNone, - }, nil + if !ok || strings.TrimSpace(track.BaseURL) == "" { + return noTranscript(v), nil } - raw, err := a.getRaw(ctx, client, "/captions/"+track.ID, url.Values{"tfmt": {"vtt"}}) + raw, status, err := a.httpGet(ctx, client, track.BaseURL, nil) if err != nil { - return domain.Transcript{}, fmt.Errorf("download caption track %q: %w", track.ID, err) + return domain.Transcript{}, fmt.Errorf("download caption track for %q: %w", v.ProviderVideoID, err) + } + if status != http.StatusOK { + // Owner-only 403, region/age gate, or transient unavailability: not an error. + return noTranscript(v), nil } - text := vttToText(string(raw)) + text := timedtextToText(string(raw)) if text == "" { - // Track existed but carried no text: treat as no usable transcript. - return domain.Transcript{ - VideoID: v.ID, - UserID: v.UserID, - Source: domain.SourceNone, - }, nil + return noTranscript(v), nil } return domain.Transcript{ VideoID: v.ID, UserID: v.UserID, Source: domain.SourceCaptions, - Language: track.Snippet.Language, + Language: track.LanguageCode, Content: text, }, nil } -func (a *Adapter) listCaptionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) { - q := url.Values{ - "part": {"snippet"}, - "videoId": {videoID}, - } - var resp captionListResponse - if err := a.getJSON(ctx, client, "/captions", q, &resp); err != nil { - return nil, err - } - return resp.Items, nil +// noTranscript is the recorded "checked, none usable" result — not an error. +func noTranscript(v domain.Video) domain.Transcript { + return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceNone} } -// selectTrack picks the best caption track: the first track whose language -// matches a configured preferred language, else the first track. Returns ok == -// false when there are no tracks at all. +// captionTracks resolves a video's caption tracks from the player response. It +// tries the InnerTube ANDROID client first and falls back to scraping +// ytInitialPlayerResponse from the watch page. A nil slice (no tracks) is a +// degrade, not an error; only transport faults return an error. +func (a *Adapter) captionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) { + tracks, err := a.playerCaptionTracks(ctx, client, videoID) + if err != nil { + return nil, err + } + if len(tracks) > 0 { + return tracks, nil + } + // Fallback: scrape the watch page (no PoToken/InnerTube context needed). + return a.scrapeCaptionTracks(ctx, client, videoID) +} + +// playerCaptionTracks POSTs the InnerTube player endpoint with the ANDROID client +// context (unauthenticated) and returns its caption tracks. +func (a *Adapter) playerCaptionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) { + reqBody, err := json.Marshal(playerRequest{ + Context: innertubeContext{Client: innertubeClient{ + ClientName: "ANDROID", + ClientVersion: "20.10.38", + AndroidSDKVersion: 30, + HL: "en", + GL: "US", + }}, + VideoID: videoID, + }) + if err != nil { + return nil, fmt.Errorf("encode player request: %w", err) + } + + body, status, err := a.httpPost(ctx, client, a.playerBaseURL+"/youtubei/v1/player", reqBody) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, nil // degrade + } + + var resp playerResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, nil // unparseable => degrade + } + return resp.Captions.PlayerCaptionsTracklistRenderer.CaptionTracks, nil +} + +// ytInitialMarker locates the embedded player JSON on the watch page. +const ytInitialMarker = "ytInitialPlayerResponse" + +// scrapeCaptionTracks fetches the watch page and extracts caption tracks from the +// embedded ytInitialPlayerResponse JSON. Any failure degrades to no tracks. +func (a *Adapter) scrapeCaptionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) { + body, status, err := a.httpGet(ctx, client, a.playerBaseURL+"/watch?v="+videoID, map[string]string{ + "User-Agent": androidUserAgent, + }) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, nil + } + obj, ok := extractJSONObject(body, ytInitialMarker) + if !ok { + return nil, nil + } + var resp playerResponse + if err := json.Unmarshal(obj, &resp); err != nil { + return nil, nil + } + return resp.Captions.PlayerCaptionsTracklistRenderer.CaptionTracks, nil +} + +// selectTrack picks the best caption track: a preferred-language non-asr track +// first, then a preferred-language asr track, then any non-asr track, else the +// first track. Returns ok == false when there are no tracks at all. func (a *Adapter) selectTrack(tracks []captionTrack) (captionTrack, bool) { if len(tracks) == 0 { return captionTrack{}, false } - for _, pref := range a.cfg.PreferredLanguages { - for _, t := range tracks { - if strings.EqualFold(t.Snippet.Language, pref) { - return t, true + for _, asr := range []bool{false, true} { + for _, pref := range a.cfg.PreferredLanguages { + for _, t := range tracks { + if t.isASR() == asr && matchLang(t.LanguageCode, pref) { + return t, true + } } } } + for _, t := range tracks { + if !t.isASR() { + return t, true + } + } return tracks[0], true } -// --- caption response shapes ------------------------------------------------ +// matchLang matches a track language against a preferred code, tolerating region +// suffixes (preferred "en" matches "en", "en-US", "en-GB"). +func matchLang(code, pref string) bool { + if strings.EqualFold(code, pref) { + return true + } + return strings.HasPrefix(strings.ToLower(code), strings.ToLower(pref)+"-") +} -type captionListResponse struct { - Items []captionTrack `json:"items"` +// --- HTTP (plain, unauthenticated) ------------------------------------------ + +// plainClient returns an unauthenticated HTTP client. No OAuth token is attached: +// the player/timedtext endpoints can reject authenticated requests (ADR-010). +// Tests inject the httptest transport via a.transport. +func (a *Adapter) plainClient() *http.Client { + if a.transport != nil { + return &http.Client{Transport: a.transport} + } + return &http.Client{} +} + +func (a *Adapter) httpGet(ctx context.Context, client *http.Client, url string, headers map[string]string) ([]byte, int, error) { + return a.httpDo(ctx, client, http.MethodGet, url, nil, headers) +} + +func (a *Adapter) httpPost(ctx context.Context, client *http.Client, url string, body []byte) ([]byte, int, error) { + return a.httpDo(ctx, client, http.MethodPost, url, body, map[string]string{ + "Content-Type": "application/json", + "User-Agent": androidUserAgent, + }) +} + +// httpDo issues a request and returns (body, status, err). A non-nil err is a +// genuine transport fault; a non-200 status is returned to the caller to decide +// (callers degrade rather than error per ADR-010). +func (a *Adapter) httpDo(ctx context.Context, client *http.Client, method, url string, body []byte, headers map[string]string) ([]byte, int, error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) + if err != nil { + return nil, 0, fmt.Errorf("build %s %s: %w", method, url, err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("%s %s: %w", method, url, err) + } + defer func() { _ = resp.Body.Close() }() + b, err := io.ReadAll(io.LimitReader(resp.Body, maxCaptionBytes)) + if err != nil { + return nil, 0, fmt.Errorf("read %s %s body: %w", method, url, err) + } + return b, resp.StatusCode, nil +} + +// --- player / caption response shapes --------------------------------------- + +type playerRequest struct { + Context innertubeContext `json:"context"` + VideoID string `json:"videoId"` +} + +type innertubeContext struct { + Client innertubeClient `json:"client"` +} + +type innertubeClient struct { + ClientName string `json:"clientName"` + ClientVersion string `json:"clientVersion"` + AndroidSDKVersion int `json:"androidSdkVersion,omitempty"` + HL string `json:"hl"` + GL string `json:"gl"` +} + +type playerResponse struct { + Captions struct { + PlayerCaptionsTracklistRenderer struct { + CaptionTracks []captionTrack `json:"captionTracks"` + } `json:"playerCaptionsTracklistRenderer"` + } `json:"captions"` } type captionTrack struct { - ID string `json:"id"` - Snippet struct { - Language string `json:"language"` - TrackKind string `json:"trackKind"` // "standard" | "ASR" | "forced" - Name string `json:"name"` - Status string `json:"status"` - } `json:"snippet"` + BaseURL string `json:"baseUrl"` + LanguageCode string `json:"languageCode"` + Kind string `json:"kind"` // "asr" for auto-generated } -// --- WebVTT -> plain text --------------------------------------------------- +func (t captionTrack) isASR() bool { return strings.EqualFold(t.Kind, "asr") } -var ( - vttCueTime = regexp.MustCompile(`-->`) - vttTag = regexp.MustCompile(`<[^>]+>`) // inline <00:00:01.000>, markup - vttIndex = regexp.MustCompile(`^\d+$`) // SRT-style numeric cue index - vttSetting = regexp.MustCompile(`^(NOTE|STYLE|REGION)`) // VTT block headers -) +// --- timedtext -> plain text ------------------------------------------------ -// vttToText reduces a WebVTT (or SRT-ish) caption file to plain transcript text: -// the WEBVTT header, NOTE/STYLE blocks, cue-timing lines, numeric indices, and -// inline markup are dropped; consecutive duplicate lines (common in rolling -// auto-captions) are collapsed. -func vttToText(raw string) string { - raw = strings.ReplaceAll(raw, "\r\n", "\n") +// timedtextToText reduces a timedtext caption body to plain transcript text. It +// auto-detects the format: json3 (a JSON object), else XML (srv3

cues or the +// legacy form). Whitespace within a cue is normalised and +// consecutive duplicate lines (common in rolling auto-captions) are collapsed. +func timedtextToText(raw string) string { + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "{") { + return collapse(json3Lines(trimmed)) + } + return collapse(xmlLines(trimmed)) +} + +type json3Body struct { + Events []struct { + Segs []struct { + Utf8 string `json:"utf8"` + } `json:"segs"` + } `json:"events"` +} + +func json3Lines(raw string) []string { + var doc json3Body + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + return nil + } + var lines []string + for _, ev := range doc.Events { + var b strings.Builder + for _, s := range ev.Segs { + b.WriteString(s.Utf8) + } + if line := normalize(b.String()); line != "" { + lines = append(lines, line) + } + } + return lines +} + +type timedtextXML struct { + Ps []struct { + Chardata string `xml:",chardata"` + Segs []struct { + Chardata string `xml:",chardata"` + } `xml:"s"` + } `xml:"body>p"` + // Legacy format: .... + Texts []string `xml:"text"` +} + +func xmlLines(raw string) []string { + var doc timedtextXML + if err := xml.Unmarshal([]byte(raw), &doc); err != nil { + return nil + } + var lines []string + for _, p := range doc.Ps { + var b strings.Builder + b.WriteString(p.Chardata) + for _, s := range p.Segs { + b.WriteString(s.Chardata) + } + if line := normalize(b.String()); line != "" { + lines = append(lines, line) + } + } + if len(lines) == 0 { + for _, t := range doc.Texts { + if line := normalize(t); line != "" { + lines = append(lines, line) + } + } + } + return lines +} + +// normalize collapses internal whitespace (incl. intra-cue newlines) to single +// spaces and trims. encoding/xml and encoding/json already decode entities. +func normalize(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// collapse drops consecutive duplicate lines and joins with newlines. +func collapse(lines []string) string { var out []string var prev string - for _, line := range strings.Split(raw, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if strings.HasPrefix(line, "WEBVTT") { - continue - } - if vttSetting.MatchString(line) { - continue - } - if vttCueTime.MatchString(line) { - continue - } - if vttIndex.MatchString(line) { - continue - } - line = strings.TrimSpace(vttTag.ReplaceAllString(line, "")) - if line == "" || line == prev { + for _, line := range lines { + if line == prev { continue } out = append(out, line) @@ -152,3 +367,45 @@ func vttToText(raw string) string { } return strings.Join(out, "\n") } + +// extractJSONObject finds marker in body and returns the first balanced JSON +// object that follows it (brace-matched, string-aware). +func extractJSONObject(body []byte, marker string) ([]byte, bool) { + i := bytes.Index(body, []byte(marker)) + if i < 0 { + return nil, false + } + s := body[i+len(marker):] + j := bytes.IndexByte(s, '{') + if j < 0 { + return nil, false + } + s = s[j:] + depth, inStr, esc := 0, false, false + for k := 0; k < len(s); k++ { + c := s[k] + if inStr { + switch { + case esc: + esc = false + case c == '\\': + esc = true + case c == '"': + inStr = false + } + continue + } + switch c { + case '"': + inStr = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[:k+1], true + } + } + } + return nil, false +} diff --git a/internal/adapters/youtube/youtube.go b/internal/adapters/youtube/youtube.go index c315ed1..5ae4c16 100644 --- a/internal/adapters/youtube/youtube.go +++ b/internal/adapters/youtube/youtube.go @@ -68,15 +68,21 @@ type Config struct { // BaseURL overrides the Data API root. Empty means defaultBaseURL. BaseURL string + + // PlayerBaseURL overrides the InnerTube / watch-page host used for + // unauthenticated transcript acquisition (ADR-010). Empty means + // defaultPlayerBaseURL. Tests point it at an httptest server. + PlayerBaseURL string } const defaultMaxVideos = 10 // Adapter implements ports.VideoSource for YouTube. type Adapter struct { - cfg Config - secrets ports.SecretStore - baseURL string + cfg Config + secrets ports.SecretStore + baseURL string + playerBaseURL string // transport, when non-nil, replaces the live OAuth2 transport for API calls. // Production leaves it nil and an oauth2-authorized client is built per call. @@ -92,10 +98,15 @@ func New(cfg Config, secrets ports.SecretStore) *Adapter { if base == "" { base = defaultBaseURL } + player := cfg.PlayerBaseURL + if player == "" { + player = defaultPlayerBaseURL + } return &Adapter{ - cfg: cfg, - secrets: secrets, - baseURL: strings.TrimRight(base, "/"), + cfg: cfg, + secrets: secrets, + baseURL: strings.TrimRight(base, "/"), + playerBaseURL: strings.TrimRight(player, "/"), } } diff --git a/internal/adapters/youtube/youtube_test.go b/internal/adapters/youtube/youtube_test.go index 6eb0abe..734c906 100644 --- a/internal/adapters/youtube/youtube_test.go +++ b/internal/adapters/youtube/youtube_test.go @@ -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(`

WRONG asr track

`)) + default: + if got := r.URL.Query().Get("lang"); got != "en" { + t.Errorf("expected en track selected, got lang=%q", got) + } + _, _ = w.Write([]byte(`` + + `

Hello world

` + + `

Hello world

` + + `

Second line

` + + `
`)) } - _, _ = w.Write([]byte("WEBVTT\n\n1\n00:00:00.000 --> 00:00:02.000\nHello world\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) } }