Downloading arbitrary Apple Podcast episode transcripts

Because the default Apple Podcasts on macOS only allows you to copy 200 words of transcript at a time, I built a quick website back in January that allows you to view and copy the full thing. 1 You can see my blog post around it here for a look at how it works. It mostly works great, but some users (including those on older versions of macOS) have flagged that their transcript files aren't locally saved, and thus don't show up in the tool. Here's how I came up with a slightly convoluted (read: very) workaround — or if you're impatient check out the Github if you're on macOS 15.5, and the Python script if not.

Follow the money app

We want to take in a podcast episode and manually download the corresponding TTML 2 Timed Text Markup Language, which won Netflix an Emmy! The non-primetime Emmy's are weird. file. The transcripts load too fast and too consistently to be autogenerated by some local LLM model, 3 Even considering this as an option would have been incomprehensible 3 years ago. We live in crazy times. so this likely hits some server endpoint. Our goal is to find how it requests this from the server, and replicate it. Viewing requests made by a website is a lot easier than seeing requests made by native apps, and Apple has a website version of its podcasts app, https://podcasts.apple.com, so I started there.

Here's a sample podcast that I'll be trying to get the transcript for. 4 Love Shift Key, refuse to miss an opportunity to shill for it. The embed allows you to play the podcast, but doesn't allow you to view or link to a transcript.

If we follow that link to the website we can see a list of options, but the "View Transcript" option is notably missing.

Is that what this is called? There's a 10-year old Reddit comment that cites Apple's documentation calling it "More Options", but since then the page has updated, now just calling it "More".
Website "More Options" 5 dropdown Different options for macOS app

Searching through the website source code also doesn't show any references beyond some string translations regardless of whether I log in, so I think this is a dead end. Let's swap over to the app.

Debugging macOS requests

Normally I would use something like mitmproxy to check network requests (see one of my previous post for details), but the Apple Podcasts app uses HTTPS and certificate pinning, so mitmproxy just hits a bunch of errors. I likewise had no success with tcpdump or Wireshark for the same reason (though I'll admit this might be user error). Instead let's jump into our old friend, lldb.

lldb to find the request

After opening up the Podcasts app we can attach our debugger to it with lldb -n podcasts (though without SIP disabled the attach will fail).

We can then set up a breakpoint when the URL request kicks off, and add custom code to print out that URL whenever the breakpoint is hit.

b "-[NSURLSession dataTaskWithRequest:]"
# Breakpoint 1: where = CFNetwork`-[NSURLSession dataTaskWithRequest:], address = 0x000000018b670b08

# Where 1 matches the Breakpoint # from the previous line
breakpoint command add 1
po [[(NSURLComponents *)$x2 URL] absoluteString]
# This will resume execution
continue
DONE

If we navigate through the app (it's a little slow because we're constantly interrupting execution) we can eventually click into "View Transcript" and voila! We see something that looks right on the money: https://amp-api.podcasts.apple.com/v1/catalog/us/podcast-episodes/1000714478537/transcripts?include%5Bpodcast-episodes%5D=podcast&fields=ttmlToken,ttmlAssetUrls&with=entitlements&l=en-US.

Loading that URL in a browser hits an Unauthorized 401 error though, so we're clearly missing some header/cookie information.

Extracting headers

Now that we know the specific URL we're hitting we can be more targeted, adding a modifier so it only breaks when the requested URL contains transcripts.

# Delete all of the existing breakpoints, and set up a new one
br delete
b "-[NSURLSession dataTaskWithRequest:]"
breakpoint modify 2 -c '[(NSString *)[[(NSURLComponents *)$x2 URL] absoluteString] containsString:@"transcripts"]'

When this trips we can run po [[(NSURLRequest *)$x2 allHTTPHeaderFields] description] to print out all of the headers:

<CFBasicHash 0x600001d979c0 [0x1f4f94998]>{type = mutable dict, count = 20,
entries =>
    0 : X-Apple-Tz = -25200
    1 : x-apple-ab = <CFString 0x600001d97940 [0x1f4f94998]>{contents = "<censored>"}
    2 : Cookie = <CFString 0x11f6a34c0 [0x1f4f94998]>{contents = "<censored>"}
    3 : iCloud-DSID = <CFString 0x60000077ecc0 [0x1f4f94998]>{contents = "1319177383"}
    4 : User-Agent = <CFString 0x600002c21dc0 [0x1f4f94998]>{contents = "Podcasts/1.1.0 (Macintosh; OS X 15.5; 24F74) AppleWebKit/0621.2.5.11.8 AMS/1 (dt:1)"}
    5 : X-Apple-Store-Front = 143441-1,42 t:podcasts1
    6 : X-DSID = <CFString 0x600000117420 [0x1f4f94998]>{contents = "1319177383"}
    9 : X-Apple-I-MD-LU = <CFString 0x6000022b77e0 [0x1f4f94998]>{contents = "BD8038107B9A2ADEA0EB7552C6626538270585890DC1C225A7F9E70149EAB267"}
    17 : X-Apple-Client-Application = <CFString 0x6000009ac9f0 [0x1f4f94998]>{contents = "com.apple.podcasts"}
    23 : X-Apple-I-TimeZone = PDT
    25 : X-Apple-I-Client-Time = <CFString 0x6000008a9770 [0x1f4f94998]>{contents = "2025-07-21T02:13:23Z"}
    28 : Accept-Language = en-US
    29 : Authorization = <CFString 0x600000899560 [0x1f4f94998]>{contents = "Bearer eyJraWQiOiJNNllDODRPNUZFIiwiYWxnIjoiRVMyNTYifQ.eyJpc3MiOiJEUUbea15eQ1JOIiwiaWF0IjoxNzUyOTUyNDMxLCJleHAiOjE3NTU1NDQ0MzF9.vTVy28k8VbVYJzBYgbv_shZWEu_afuEA2FVF_xNq7Rkn9wGMbvbIgtOsUk6PDY-OgvGcZqq3v99hDRk3LtYC2Q"}
    31 : X-Apple-I-MD-RINFO = 33883392
    32 : X-Apple-ADSID = <CFString 0x600001d04480 [0x1f4f94998]>{contents = "000880-05-8a201ed6-78bd-487b-b03b-7e5acf6505cd"}
    34 : x-apple-i-device-type = 1
    36 : Accept-Encoding = <CFString 0x1f74f0c60 [0x1f4f94998]>{contents = "br, gzip, deflate"}
    37 : X-Apple-I-MD-M = <CFString 0x600002c53bf0 [0x1f4f94998]>{contents = "<censored>"}
    38 : X-Apple-I-Locale = en_US
    40 : X-Apple-I-MD = <CFString 0x600001f335c0 [0x1f4f94998]>{contents = "<censored>"}
}

The main header that we care about is Authorization to address the 401, and if we copy it over into the request, it's enough to make it correctly load with data. 6 This is a JWT, or JSON web token, and is pretty self-descriptive: you can use a tool like https://jwt.io/ to validate one, see its expiration date, and see what algorithm was used to hash it.

{
  "data": [
    {
      "id": "1000714478537-0",
      "type": "transcripts",
      "attributes": {
        "ttmlAssetUrls": {
          "ttml": "https://podcasts.itunes.apple.com/itunes-assets/PodcastContent211/v4/92/42/50/9242500d-c2a4-48f7-7323-890937263f09/transcript_1000714478537.ttml?accessKey=1753259491_1466603024519196744_3Vvfa%2FukJtDcAj5eXFI%2Fb%2FAloZNs9MbXL3UCvynI4ZECuF93eG4ctvt%2Fh8ItuEJMQLE0t3BC%2BV4L4F8RzoyF6nky71Ie7oZKuQVpoRHE7f5%2BEnitBNnvt3uZV2Zt%2Ba8Rr0tummmgJqSz%2BqaWpnFTSXG5QkVEDf7shpdjcaHCw%2B6NUNAOSzN2a1Rvly96JPS1",
          "signature": "https://podcasts.itunes.apple.com/itunes-assets/PodcastContent211/v4/93/f8/12/93f8123f-b460-87d7-8f9d-9305c40aca84/transcript_1000714478537.ttml?accessKey=1753259491_4472639228109545434_tZ3LYObWKNQJZuW0yi8%2FfPR6SIEkSF8JWHXJGGnqABnVJIyHOLoxJ0Lie%2BxbakCFf6cylRE8%2BxMZzUBkcdH51ZTtJToCO12DJUf62ZMZEGylMnESlXKEQUYu2EFC6%2Fv9schqQsJPpR90wMFfDcGXWNkBMkOWMzqZM6gOZ6HUuuFgbyaOrBdRPVRhTDpeVAUy"
        },
        "ttmlToken": "PodcastContent211/v4/92/42/50/9242500d-c2a4-48f7-7323-890937263f09/transcript_1000714478537.ttml"
      }
    }
  ]
}

In the ttml key there's a full website path to the TTML file which works to download it. Now we just need a way to reliably get the token!

Where does the token come from?

Now we know what the token is, but how is it calculated? Because we're still stopped at the URL request breakpoint we can use bt to get the stack trace of how the request was built to dig deeper.

 thread #22, queue = 'com.apple.root.user-initiated-qos.cooperative', stop reason = breakpoint 2.1
  * frame #0: 0x000000018b670b08 CFNetwork`-[NSURLSession dataTaskWithRequest:]
    frame #1: 0x00000001a29b5a30 AppleMediaServices`+[AMSURLSession _taskFromSession:request:activity:] + 52
    frame #2: 0x00000001a29b4f30 AppleMediaServices`__122-[AMSURLSession createDataTaskWithRequest:signpostID:activity:dataTaskCreationCompletionHandler:requestCompletionHandler:]_block_invoke + 132
    frame #3: 0x00000001a2897394 AppleMediaServices`-[AMSFinishedPromise addFinishBlock:] + 116
    frame #4: 0x00000001a29b4e08 AppleMediaServices`-[AMSURLSession createDataTaskWithRequest:signpostID:activity:dataTaskCreationCompletionHandler:requestCompletionHandler:] + 1380
    frame #5: 0x00000001a29b5cd4 AppleMediaServices`__53-[AMSURLSession dataTaskPromiseWithRequest:activity:]_block_invoke + 268
    frame #6: 0x00000001a22e4334 AppleMediaServices`-[AMSLazyPromise _runBlock] + 164
    frame #7: 0x00000001a22f6f80 AppleMediaServices`-[AMSLazyPromise addSuccessBlock:] + 44
    frame #8: 0x00000001a2908d90 AppleMediaServices`+[AMSMutablePromise finishPromise:withPromise:] + 168
    frame #9: 0x00000001a29399d4 AppleMediaServices`-[AMSPromise promiseWithTimeout:] + 72
    frame #10: 0x00000001a2939c68 AppleMediaServices`-[AMSPromise resultWithTimeout:completion:] + 52
    frame #11: 0x00000001ce75c4d0 PodcastsFoundation`__78-[IMBaseStoreService performDataRequest:account:telemetryIdentifier:callback:]_block_invoke + 492
    frame #12: 0x00000001a22e3954 AppleMediaServices`-[AMSPromiseCompletionBlocks flushCompletionBlocksWithPromiseResult:] + 260
    frame #13: 0x00000001a2939edc AppleMediaServices`-[AMSPromise flushCompletionBlocksWithResult:] + 60
    frame #14: 0x00000001a2909164 AppleMediaServices`+[AMSMutablePromise _finishPromise:withResult:error:logDuplicateFinishes:] + 788
    frame #15: 0x00000001a2908c08 AppleMediaServices`-[AMSMutablePromise finishWithResult:error:logDuplicateFinishes:] + 84
    frame #16: 0x00000001a28c7960 AppleMediaServices`__62-[AMSMediaRequestEncoder requestByEncodingRequest:parameters:]_block_invoke.9 + 880
    frame #17: 0x00000001a22f6b88 AppleMediaServices`__46-[AMSPromiseCompletionBlocks addSuccessBlock:]_block_invoke + 72
    frame #18: 0x00000001a22e3954 AppleMediaServices`-[AMSPromiseCompletionBlocks flushCompletionBlocksWithPromiseResult:] + 260
...
    frame #70: 0x00000001a29a7380 AppleMediaServices`__119+[AMSURLRequestDecoration addFPDIHeadersToRequest:buyParams:bag:retryCount:fairPlayDeviceIdentity:fpdiNetworkProvider:]_block_invoke + 328
    frame #71: 0x00000001a23a6474 AppleMediaServices`___lldb_unnamed_symbol40792 + 492
    frame #72: 0x000000026e863454 libswift_Concurrency.dylib`swift::runJobInEstablishedExecutorContext(swift::Job*) + 248
    frame #73: 0x000000026e8649b4 libswift_Concurrency.dylib`swift_job_runImpl(swift::Job*, swift::SerialExecutorRef) + 156
    frame #74: 0x00000001858d4e30 libdispatch.dylib`_dispatch_root_queue_drain + 364
    frame #75: 0x00000001858d55d4 libdispatch.dylib`_dispatch_worker_thread2 + 156
    frame #76: 0x0000000185a76e28 libsystem_pthread.dylib`_pthread_wqthread + 232

All of this runs through AppleMediaServices, and if you look past all of the stacked promises and completion blocks you can see a few interesting functions. Specifically I want to take a look at -[AMSMediaRequestEncoder requestByEncodingRequest:parameters:]. Running image lookup -n says that the function is defined in /System/Library/PrivateFrameworks/AppleMediaServices.framework/Versions/A/AppleMediaServices, but that file doesn't exist. That's because macOS (and iOS) merge their private frameworks into a large DYLD shared cache, 7 Or at least it is after macOS 11. If you're following this guide for versions before then...good luck. which helps reduce startup time. Instead we can have to find that combined file at /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e, drag it into Hopper, 8 Finally pulled the trigger on the paid version after rebooting 6 times and getting more and more frustrated at offsetting the binaries. and choose AppleMediaServices to take a look at the function. Immediately tokenService and fetchMediaToken look promising.

If we set a new breakpoint with b -[AMSMediaRequestEncoder requestByEncodingRequest:parameters:] we can run po [$x0 tokenService] to show that the service is AMSMediaTokenService, though fetchMediaToken returns a AMSMutablePromise. Again we can use Hopper to look at the implementation for fetchMediaToken, and see that it first calls cachedMediaToken (hitting _fetchToken if the cache is invalid). If we run po [[$x0 tokenService] cachedMediaToken] we can see the raw token value! We can also see that they're valid for 30 days.

<AMSMediaToken: 0x600000b2c540> {
  expirationDate = 2025-08-18 19:13:51 +0000,
  lifetime = 2592000,
  tokenString = eyJraWQiOiJNNllDODRPNUZFIiwiYWxnIjoiRVMyNTYifQ.eyJpc3MiOiJEUUVTRUNKQ1JOIiwiaWF0IjoxNzUyOTUyNDMxLCJleHAiOjE3NTU1NDQ0MzF9.vTVy28k8VbVYJzBYgbv_shZWEu_afuEA2FVF_xNq7Rkn9wGMbvbIgtOsUk6PDY-OgvGcZqq3v99hDRk3LtYC2Q,
  valid = true,
}

Tracing the code shows that the cachedMediaToken is read through AMSMediaTokenServiceKeychainStore, which hits SecItemCopyMatching, part of Apple's Secure Keychain API. By setting a breakpoint on for that function, po $x0 shows that it's stored in com.apple.AppleMediaServices.mediaToken.

{
    acct = "com.apple.podcasts.macos";
    agrp = apple;
    class = genp;
    nleg = 1;
    pdmn = ck;
    "r_Data" = 1;
    svce = "com.apple.AppleMediaServices.mediaToken";
}

And if we open up Keychain Access and go to the iCloud section, we can find it! Unfortunately while you can click "Show password", it doesn't work for the mediaTokens, so we can't just access it directly. While we could use it for 30 days, having to go through this process to regenerate it would quickly get annoying.

No, but where is the token really from?

We know that the cache is kept in Keychain Access, and while we can't see the value, we can delete it, which will force the app to refetch one. Doing the same trick of printing out all requests gives us the request it's making to build the token in the first request: https://sf-api-token-service.itunes.apple.com/apiToken?clientClass=apple&clientId=com.apple.podcasts.macos&os=OS%20X&osVersion=15.5&productVersion=1.1.0&version=2.

Again we can follow the extracting headers section, set a breakpoint on requests that contain apiToken, and print out all headers. By deleting headers we can see which ones are necessary for the request to succeed. Eventually this whittles down to 3.

"x-request-timestamp": "2025-07-21T04:20:19Z",
"X-Apple-ActionSignature": "Am5RCI1saxgvGhvQEYacTol3bg4W8VCj3IsbhROy4HuBAAAB0AMAAAABAAABABMVBYZuoNZLtsTu6gvFjv7arrIsfOTzZK/zwgCyj8YchiP9m2GHzG4fyPoBSc1DaJj+mIxbea15W1VRUxeR8bn2WvTJXe9yRldOC5JNDof8LajHJ1oOXZuPdF7MSzhVg0a0IQTVublthWI3vI5MWyfqHFCWRDRen0NaOfGrami0y++oitFLH/6j5g8KI9XrCd6qGQ1jsMpgt/CbaolN4hPx/CJY0uwKNfhWeEPYA4Gop6BUlUCjLLiFybZ4YNytJFfyzFjXTHv3HwCgUADcQqg3AcIbMLcE6yFHdT2J4OFuiS3fZi35mvCT72MroFbpv64u3lUTcKluYuh3gicyGccAAAAVPjZwGe9Yri744d3T2lLROdt4oCI0AAAAnwFLV835KssbSsAkmGqjiABOHn3yHgAAAIYECNkrAHtCoti53rJyfPW7BLdofaHaLu7T+DvIzoxwF6FV2hwiL5MpySbs7UNmmaa1ST100yHuXjmJ9VSstsqM9aN+FiE6HjgXYqUTzjrbwuY//3YDdQ4g/6Qzl6o2I1q1WI5zMHmhY4vSk6CjsSkef3DYNkiU2GyadOs5cJMxfnaDbqrTQAAAAAAAAAAAAAAA",
"X-Apple-Store-Front": "143441-1,42 t:podcasts1"

x-request-timestamp is the current timestamp, which is easy enough to get. X-Apple-Store-Front is a constant, 9 And here is what it means, though I didn't verify any of that comment. Trust but verify — unless you're just mashing together private APIs, in which case, if it runs you're done. which is also easily handled. But how is X-Apple-ActionSignature calculated? It feels like we've only transformed the Bearer token problem into another one.

Replicating X-Apple-ActionSignature

There's some loose references around this online here and in these GitHub comments but not with any concrete answers (and mostly around iTunes reverse engineering where the "solution" was to directly call the underlying binary).

We can try and figure out the path of where this is set by setting a breakpoint when the header is set, and checking backtrace:

b CFURLRequestSetHTTPHeaderFieldValue
breakpoint modify 2 -c '[(NSString *)$x1 isEqualToString:@"X-Apple-ActionSignature"]'

That flags [NSMutableURLRequest(AppleMediaServices) ams_addHeadersFromPromise:]. A combination of setting breakpoints 10 I unfortunately didn't get a good place to include this in the final writeup, but breakpoint's "func-regex" is a livesaver for seeing what functions in a broad range are hit: breakpoint set --func-regex "-\\[AMS.*\\]" and tracing uses of the string X-Apple-ActionSignature through the binary finally takes us to +[AMSURLRequestDecoration addMescalHeaderToRequest:type:bag:logKey:]. That appears to be calculating the important bit in +[AMSMescal signaturePromiseFromRequest:type:bag:], and then passing it into the header setting logic.

All of the AppleMediaServices code is using Promises 11 Capital P thanks to Javascript, but it's a general programming concept for saying "do some code that will take a while, and run this code once it's done". littered with callbacks that Hopper is very bad at decompiling, but we can get around its inability to link them properly by just looking through the +[AMSMescal signaturePromiseFromRequest:type:bag:]_block_invoke-prefixed functions. In the first relevant one it checks if it should sign some data with _matchSignedActions:URL:, and then does sign it with _signedActionDataFromRequest:policy:.

By stepping through execution and printing out the policy variable, we can see that this function is taking in some fields and headers and iterating over them, building a raw data block from concatenating their NSUTF8StringEncoding's.

po $x19
{
    fields =     (
        clientId
    );
    headers =     (
        "x-apple-store-front",
        "x-apple-client-application",
        "x-request-timestamp"
    );
}

Going back to the _block_invoke functions we can see that it loads [AMSMescalSession sessionWithType:], and then invokes signData:bag: on it, with the data calculated from _signedActionDataFromRequest.

We can inspect and see that [AMSMescalSession sessionWithType:] returns [AMSMescalSession defaultSession], and with this we have enough to replicate everything.

Building a binary to do this

We need access to AppleMediaServices which means that we have no choice but to build the script to do this in Objective-C. While we can link a PrivateFramework with -F/System/Library/PrivateFrameworks -framework AppleMediaServices, we also need PodcastsFoundation to construct the IMURLBag that the bag params requires. That binary is restricted though, and can't be linked at build time, forcing us to rely on dynamically loading it with dlopen:

dlopen("/System/Library/PrivateFrameworks/PodcastsFoundation.framework/PodcastsFoundation", RTLD_LAZY);

With this, and all of the signing pieces above, we have everything we need! You can check out the full code in my Github repo and compile it yourself, or just download the raw binary. It can be run with ./FetchTranscript <podcastID> where the podcastID is 1000714478537, and can also use a flag to locally store the bearer token and reuse it for 30 days with ./FetchTranscript <podcastID> --cache-bearer-token.

Alternative script

I thought I was done with this, but while ./FetchTranscript worked for me on macOS 15.5, the first use of it on a different OS (specifically 14.4.1) failed. Digging into these functions takes a long time though, and doing it through macOS VMs is even harder, so I wasn't eager to try and expand functionality. 12 Though I'm glad Mac is now doing a proper Virtualization framework, so there are free programs like Orka Desktop now to make poking around Mac VMs easier.

However, there's one great thing about the X-Apple-ActionSignature header — even though it's based on the timestamp it never seems to expire, and keeps working even for timestamps that are years out of date. 13 At least this is true at the time of writing. My deepest apologies if this changed after, or because of, this post. This means that getting the header once allows us to write a script to generate Bearer tokens in perpetuity. Here are the steps, adapted from the previous token section.

  1. Make sure SIP is disabled or we won't be able to attach to the Podcasts app.
  2. Open Terminal and run the following:
    lldb -n Podcasts
    b "-[NSURLSession dataTaskWithRequest:]"
    breakpoint modify 1 -c '[(NSString *)[[(NSURLComponents *)$x2 URL] absoluteString] containsString:@"apiToken"]'
    continue
  3. Force the API token to be regenerated by opening Keychain Access, finding the entry for com.apple.AppleMediaServices.mediaToken with account "com.apple.podcasts.macos", and deleting it.
  4. Click around in the Podcasts app, which will force it to try and refetch this.
  5. Back in Terminal in lldb it should now be stopped at our breakpoint. Run the following:
    po [[(NSURLRequest *)$x2 allHTTPHeaderFields] description]
  6. Copy the contents of "x-request-timestamp" and "X-Apple-ActionSignature" into the following Python script.
import requests

##################
podcast_id = 1000714478537
# x-request-timestamp
timestamp = '2025-07-23T04:56:32Z'
# X-Apple-ActionSignature
signature = 'AgOmk5JHuU2E+Vdv829+th7xubea15K63oxNUGAAAB0AMAAAABAAABAIA90LOou3vUjPBWvFa8ptgim3bbUrpnNKzjiIV9gVCUaH30d6Jxpih7g0zPzMDAVoPHSv1i6kbqbECEU92VzXU8HkJvriR02fn8gbeQVxHguaG4IXOYNEp8dGKQb0p6qpALO7R49N4Uq7IlpXuj7pHG1YV5GIhZgkYNV0X/f3EjTLTF1RcjLAPcmY4VA7absX3GfQasqc5amv7wC1rCMPfMb9IP9RJV+l+t/Zb7hQIJzdK8XPG4z40EgHgUh5prO1d2ym2ItEHNo9xofI6uX7Y0/8EYibGbVHvgYNgcUmjKZSYxw2ZIYzHm8KCt1qdpbv0ETH2w//X5LsA7ocbDiXsAAAAfBWcJbU05bhAc61gceBKKyDCHTAG0FBYqMny5lttdMQAAAJ8BgDVqSfPT+Bp2BPszSyyc+T8PGNcAAACGAwlr3TjpwAuOYipypBNqCQViaIBqmP+F7GeIWDeXA8DKx5PeV/glm6HSvIehTyULwvFvw0L1XyG6C0qVJHKR4icXhEIbVmX1ewIUqU24SCrD9ZHX+nmNB26I4sTbUPl4sBcI0Y4zm2UhjpNwLHZhwsNW03pvurERb0KjjCfiHlsnMhcCMAsA'
##################

url = f'https://sf-api-token-service.itunes.apple.com/apiToken?clientClass=apple&clientId=com.apple.podcasts.macos&os=OS%20X&osVersion=15.5&productVersion=1.1.0&version=2'
bearer_token = requests.get(url, headers={
  'x-request-timestamp': timestamp,
  'X-Apple-ActionSignature': signature,
  'X-Apple-Store-Front': '143441-1,42 t:podcasts1',
}).json()['token']

url = f'https://amp-api.podcasts.apple.com/v1/catalog/us/podcast-episodes/{podcast_id}/transcripts?fields=ttmlToken,ttmlAssetUrls&include%5Bpodcast-episodes%5D=podcast&l=en-US&with=entitlements'
attributes = requests.get(url, headers={
  'Authorization': f'Bearer {bearer_token}',
}).json()['data'][0]['attributes']

with requests.get(attributes['ttmlAssetUrls']['ttml'], stream=True) as r:
  r.raise_for_status()
  with open(attributes['ttmlToken'].split('/')[-1], "wb") as f:
    for chunk in r.iter_content(chunk_size=8192):
      if chunk:
        f.write(chunk)

And we're done! This downloads the .ttml file, and dragging and dropping it onto https://alexbeals.com/projects/podcasts/ will allow you to view the full text like normal.

Easy! Not at all convoluted. Definitely.


  1. You can see my blog post around it here for a look at how it works. ↩︎

  2. Timed Text Markup Language, which won Netflix an Emmy! The non-primetime Emmy's are weird. ↩︎

  3. Even considering this as an option would have been incomprehensible 3 years ago. We live in crazy times. ↩︎

  4. Love Shift Key, refuse to miss an opportunity to shill for it. ↩︎

  5. Is that what this is called? There's a 10-year old Reddit comment that cites Apple's documentation calling it "More Options", but since then the page has updated, now just calling it "More"↩︎

  6. This is a JWT, or JSON web token, and is pretty self-descriptive: you can use a tool like https://jwt.io/ to validate one, see its expiration date, and see what algorithm was used to hash it. ↩︎

  7. Or at least it is after macOS 11. If you're following this guide for versions before then...good luck. ↩︎

  8. Finally pulled the trigger on the paid version after rebooting 6 times and getting more and more frustrated at offsetting the binaries. ↩︎

  9. And here is what it means, though I didn't verify any of that comment. Trust but verify — unless you're just mashing together private APIs, in which case, if it runs you're done.  ↩︎

  10. I unfortunately didn't get a good place to include this in the final writeup, but breakpoint's "func-regex" is a livesaver for seeing what functions in a broad range are hit: breakpoint set --func-regex "-\\[AMS.*\\]" ↩︎

  11. Capital P thanks to Javascript, but it's a general programming concept for saying "do some code that will take a while, and run this code once it's done". ↩︎

  12. Though I'm glad Mac is now doing a proper Virtualization framework, so there are free programs like Orka Desktop now to make poking around Mac VMs easier. ↩︎

  13. At least this is true at the time of writing. My deepest apologies if this changed after, or because of, this post. ↩︎