feat(youtube): acquire captions via player/timedtext baseUrl (ADR-010)
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped

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:
2026-06-02 23:08:13 +02:00
co-authored by Claude Opus 4.8
parent fa16a62e6d
commit 8b14ef4add
4 changed files with 530 additions and 127 deletions
+17 -6
View File
@@ -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, "/"),
}
}