tenbrains · implementation report

YouTube transcription

tenbrains analyze now accepts a YouTube URL, fetches public captions for free, and runs the transcript through the existing research pipeline — including narrative summaries, persistence, learning tracks, search, and digest.

Implemented · merged Owner: @moeghashim Version: tenbrains 2.2.0 PR: #8 No new dependency No schema migration
# A transcript is just long-form content — it enters the existing pipeline
tenbrains analyze --url "https://youtube.com/watch?v=…" --summarize --learn
# → fetch captions · analyse · summarize · build a learning track — one call
# meta.source = "youtube" · search / digest / record get see them together

01 What shipped

02 Delivered scope

Included in 2.2.0

  • Fetch a public transcript with no API key, no paid tier, no new dependency.
  • Reuse the whole downstream pipeline (persist → learn → search → digest → suggest).
  • Structured errors + a manual fallback — never crash.
  • Pure, fixture-tested URL, player-response, and timed-text parsers.

Still out of scope

  • Audio transcription (Whisper / yt-dlp) for videos with no captions.
  • Chapter/timestamp segmentation or a transcript-viewer UI.
  • Playlist / channel bulk import.
  • Auto-translation beyond selecting an available track.

03 Fetch — free-first caption scrape

The implementation in src/youtube/client.ts mirrors src/x/client.ts: dependency-free network orchestration around pure parsers, with structured errors at every boundary.

STEP 1Watch pageGET the video's HTML
STEP 2Player responseextract ytInitialPlayerResponse
STEP 3Caption trackpick manual>ASR, lang>en>first
STEP 4Timedtextfetch baseUrl → flatten to text
No new dependency

fetch + regex/JSON parsing — the same toolkit the oEmbed path uses. Rejected: youtube-transcript (fragile, redundant), yt-dlp (external binary, breaks the self-contained stance), and the YouTube Data API captions.download (needs the owner's OAuth — the same dead end that makes X timelines a paid feature).

Production compatibility fallback

YouTube's WEB timed-text URL can return HTTP 200 with an empty body. When that happens, tenbrains requests an equivalent caption track through the embedded Android player API and feeds it into the same pure timed-text parser. The Android client version is isolated and documented as a maintenance surface.

04 Integration — extend analyze

No parallel command was added. analyze detects a YouTube URL in --url and routes to the transcript fetcher, exactly as --thread routes differently from a plain tweet.

FieldSource
post.textflattened transcript
post.externalIdyt:<videoId> — distinct prefix, dedupes re-runs, no tweet-id collision
post.urlcanonical watch?v=<id>
post.authorUsernamechannel
post.postedAtupload date
post.rawvideo metadata → existing posts.raw_json (no migration)
meta.source"youtube"

The analyzer now receives kind: "tweet" | "transcript" and uses "You are analyzing a video transcript." for video content. The analysis output schema remains unchanged.

Companion actions — --summarize & --learn

Two composable companion actions run on the same fetched content in one call — pass either, both, or neither. They are general analyze options and especially useful for long-form transcripts.

--summarize

  • A fuller narrative digest ({ summary, keyPoints[] }), distinct from the terse analysis summary field.
  • New summarizeContent() + prompt + schema + mock — offline-testable like the rest.
  • Returned under data.summary; meta.summarized = true; persisted in raw_json.
  • Doubles as the condense-first pass for long transcripts, so the expensive extraction sees a shorter document.

--learn

  • Builds the 7-day Feynman track from the analysis' concepts (--minutes, --ratings apply).
  • Already built — works for any analysed content, transcript included.
  • Track progress via the existing learn today / learn done.

When requested, summarization is the condense-first pass: fetch → summarize → analyse the digest → learn. All outcomes persist against the same post.

05 Errors & fallback

The implementation reuses the existing PROVIDER_* scaffolding. Every "no captions" path points at the manual escape hatch analyze --transcript @file, which also keeps the complete workflow testable offline.

ConditionCodeExit
Not found / deleted / no caption tracksNOT_FOUND3
Private / members-only / age-gated (LOGIN_REQUIRED) / region-blockedPROVIDER_UNAUTHORIZED5
Rate limitedPROVIDER_RATE_LIMITED5
Network / timeoutPROVIDER_NETWORK5
Markup changed, can't parsePROVIDER_BAD_OUTPUT5

06 Surface, storage, testing

analyze --url <youtube-url> # fetch + analyse captions
analyze --url <url> --lang es # prefer a caption language
analyze --transcript @file | - # supply a transcript manually (no network)
analyze --url <url> --summarize # + a fuller narrative summary
analyze --url <url> --summarize --learn # both, in one call

07 Risks

RiskMitigation
YouTube changes watch-page markupPure, fixture-tested parsers → one-line fix. Same risk class as existing oEmbed HTML parsing.
Android player client version expiresOne isolated version marker and documented fallback path; update it when the endpoint stops returning tracks.
Video genuinely has no captionsStructured NOT_FOUND--transcript @file. Audio transcription deferred.
ToS / gray-area scrapingSame posture as tweet oEmbed + archive paths — public captions, personal research; noted in docs.
Token cost on long transcriptsSoft warning in v1; --summarize condenses first (§4).

08 Decisions shipped

1
Folded into analyzeshipped rather than adding a separate YouTube command.
2
Caption-only v1shipped with --transcript @file|- as the manual fallback. Audio and Whisper remain deferred.
3
Versioned as 2.2.0. The feature is additive, with no database migration or runtime dependency.
4
Manual captions outrank ASR, then language preference applies. This preserves the design-of-record behavior, including manual English winning over ASR Spanish for --lang es.
Delivery

Implemented in PR #8 and merged to main with signed-off commits. DCO and the full typecheck, lint, test, and build workflow passed before merge.