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:
+51
-1
@@ -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
|
## 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
|
**Context.** YouTube's Data API does not expose transcripts. Options: official captions
|
||||||
(clean, limited coverage) vs. audio download + local Whisper (broad coverage, ToS-grey,
|
(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 `<transcript>` 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
|
## ADR-009 — Trunk-Based Development
|
||||||
|
|
||||||
**Status:** Accepted (2026-06-02)
|
**Status:** Accepted (2026-06-02)
|
||||||
|
|||||||
@@ -1,150 +1,365 @@
|
|||||||
package youtube
|
package youtube
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetchTranscript resolves a transcript captions-first (ADR-007):
|
// defaultPlayerBaseURL is the InnerTube / watch-page host. Overridable via
|
||||||
//
|
// Config.PlayerBaseURL (tests point it at an httptest server).
|
||||||
// - List the video's caption tracks. No track => domain.SourceNone, no error.
|
const defaultPlayerBaseURL = "https://www.youtube.com"
|
||||||
// - 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
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)
|
track, ok := a.selectTrack(tracks)
|
||||||
if !ok {
|
if !ok || strings.TrimSpace(track.BaseURL) == "" {
|
||||||
// No usable caption track: a recorded "checked, none available", not an error.
|
return noTranscript(v), nil
|
||||||
return domain.Transcript{
|
|
||||||
VideoID: v.ID,
|
|
||||||
UserID: v.UserID,
|
|
||||||
Source: domain.SourceNone,
|
|
||||||
}, 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 {
|
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 == "" {
|
if text == "" {
|
||||||
// Track existed but carried no text: treat as no usable transcript.
|
return noTranscript(v), nil
|
||||||
return domain.Transcript{
|
|
||||||
VideoID: v.ID,
|
|
||||||
UserID: v.UserID,
|
|
||||||
Source: domain.SourceNone,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.Transcript{
|
return domain.Transcript{
|
||||||
VideoID: v.ID,
|
VideoID: v.ID,
|
||||||
UserID: v.UserID,
|
UserID: v.UserID,
|
||||||
Source: domain.SourceCaptions,
|
Source: domain.SourceCaptions,
|
||||||
Language: track.Snippet.Language,
|
Language: track.LanguageCode,
|
||||||
Content: text,
|
Content: text,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adapter) listCaptionTracks(ctx context.Context, client *http.Client, videoID string) ([]captionTrack, error) {
|
// noTranscript is the recorded "checked, none usable" result — not an error.
|
||||||
q := url.Values{
|
func noTranscript(v domain.Video) domain.Transcript {
|
||||||
"part": {"snippet"},
|
return domain.Transcript{VideoID: v.ID, UserID: v.UserID, Source: domain.SourceNone}
|
||||||
"videoId": {videoID},
|
|
||||||
}
|
|
||||||
var resp captionListResponse
|
|
||||||
if err := a.getJSON(ctx, client, "/captions", q, &resp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return resp.Items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectTrack picks the best caption track: the first track whose language
|
// captionTracks resolves a video's caption tracks from the player response. It
|
||||||
// matches a configured preferred language, else the first track. Returns ok ==
|
// tries the InnerTube ANDROID client first and falls back to scraping
|
||||||
// false when there are no tracks at all.
|
// 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) {
|
func (a *Adapter) selectTrack(tracks []captionTrack) (captionTrack, bool) {
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
return captionTrack{}, false
|
return captionTrack{}, false
|
||||||
}
|
}
|
||||||
for _, pref := range a.cfg.PreferredLanguages {
|
for _, asr := range []bool{false, true} {
|
||||||
for _, t := range tracks {
|
for _, pref := range a.cfg.PreferredLanguages {
|
||||||
if strings.EqualFold(t.Snippet.Language, pref) {
|
for _, t := range tracks {
|
||||||
return t, true
|
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
|
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 {
|
// --- HTTP (plain, unauthenticated) ------------------------------------------
|
||||||
Items []captionTrack `json:"items"`
|
|
||||||
|
// 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 {
|
type captionTrack struct {
|
||||||
ID string `json:"id"`
|
BaseURL string `json:"baseUrl"`
|
||||||
Snippet struct {
|
LanguageCode string `json:"languageCode"`
|
||||||
Language string `json:"language"`
|
Kind string `json:"kind"` // "asr" for auto-generated
|
||||||
TrackKind string `json:"trackKind"` // "standard" | "ASR" | "forced"
|
|
||||||
Name string `json:"name"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
} `json:"snippet"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebVTT -> plain text ---------------------------------------------------
|
func (t captionTrack) isASR() bool { return strings.EqualFold(t.Kind, "asr") }
|
||||||
|
|
||||||
var (
|
// --- timedtext -> plain text ------------------------------------------------
|
||||||
vttCueTime = regexp.MustCompile(`-->`)
|
|
||||||
vttTag = regexp.MustCompile(`<[^>]+>`) // inline <00:00:01.000>, <c> markup
|
|
||||||
vttIndex = regexp.MustCompile(`^\d+$`) // SRT-style numeric cue index
|
|
||||||
vttSetting = regexp.MustCompile(`^(NOTE|STYLE|REGION)`) // VTT block headers
|
|
||||||
)
|
|
||||||
|
|
||||||
// vttToText reduces a WebVTT (or SRT-ish) caption file to plain transcript text:
|
// timedtextToText reduces a timedtext caption body to plain transcript text. It
|
||||||
// the WEBVTT header, NOTE/STYLE blocks, cue-timing lines, numeric indices, and
|
// auto-detects the format: json3 (a JSON object), else XML (srv3 <p> cues or the
|
||||||
// inline markup are dropped; consecutive duplicate lines (common in rolling
|
// legacy <transcript><text> form). Whitespace within a cue is normalised and
|
||||||
// auto-captions) are collapsed.
|
// consecutive duplicate lines (common in rolling auto-captions) are collapsed.
|
||||||
func vttToText(raw string) string {
|
func timedtextToText(raw string) string {
|
||||||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
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: <transcript><text start dur>...</text></transcript>.
|
||||||
|
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 out []string
|
||||||
var prev string
|
var prev string
|
||||||
for _, line := range strings.Split(raw, "\n") {
|
for _, line := range lines {
|
||||||
line = strings.TrimSpace(line)
|
if line == prev {
|
||||||
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 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, line)
|
out = append(out, line)
|
||||||
@@ -152,3 +367,45 @@ func vttToText(raw string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(out, "\n")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,15 +68,21 @@ type Config struct {
|
|||||||
|
|
||||||
// BaseURL overrides the Data API root. Empty means defaultBaseURL.
|
// BaseURL overrides the Data API root. Empty means defaultBaseURL.
|
||||||
BaseURL string
|
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
|
const defaultMaxVideos = 10
|
||||||
|
|
||||||
// Adapter implements ports.VideoSource for YouTube.
|
// Adapter implements ports.VideoSource for YouTube.
|
||||||
type Adapter struct {
|
type Adapter struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
secrets ports.SecretStore
|
secrets ports.SecretStore
|
||||||
baseURL string
|
baseURL string
|
||||||
|
playerBaseURL string
|
||||||
|
|
||||||
// transport, when non-nil, replaces the live OAuth2 transport for API calls.
|
// 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.
|
// 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 == "" {
|
if base == "" {
|
||||||
base = defaultBaseURL
|
base = defaultBaseURL
|
||||||
}
|
}
|
||||||
|
player := cfg.PlayerBaseURL
|
||||||
|
if player == "" {
|
||||||
|
player = defaultPlayerBaseURL
|
||||||
|
}
|
||||||
return &Adapter{
|
return &Adapter{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
secrets: secrets,
|
secrets: secrets,
|
||||||
baseURL: strings.TrimRight(base, "/"),
|
baseURL: strings.TrimRight(base, "/"),
|
||||||
|
playerBaseURL: strings.TrimRight(player, "/"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func newTestAdapter(t *testing.T, handler http.HandlerFunc) (*Adapter, *fakeSecr
|
|||||||
ConnectionID: "conn-1",
|
ConnectionID: "conn-1",
|
||||||
PreferredLanguages: []string{"en"},
|
PreferredLanguages: []string{"en"},
|
||||||
BaseURL: srv.URL,
|
BaseURL: srv.URL,
|
||||||
|
PlayerBaseURL: srv.URL,
|
||||||
}, secrets)
|
}, secrets)
|
||||||
a.transport = srv.Client().Transport
|
a.transport = srv.Client().Transport
|
||||||
return a, secrets
|
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) {
|
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 {
|
switch r.URL.Path {
|
||||||
case "/captions":
|
case "/youtubei/v1/player":
|
||||||
if got := r.URL.Query().Get("videoId"); got != "vid1" {
|
if r.Method != http.MethodPost {
|
||||||
t.Errorf("expected videoId=vid1, got %q", got)
|
t.Errorf("player must be POST, got %s", r.Method)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte(`{"items":[
|
if h := r.Header.Get("Authorization"); h != "" {
|
||||||
{"id":"cap-sv","snippet":{"language":"sv","trackKind":"standard","status":"serving"}},
|
t.Errorf("player request must not be authenticated, got Authorization=%q", h)
|
||||||
{"id":"cap-en","snippet":{"language":"en","trackKind":"standard","status":"serving"}}
|
}
|
||||||
]}`))
|
_, _ = w.Write([]byte(playerBody(r.Host)))
|
||||||
case "/captions/cap-en":
|
case "/api/timedtext":
|
||||||
if got := r.URL.Query().Get("tfmt"); got != "vtt" {
|
if h := r.Header.Get("Authorization"); h != "" {
|
||||||
t.Errorf("expected tfmt=vtt, got %q", got)
|
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:
|
default:
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
}
|
}
|
||||||
@@ -184,7 +212,7 @@ func TestFetchTranscriptWithCaptions(t *testing.T) {
|
|||||||
if tr.Language != "en" {
|
if tr.Language != "en" {
|
||||||
t.Errorf("expected preferred language en, got %q", tr.Language)
|
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"
|
want := "Hello world\nSecond line"
|
||||||
if tr.Content != want {
|
if tr.Content != want {
|
||||||
t.Errorf("transcript text mismatch:\n got %q\nwant %q", 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() {
|
if !tr.HasText() {
|
||||||
t.Error("expected HasText() true")
|
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) {
|
func TestFetchTranscriptNoCaptionsReturnsSourceNone(t *testing.T) {
|
||||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
a, _ := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/captions" {
|
if r.URL.Path == "/api/timedtext" {
|
||||||
t.Errorf("download must not be attempted when no track exists; got path %q", r.URL.Path)
|
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"}
|
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.
|
// A 403/empty baseUrl fetch degrades to SourceNone, never an error (the explicit
|
||||||
func TestFetchTranscriptSecretResolutionError(t *testing.T) {
|
// quick-fix: a finicky unofficial endpoint must not produce an error spew).
|
||||||
a, _ := newTestAdapter(t, func(w http.ResponseWriter, _ *http.Request) {
|
func TestFetchTranscriptBaseURLForbiddenDegrades(t *testing.T) {
|
||||||
_, _ = w.Write([]byte(`{"items":[]}`))
|
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"})
|
tr, err := a.FetchTranscript(context.Background(), domain.Video{ID: "v1", UserID: "u1", ProviderVideoID: "vid1"})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected error when secret ref cannot be resolved")
|
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