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:
@@ -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, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user