Skip to content

YouTube transcript fails on iOS Safari (m.youtube.com): User-Agent header on caption fetch breaks CORS preflight #310

Description

@OrdenDelfai

Symptom: In Obsidian Web Clipper on iOS Safari, {{transcript}} is always empty for YouTube videos, while the same template works on macOS (www.youtube.com). Previously reported as symptoms in obsidianmd/obsidian-clipper#728 and obsidianmd/obsidian-clipper#758, but the underlying fetch bug was never identified. Verified against current main (9db7260).

Root cause

On iPhone, Safari is redirected to m.youtube.com, so the caption XML fetch to https://www.youtube.com/api/timedtext?... is cross-origin. fetchCaptionXml attaches a custom header:

https://github.com/kepano/defuddle/blob/9db72600/src/extractors/youtube.ts#L585

const captionHeaders: Record<string, string> = { 'User-Agent': 'Mozilla/5.0' };

User-Agent is no longer a forbidden header in WebKit, so the browser does not strip it. On a cross-origin request it is a non-safelisted header, which forces a CORS preflight. The timedtext endpoint's preflight response contains no Access-Control-Allow-Headers, so the request is blocked and the fetch rejects with TypeError: Load failed. The transcript then falls through to the mobile DOM-clicking fallback, which frequently fails (see "Secondary issues" below), so the user gets an empty transcript.

On desktop (www.youtube.com) the same fetch is same-origin, CORS never applies, and everything works. That is the entire macOS/iOS asymmetry.

Reproduction (Playwright WebKit 26.4, which is the iOS Safari engine)

On a loaded https://m.youtube.com/watch?v=dQw4w9WgXcQ page, in page context:

Step (mirrors youtube.ts logic) Result
InnerTube IOS client POST to /youtubei/v1/player PASS, status 200, 6 captionTracks
fetch(track.baseUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } }) FAIL, TypeError: Load failed
Identical fetch(track.baseUrl) with no headers PASS, status 200, 4555 bytes of transcript XML

Preflight evidence via curl (note: no Access-Control-Allow-Headers in the response, so the requested user-agent header is not approved):

$ curl -X OPTIONS "https://www.youtube.com/api/timedtext?..." \
    -H "Origin: https://m.youtube.com" \
    -H "Access-Control-Request-Method: GET" \
    -H "Access-Control-Request-Headers: user-agent" -D -
HTTP/2 200
access-control-allow-origin: https://m.youtube.com
access-control-allow-credentials: true

Suggested fix

Only set the User-Agent header outside browser contexts (it exists for the server-side CLI; in a browser the real UA is sent anyway and the custom value is at best ignored):

const captionHeaders: Record<string, string> = {};
if (typeof window === 'undefined') {
    captionHeaders['User-Agent'] = 'Mozilla/5.0';
}

The same consideration applies to the Android InnerTube client path in fetchPlayerData, which also sends a custom User-Agent and cannot pass preflight from m.youtube.com (the player endpoint's preflight only allows content-type). The IOS client path is unaffected since Content-Type: application/json is approved.

Secondary issues found while diagnosing (can split into separate issues if preferred)

  1. Relative inline baseUrl on m.youtube.com. The mobile page's ytInitialPlayerResponse caption baseUrl is relative (/api/timedtext?...), so the SSRF guard new URL(track.baseUrl) throws and kills the inline path even though that fetch would be same-origin on mobile. Fix: new URL(track.baseUrl, 'https://www.youtube.com') (https://github.com/kepano/defuddle/blob/9db72600/src/extractors/youtube.ts#L582).
  2. Mobile DOM fallback rarely fires. openMobileTranscriptPanel requires button[aria-label="View all"], which only exists when the video has chapters, and the full chain (3 sequential 4s API attempts plus up to 5s polling per step) can exceed Obsidian Web Clipper's 8s parseAsync race, after which the sync fallback drops async variables like {{transcript}} while all sync variables fill in normally. That matches the "everything but the transcript" reports exactly.

Happy to submit a PR for the User-Agent and relative-URL fixes if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions