Repository: yt-dlp/yt-dlp
Pull request #13234: [ie/youtube] Add PO token support for subtitles
Author: login: bashonly
Labels: name: high-priority
name: site-bug
description: Issue with a specific website
name: site:youtube
Created: 2025-05-20T06:45:02Z | Merged: 2025-05-22T09:13:42Z

Closes #13075


<details open><summary>Template</summary> <!-- OPEN is intentional -->

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check those that apply and remove the others:
- [x] I am the original author of the code in this PR, and I am willing to release it under [Unlicense](http://unlicense.org/)
- [x] I am not the original author of the code in this PR, but it is in the public domain or released under [Unlicense](http://unlicense.org/): co-authored by @coletdjnz 

### What is the purpose of your *pull request*? Check those that apply and remove the others:
- [x] Fix or improvement to an extractor (Make sure to add/update tests)

</details>


Linked issue #13075: [youtube] Some subtitles require POT now?
### Checklist

- [x] I'm reporting that yt-dlp is broken on a **supported** site
- [x] I've verified that I have **updated yt-dlp to nightly or master** ([update instructions](https://github.com/yt-dlp/yt-dlp#update-channels))
- [x] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [x] I've checked that all URLs and arguments with special characters are [properly quoted or escaped](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#video-url-contains-an-ampersand--and-im-getting-some-strange-output-1-2839-or-v-is-not-recognized-as-an-internal-or-external-command)
- [x] I've searched [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766), [the FAQ](https://github.com/yt-dlp/yt-dlp/wiki/FAQ), and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=is%3Aissue%20-label%3Aspam%20%20) for similar issues **including closed ones**. DO NOT post duplicates
- [x] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

US

### Provide a description that is worded well enough to be understood

It looks like sometimes subtitles require a POT to download. When monitoring network traffic in the browser, the subtitles are downloaded using pot parameters. I can remove some of the parameters from the browser-requested url that aren't used in YTDL and still download the subs. But removing the pot=*** or c=WEB parameters causes the response body to be empty. I am using a GetPOT plugin, but it seems YTDL doesn't include the token for subtitles

Working Browser Request: https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=7SQUaOiOPIKyy_sPrfecsQE&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746175838&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=3D3B7ECB6EF0BA3AED7C7599C5D938B9F3829B5C.CA75E9F9FB6A43948F2F6B61EF72585CC1D1C876&key=yt8&lang=en-US&potc=1&pot=MlN-IU-iLbuFMn4dA_6KP5FQsM6ZdcH-8RhgWWcdW_U5m172eEcCmAo7yRE4BzLRsgTOQOlKMps1q4v5nkc70NdVp3aiXLpbhd4pvQjBDa09NGA_rA%3D%3D&fmt=json3&xorb=2&xobt=3&xovt=3&cbr=Firefox&cbrver=136.0&c=WEB&cver=2.20250430.08.00&cplayer=UNIPLAYER&cos=Windows&cosver=10.0&cplatform=DESKTOP

Not working Youtube-DL Request: https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=GiQUaNy2Mta2qvAPzIrN-Aw&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746175626&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=16107AE6DA5DC4CB8C52EACD5F6ECDA294DA5CC5.57FFFFB48DA11322BEC02B56C067A0362D0CC033&key=yt8&lang=en-US&fmt=vtt

I can't test without cookies because I get "ERROR: [youtube+GetPOT] -zueUUhTIsc: Sign in to confirm you’re not a bot."

### Provide verbose output that clearly demonstrates the problem

- [x] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [x] If using API, add `'verbose': True` to `YoutubeDL` params instead
- [x] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
D:\>youtube-dl.exe "https://www.youtube.com/watch?v=-zueUUhTIsc" --sub-lang all --convert-subs srt --embed-subs --cookies "cookies.txt" -vU --ignore-config -f worst
[debug] Command-line config: ['https://www.youtube.com/watch?v=-zueUUhTIsc', '--sub-lang', 'all', '--convert-subs', 'srt', '--embed-subs', '--cookies', 'cookies.txt', '-vU', '--ignore-config', '-f', 'worst']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version nightly@2025.04.30.232944 from yt-dlp/yt-dlp-nightly-builds [b77e5a553] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.22000-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg N-105793-ga0a2ccd55d-20220228 (setts), ffprobe N-105793-ga0a2ccd55d-20220228, phantomjs 2.1.1
[debug] Optional libraries: Cryptodome-3.22.0, brotli-1.1.0, certifi-2025.04.26, curl_cffi-0.10.0, mutagen-1.47.0, requests-2.32.3, sqlite3-3.40.1, urllib3-2.4.0, websockets-15.0.1
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Extractor Plugins: GetPOT (YoutubeIE)
[debug] Plugin directories: C:\Programs\YouTube-DL\yt-dlp-plugins\yt-dlp-get-pot.zip\yt_dlp_plugins, C:\Programs\YouTube-DL\yt-dlp-plugins\yt-dlp-getpot-jsi.zip\yt_dlp_plugins
[debug] Loaded 1857 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest
Latest version: nightly@2025.04.30.232944 from yt-dlp/yt-dlp-nightly-builds
yt-dlp is up to date (nightly@2025.04.30.232944 from yt-dlp/yt-dlp-nightly-builds)
[debug] GetPOT plugin version 0.3.0
[debug] [GetPOT] PO Token Providers: PhantomJS-0.1.0
[debug] [youtube+GetPOT] Found YouTube account cookies
[youtube+GetPOT] Extracting URL: https://www.youtube.com/watch?v=-zueUUhTIsc
[youtube+GetPOT] -zueUUhTIsc: Downloading webpage
[youtube+GetPOT] -zueUUhTIsc: Downloading tv client config
[debug] [GetPOT] Fetching player PO Token for tv client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: -zueUUhTIsc
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmplppjzt7n"
127.0.0.1 - - [01/May/2025 21:47:08] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [01/May/2025 21:47:08] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["MlMMkuuqzfL3FAyurSzvZOgTx3VbvkCWXzZXKgTFsstx_dryuaDo1FqBBqPP-SSBGGt4H5krArLhCmc_HUvi8RJF0gH_o1m4ZWfsU7kne3eIAgsCIg=="]

[debug] [GetPOT] PhantomJS: Generated POT: MlMMkuuqzfL3FAyurSzvZOgTx3VbvkCWXzZXKgTFsstx_dryuaDo1FqBBqPP-SSBGGt4H5krArLhCmc_HUvi8RJF0gH_o1m4ZWfsU7kne3eIAgsCIg==
[debug] [GetPOT] Fetching gvs PO Token for tv client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: 108233318205464433138||
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmplppjzt7n"
127.0.0.1 - - [01/May/2025 21:47:10] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [01/May/2025 21:47:10] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["Ml9d931EBkgEbl3Lu5qr4NUpTedSNV8CL2OpR6VrGiReuZafnc2rvxVNu4lmRrng90gmu8hhF0DOnXd-7TZiiy4ArrsOfc1Htk6PWc7dK9Dc7zhjLNW63JUMRJ10Xju0gQ=="]

[debug] [GetPOT] PhantomJS: Generated POT: Ml9d931EBkgEbl3Lu5qr4NUpTedSNV8CL2OpR6VrGiReuZafnc2rvxVNu4lmRrng90gmu8hhF0DOnXd-7TZiiy4ArrsOfc1Htk6PWc7dK9Dc7zhjLNW63JUMRJ10Xju0gQ==
[debug] Loading youtube-sts.aa3fc80b-main from cache
[youtube+GetPOT] -zueUUhTIsc: Downloading tv player API JSON
[debug] [GetPOT] Fetching player PO Token for web client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: -zueUUhTIsc
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmplppjzt7n"
127.0.0.1 - - [01/May/2025 21:47:12] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [01/May/2025 21:47:12] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["MlNX8sF-In9aI1fObWqMfz_8RannWGXSRqIARZaGgwJD8-m0Imc1V_J3nOFFNvPjurA-Lil4GdB4YVE_mLgqOpA9S-fbFakek9_6TzuSr3BPwWM9mA=="]

[debug] [GetPOT] PhantomJS: Generated POT: MlNX8sF-In9aI1fObWqMfz_8RannWGXSRqIARZaGgwJD8-m0Imc1V_J3nOFFNvPjurA-Lil4GdB4YVE_mLgqOpA9S-fbFakek9_6TzuSr3BPwWM9mA==
[debug] [GetPOT] Fetching gvs PO Token for web client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: 108233318205464433138||
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmplppjzt7n"
127.0.0.1 - - [01/May/2025 21:47:13] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [01/May/2025 21:47:13] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["Ml_rxeBBwko7y-v5j-hFO22TF46G_dmGk1dhEUr_AsHxh_wjsoL1NIv73MSFNyDdrqkWr-SMy4xGNDraE4CP5ki6fuiEqFJUMV8WQjESErSVPTl2n-Hp_zHJiVzy0uG5DQ=="]

[debug] [GetPOT] PhantomJS: Generated POT: Ml_rxeBBwko7y-v5j-hFO22TF46G_dmGk1dhEUr_AsHxh_wjsoL1NIv73MSFNyDdrqkWr-SMy4xGNDraE4CP5ki6fuiEqFJUMV8WQjESErSVPTl2n-Hp_zHJiVzy0uG5DQ==
[debug] Loading youtube-nsig.aa3fc80b-main from cache
[debug] [youtube+GetPOT] Decrypted nsig f-sZbjF_d1bTzL1tDeZT3D => 9IJfdNMM6hPfzQ
[debug] [youtube+GetPOT] Decrypted nsig zt1R85PB_UAcSCBPd3PQvj => 11LHFCkdCAFoyQ
[info] -zueUUhTIsc: Downloading subtitles: en-US
[debug] Sort order given by extractor: quality, res, fps, hdr:12, source, vcodec, channels, acodec, lang, proto
[debug] Formats sorted by: hasvid, ie_pref, quality, res, fps, hdr:12(7), source, vcodec, channels, acodec, lang, proto, size, br, asr, vext, aext, hasaud, id
[info] -zueUUhTIsc: Downloading 1 format(s): 18
[info] Writing video subtitles to: Can the ‘Thunderbolts＊’ Cast ID Each Other’s Body Parts？ ｜ MTV [-zueUUhTIsc].en-US.vtt
[debug] Invoking http downloader on "https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=GiQUaNy2Mta2qvAPzIrN-Aw&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746175626&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=16107AE6DA5DC4CB8C52EACD5F6ECDA294DA5CC5.57FFFFB48DA11322BEC02B56C067A0362D0CC033&key=yt8&lang=en-US&fmt=vtt"


ERROR: Did not get any data blocks
  File "yt_dlp\__main__.py", line 17, in <module>
  File "yt_dlp\__init__.py", line 1100, in main
  File "yt_dlp\__init__.py", line 1091, in _real_main
  File "yt_dlp\YoutubeDL.py", line 3634, in download
  File "yt_dlp\YoutubeDL.py", line 3607, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1642, in extract_info
  File "yt_dlp\YoutubeDL.py", line 1653, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1809, in __extract_info
  File "yt_dlp\YoutubeDL.py", line 1868, in process_ie_result
  File "yt_dlp\YoutubeDL.py", line 3040, in process_video_result
  File "yt_dlp\YoutubeDL.py", line 185, in wrapper
  File "yt_dlp\YoutubeDL.py", line 3296, in process_info
  File "yt_dlp\YoutubeDL.py", line 4375, in _write_subtitles
  File "yt_dlp\YoutubeDL.py", line 3228, in dl
  File "yt_dlp\downloader\common.py", line 468, in download
  File "yt_dlp\downloader\http.py", line 368, in real_download
  File "yt_dlp\downloader\http.py", line 334, in download
  File "yt_dlp\YoutubeDL.py", line 1111, in report_error
  File "yt_dlp\YoutubeDL.py", line 1039, in trouble


[SubtitlesConvertor] Converting subtitles
WARNING: Skipping embedding en-US subtitle because the file is missing
[debug] Invoking http downloader on "https://rr1---sn-u1hp55-5c.googlevideo.com/videoplayback?expire=1746172031&ei=HyQUaPDOLciSvPEPuaKl6A8&ip=104.10.134.178&id=o-AJIXdlwkj2hH50WPSDQD9dzcuwS3ksGFtO5aWdra3ytH&itag=18&source=youtube&requiressl=yes&xpc=EgVo2aDSNQ%3D%3D&met=1746150431%2C&mh=l1&mm=31%2C26&mn=sn-u1hp55-5c%2Csn-5ualdnz7&ms=au%2Conr&mv=m&mvi=1&pl=17&rms=au%2Cau&gcr=us&initcwndbps=1665000&siu=1&bui=AecWEAZykNeNI019gHrPnKMEqvVTCakFla_UivB8DBE5EhmgOEh3QR5HwqkcwAjXyYgS08nYpg&vprv=1&svpuc=1&mime=video%2Fmp4&ns=K3_R4Wx0a4Nl3WjN_jJb58cQ&rqh=1&gir=yes&clen=22821458&ratebypass=yes&dur=403.469&lmt=1746116694680906&mt=1746149815&fvip=5&lmw=1&c=TVHTML5&sefc=1&txp=5538534&n=9IJfdNMM6hPfzQ&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cxpc%2Cgcr%2Csiu%2Cbui%2Cvprv%2Csvpuc%2Cmime%2Cns%2Crqh%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&sig=AJfQdSswRQIhAMOzTImqCE-0pCFmhUjAEdDiP4d5s2djaAZQQOIK6sr3AiAvDb9CgkFbgrRiFHLZBvSA8XqyLS7rgLbAbpjoYp03Tw%3D%3D&lsparams=met%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps&lsig=ACuhMU0wRgIhAJ4PwqAEx-ZD6VhnTDr7M2_HOox3jXVkWnJuUq1vbfg2AiEApA1tF7zxjJwMZsV1_Ts7HNl_1loqBEHIEoYFNiNBvNo%3D&pot=Ml9d931EBkgEbl3Lu5qr4NUpTedSNV8CL2OpR6VrGiReuZafnc2rvxVNu4lmRrng90gmu8hhF0DOnXd-7TZiiy4ArrsOfc1Htk6PWc7dK9Dc7zhjLNW63JUMRJ10Xju0gQ%3D%3D"
[debug] File locking is not supported. Proceeding without locking
[download] Destination: Can the ‘Thunderbolts＊’ Cast ID Each Other’s Body Parts？ ｜ MTV [-zueUUhTIsc].mp4
[download] 100% of   21.76MiB in 00:00:10 at 2.02MiB/s
WARNING: Skipping embedding en-US subtitle because the file is missing
```
Linked issue comments: user: login: bashonly
created_at: 2025-05-03T03:44:05Z
body: Does this happen with `--write-auto-subs` as well? I'm guessing maybe not, since they are already rate-limited
author_association: MEMBER
user: login: Quaraxkad
created_at: 2025-05-03T04:18:03Z
body: This video has a ton of auto-subs, they also failed the same way. I think the log is too long, github won't let me post the whole thing. Here's a portion:

````
D:\>youtube-dl.exe "https://www.youtube.com/watch?v=-zueUUhTIsc" --sub-lang all --convert-subs srt --embed-subs --cookies "C:\Programs\YouTube-DL\cookies.txt" -vU --ignore-config -f worst --write-auto-subs
[debug] Command-line config: ['https://www.youtube.com/watch?v=-zueUUhTIsc', '--sub-lang', 'all', '--convert-subs', 'srt', '--embed-subs', '--cookies', 'C:\\Programs\\YouTube-DL\\cookies.txt', '-vU', '--ignore-config', '-f', 'worst', '--write-auto-subs']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version nightly@2025.04.30.232944 from yt-dlp/yt-dlp-nightly-builds [b77e5a553] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.22000-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg N-105793-ga0a2ccd55d-20220228 (setts), ffprobe N-105793-ga0a2ccd55d-20220228, phantomjs 2.1.1
[debug] Optional libraries: Cryptodome-3.22.0, brotli-1.1.0, certifi-2025.04.26, curl_cffi-0.10.0, mutagen-1.47.0, requests-2.32.3, sqlite3-3.40.1, urllib3-2.4.0, websockets-15.0.1
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Extractor Plugins: GetPOT (YoutubeIE)
[debug] Plugin directories: C:\Programs\YouTube-DL\yt-dlp-plugins\yt-dlp-get-pot.zip\yt_dlp_plugins, C:\Programs\YouTube-DL\yt-dlp-plugins\yt-dlp-getpot-jsi.zip\yt_dlp_plugins
[debug] Loaded 1857 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest
Latest version: nightly@2025.04.30.232944 from yt-dlp/yt-dlp-nightly-builds
yt-dlp is up to date (nightly@2025.04.30.232944 from yt-dlp/yt-dlp-nightly-builds)
[debug] GetPOT plugin version 0.3.0
[debug] [GetPOT] PO Token Providers: PhantomJS-0.1.0
[debug] [youtube+GetPOT] Found YouTube account cookies
[youtube+GetPOT] Extracting URL: https://www.youtube.com/watch?v=-zueUUhTIsc
[youtube+GetPOT] -zueUUhTIsc: Downloading webpage
[youtube+GetPOT] -zueUUhTIsc: Downloading tv client config
[debug] [GetPOT] Fetching player PO Token for tv client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: -zueUUhTIsc
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmpwpsihgn1"
127.0.0.1 - - [03/May/2025 00:12:46] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [03/May/2025 00:12:46] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["MlOm3x7zWzX0cqbjuKjC9hB_BZkEu-9T2u1RQB_0ODCNLew-lwrFCLbfkLK-rs1O7DDgT7-DYdw8hPoLftrQyuRH5KGTBgmhhOU8WxleUNs8CAycpQ=="]

[debug] [GetPOT] PhantomJS: Generated POT: MlOm3x7zWzX0cqbjuKjC9hB_BZkEu-9T2u1RQB_0ODCNLew-lwrFCLbfkLK-rs1O7DDgT7-DYdw8hPoLftrQyuRH5KGTBgmhhOU8WxleUNs8CAycpQ==
[debug] [GetPOT] Fetching gvs PO Token for tv client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: 108233318205464433138||
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmpwpsihgn1"
127.0.0.1 - - [03/May/2025 00:12:48] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [03/May/2025 00:12:48] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["Ml_wKamR57_NufAVaA8Qz4Mdej7UkYjaY82afL844h5EOTL-cjB935Vv9_zEqquMV5oaOEloQ0L9oLeU8MvFcqqEl4Tosga5mdHH1tCtT_Tia_mHLDnaDA0mNxN1HJrxIw=="]

[debug] [GetPOT] PhantomJS: Generated POT: Ml_wKamR57_NufAVaA8Qz4Mdej7UkYjaY82afL844h5EOTL-cjB935Vv9_zEqquMV5oaOEloQ0L9oLeU8MvFcqqEl4Tosga5mdHH1tCtT_Tia_mHLDnaDA0mNxN1HJrxIw==
[debug] Loading youtube-sts.aa3fc80b-main from cache
[youtube+GetPOT] -zueUUhTIsc: Downloading tv player API JSON
[debug] [GetPOT] Fetching player PO Token for web client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: -zueUUhTIsc
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmpwpsihgn1"
127.0.0.1 - - [03/May/2025 00:12:50] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [03/May/2025 00:12:50] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["MlOIieEI8G64vYi1UFX-qEM5AHMGjE8Zon2-VHzGGeQQeCNURM5JdNQlu1q3rACZhrdt4Vh08dmSkoKqcOJjtMgbTa_OD1VITNiQDpMy8NgcJ9xewg=="]

[debug] [GetPOT] PhantomJS: Generated POT: MlOIieEI8G64vYi1UFX-qEM5AHMGjE8Zon2-VHzGGeQQeCNURM5JdNQlu1q3rACZhrdt4Vh08dmSkoKqcOJjtMgbTa_OD1VITNiQDpMy8NgcJ9xewg==
[debug] [GetPOT] Fetching gvs PO Token for web client
[debug] [GetPOT] PhantomJS: Generating POT for content binding: 108233318205464433138||
[youtube+GetPOT] Executing JS
[debug] [youtube+GetPOT] PhantomJS command line: phantomjs "--ssl-protocol=any" "--web-security=false" "C:\Users\QUARAX~1\AppData\Local\Temp\tmpwpsihgn1"
127.0.0.1 - - [03/May/2025 00:12:51] "GET /descrambled HTTP/1.1" 200 -
127.0.0.1 - - [03/May/2025 00:12:51] "POST /genit HTTP/1.1" 200 -
[debug] [youtube+GetPOT] phantomjs stdout: ["Ml9QS-XMPQq9LFB3urTsNt_Qy1iRTfkIl6ARBPxfgsHgImYTE-VyFxGFuvDVCmBdk71urkTc7zEUoTQWKcIZoa_HEonxbLREyE-FDHXz-N-UzysXJggjafW33kw-6BW5Pg=="]

[debug] [GetPOT] PhantomJS: Generated POT: Ml9QS-XMPQq9LFB3urTsNt_Qy1iRTfkIl6ARBPxfgsHgImYTE-VyFxGFuvDVCmBdk71urkTc7zEUoTQWKcIZoa_HEonxbLREyE-FDHXz-N-UzysXJggjafW33kw-6BW5Pg==
[debug] Loading youtube-nsig.aa3fc80b-main from cache
[debug] [youtube+GetPOT] Decrypted nsig d31aJ7RfyUkK6rQS-hILOA => GEcimQzAWlyHBw
[debug] [youtube+GetPOT] Decrypted nsig CS1DZSVIE9lLBsZT8joP0K => P9ecISInVteltQ
[info] -zueUUhTIsc: Downloading subtitles: ab-en-US, aa-en-US, af-en-US, ak-en-US, sq-en-US, am-en-US, ar-en-US, hy-en-US, as-en-US, ay-en-US, az-en-US, bn-en-US, ba-en-US, eu-en-US, be-en-US, bho-en-US, bs-en-US, br-en-US, bg-en-US, my-en-US, ca-en-US, ceb-en-US, zh-Hans-en-US, zh-Hant-en-US, co-en-US, hr-en-US, cs-en-US, da-en-US, dv-en-US, nl-en-US, dz-en-US, en-en-US, eo-en-US, et-en-US, ee-en-US, fo-en-US, fj-en-US, fil-en-US, fi-en-US, fr-en-US, gaa-en-US, gl-en-US, lg-en-US, ka-en-US, de-en-US, el-en-US, gn-en-US, gu-en-US, ht-en-US, ha-en-US, haw-en-US, iw-en-US, hi-en-US, hmn-en-US, hu-en-US, is-en-US, ig-en-US, id-en-US, iu-en-US, ga-en-US, it-en-US, ja-en-US, jv-en-US, kl-en-US, kn-en-US, kk-en-US, kha-en-US, km-en-US, rw-en-US, ko-en-US, kri-en-US, ku-en-US, ky-en-US, lo-en-US, la-en-US, lv-en-US, ln-en-US, lt-en-US, lua-en-US, luo-en-US, lb-en-US, mk-en-US, mg-en-US, ms-en-US, ml-en-US, mt-en-US, gv-en-US, mi-en-US, mr-en-US, mn-en-US, mfe-en-US, ne-en-US, new-en-US, nso-en-US, no-en-US, ny-en-US, oc-en-US, or-en-US, om-en-US, os-en-US, pam-en-US, ps-en-US, fa-en-US, pl-en-US, pt-en-US, pt-PT-en-US, pa-en-US, qu-en-US, ro-en-US, rn-en-US, ru-en-US, sm-en-US, sg-en-US, sa-en-US, gd-en-US, sr-en-US, crs-en-US, sn-en-US, sd-en-US, si-en-US, sk-en-US, sl-en-US, so-en-US, st-en-US, es-en-US, su-en-US, sw-en-US, ss-en-US, sv-en-US, tg-en-US, ta-en-US, tt-en-US, te-en-US, th-en-US, bo-en-US, ti-en-US, to-en-US, ts-en-US, tn-en-US, tum-en-US, tr-en-US, tk-en-US, uk-en-US, ur-en-US, ug-en-US, uz-en-US, ve-en-US, vi-en-US, war-en-US, cy-en-US, fy-en-US, wo-en-US, xh-en-US, yi-en-US, yo-en-US, zu-en-US
[debug] Sort order given by extractor: quality, res, fps, hdr:12, source, vcodec, channels, acodec, lang, proto
[debug] Formats sorted by: hasvid, ie_pref, quality, res, fps, hdr:12(7), source, vcodec, channels, acodec, lang, proto, size, br, asr, vext, aext, hasaud, id
[info] -zueUUhTIsc: Downloading 1 format(s): 18
[info] Writing video subtitles to: Can the ‘Thunderbolts＊’ Cast ID Each Other’s Body Parts？ ｜ MTV [-zueUUhTIsc].ab-en-US.vtt
[debug] Invoking http downloader on "https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=vZcVaOqxLpezlu8PgOW02QM&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746270765&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=A68C903590AB54568805E3576B60A445C305DE84.45CF4115AA5DD7CBF612C7D6B54DC432BA61DB94&key=yt8&lang=en-US&tlang=ab-en-US&fmt=vtt"


ERROR: Did not get any data blocks
  File "yt_dlp\__main__.py", line 17, in <module>
  File "yt_dlp\__init__.py", line 1100, in main
  File "yt_dlp\__init__.py", line 1091, in _real_main
  File "yt_dlp\YoutubeDL.py", line 3634, in download
  File "yt_dlp\YoutubeDL.py", line 3607, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1642, in extract_info
  File "yt_dlp\YoutubeDL.py", line 1653, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1809, in __extract_info
  File "yt_dlp\YoutubeDL.py", line 1868, in process_ie_result
  File "yt_dlp\YoutubeDL.py", line 3040, in process_video_result
  File "yt_dlp\YoutubeDL.py", line 185, in wrapper
  File "yt_dlp\YoutubeDL.py", line 3296, in process_info
  File "yt_dlp\YoutubeDL.py", line 4375, in _write_subtitles
  File "yt_dlp\YoutubeDL.py", line 3228, in dl
  File "yt_dlp\downloader\common.py", line 468, in download
  File "yt_dlp\downloader\http.py", line 368, in real_download
  File "yt_dlp\downloader\http.py", line 334, in download
  File "yt_dlp\YoutubeDL.py", line 1111, in report_error
  File "yt_dlp\YoutubeDL.py", line 1039, in trouble


[info] Writing video subtitles to: Can the ‘Thunderbolts＊’ Cast ID Each Other’s Body Parts？ ｜ MTV [-zueUUhTIsc].aa-en-US.vtt
[debug] Invoking http downloader on "https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=vZcVaOqxLpezlu8PgOW02QM&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746270765&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=A68C903590AB54568805E3576B60A445C305DE84.45CF4115AA5DD7CBF612C7D6B54DC432BA61DB94&key=yt8&lang=en-US&tlang=aa-en-US&fmt=vtt"


ERROR: Did not get any data blocks
  File "yt_dlp\__main__.py", line 17, in <module>
  File "yt_dlp\__init__.py", line 1100, in main
  File "yt_dlp\__init__.py", line 1091, in _real_main
  File "yt_dlp\YoutubeDL.py", line 3634, in download
  File "yt_dlp\YoutubeDL.py", line 3607, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1642, in extract_info
  File "yt_dlp\YoutubeDL.py", line 1653, in wrapper
  File "yt_dlp\YoutubeDL.py", line 1809, in __extract_info
  File "yt_dlp\YoutubeDL.py", line 1868, in process_ie_result
  File "yt_dlp\YoutubeDL.py", line 3040, in process_video_result
  File "yt_dlp\YoutubeDL.py", line 185, in wrapper
  File "yt_dlp\YoutubeDL.py", line 3296, in process_info
  File "yt_dlp\YoutubeDL.py", line 4375, in _write_subtitles
  File "yt_dlp\YoutubeDL.py", line 3228, in dl
  File "yt_dlp\downloader\common.py", line 468, in download
  File "yt_dlp\downloader\http.py", line 368, in real_download
  File "yt_dlp\downloader\http.py", line 334, in download
  File "yt_dlp\YoutubeDL.py", line 1111, in report_error
  File "yt_dlp\YoutubeDL.py", line 1039, in trouble
```
`
author_association: NONE
user: login: bashonly
created_at: 2025-05-03T04:21:59Z
body: If you copy one of the PO token values from your log and manually add it to the subtitles URL (e.g. append `&c=WEB&potc=1&pot=POTOKEN`), can you download the subs by pasting the modified URL into your browser's address bar?
author_association: MEMBER
user: login: Quaraxkad
created_at: 2025-05-03T05:58:19Z
body: Yes, this url from Youtube-DL did not work: https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=6a4VaJmvOaHs2_gP24jV0QM&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746276697&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=8832DAA9201948541EAE658BEA289A9EC4BB79F6.5320D362B415E843DDE7332BAD89EB0B1CFFBB53&key=yt8&lang=en-US&fmt=vtt

But adding POT from the web client works: https://www.youtube.com/api/timedtext?v=-zueUUhTIsc&ei=6a4VaJmvOaHs2_gP24jV0QM&opi=112496729&exp=xpe&xoaf=5&hl=en&ip=0.0.0.0&ipbits=0&expire=1746276697&sparams=ip%2Cipbits%2Cexpire%2Cv%2Cei%2Copi%2Cexp%2Cxoaf&signature=8832DAA9201948541EAE658BEA289A9EC4BB79F6.5320D362B415E843DDE7332BAD89EB0B1CFFBB53&key=yt8&lang=en-US&fmt=vtt&c=WEB&potc=1&pot=MlNqWoNSdC89zWpm0ceuQLCOSGnMecyjS3Ydq3IPzml4tRJXGnG2zeyjSGPcC0312n_wXtZ9VNm7OvyEKpK_nC2k_Y4lAwgZ2s2Um6s9NZ6btDKuaA%3D%3D
author_association: NONE
user: login: bashonly
created_at: 2025-05-07T16:00:34Z
body: This seems to only affect subtitles extracted with clients that already require a PO token (e.g. `web`). `ios` and `tv` subs do not seem to be affected.

If you are encountering this issue without passing any PO token or extractor-args, a workaround is to add this to your command:

```
--extractor-args "youtube:player_client=default,-web"
```

(NOTE: you shouldn't use the above extractor-arg when attempting to download livestreams, since it will result in reduced format availability)
author_association: MEMBER
user: login: error-reporting
created_at: 2025-05-10T14:37:56Z
body: is mweb client subtitle affected by this?
author_association: NONE
user: login: error-reporting
created_at: 2025-05-12T02:55:57Z
body: > This seems to only affect subtitles extracted with clients that already require a PO token (e.g. `web`). `ios` and `tv` subs do not seem to be affected.
> 
> If you are encountering this issue without passing any PO token or extractor-args, a workaround is to add this to your command:
> 
> ```
> --extractor-args "youtube:player_client=default,-web"
> ```
> 
> (NOTE: you shouldn't use the above extractor-arg when attempting to download livestreams, since it will result in reduced format availability)

is mweb client subtitle affected by this?
author_association: NONE
user: login: bashonly
created_at: 2025-05-12T03:41:46Z
body: @error-reporting please do not repeatedly comment with the same question. I hadn't replied yet because I don't know the answer to your question.
author_association: MEMBER
user: login: error-reporting
created_at: 2025-05-17T12:19:55Z
body: so the situation on mweb client subtitle poToken requirement is undetermined?
author_association: NONE
user: login: andrei-cb
created_at: 2025-05-19T21:12:32Z
body: how does YouTube generate this token? Where is it located? Can't find it in cookies or storage
author_association: NONE
user: login: bashonly
created_at: 2025-05-19T21:38:40Z
body: @andrei-cb https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide#introduction
author_association: MEMBER
user: login: DanHarkless
created_at: 2025-05-20T01:19:31Z
body: Thank you very much for the workaround!  I was very confused as to what was going on when I encountered this today, since the "ERROR: Did not get any data blocks" occurs intermittently.  Over the course of several videos I was downloading, on the ones that hit this error, it would occur on every retry, unless I changed to a different local directory, in which case it always worked on the first try.

Clearly that was just a weird coincidence, and it depends on either which YouTube servers you get load-balanced to on a given request (some with this new subtitle PO Token requirement, and some without), or it has to do with rate-limiting (I'm currently coming from a shared IP address).
author_association: NONE

Diff:
diff --git a/README.md b/README.md
index 9f542844e095..aaa3beb716a2 100644
--- a/README.md
+++ b/README.md
@@ -1805,7 +1805,7 @@ The following extractors use this feature:
 * `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
 * `data_sync_id`: Overrides the account Data Sync ID used in Innertube API requests. This may be needed if you are using an account with `youtube:player_skip=webpage,configs` or `youtubetab:skip=webpage`
 * `visitor_data`: Overrides the Visitor Data used in Innertube API requests. This should be used with `player_skip=webpage,configs` and without cookies. Note: this may have adverse effects if used improperly. If a session from a browser is wanted, you should pass cookies instead (which contain the Visitor ID)
-* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs) or `player` (Innertube player request)
+* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be any of `gvs` (Google Video Server URLs), `player` (Innertube player request) or `subs` (Subtitles)
 * `pot_trace`: Enable debug logging for PO Token fetching. Either `true` or `false` (default)
 * `fetch_pot`: Policy to use for fetching a PO Token from providers. One of `always` (always try fetch a PO Token regardless if the client requires one for the given context), `never` (never fetch a PO Token), or `auto` (default; only fetch a PO Token if the client requires one for the given context)
 
diff --git a/test/test_pot/test_pot_builtin_utils.py b/test/test_pot/test_pot_builtin_utils.py
index 1682e42a16fb..a95fc4e15941 100644
--- a/test/test_pot/test_pot_builtin_utils.py
+++ b/test/test_pot/test_pot_builtin_utils.py
@@ -15,6 +15,7 @@ class TestGetWebPoContentBinding:
           for context, is_authenticated, expected in [
             (PoTokenContext.GVS, False, ('example-visitor-data', ContentBindingType.VISITOR_DATA)),
             (PoTokenContext.PLAYER, False, ('example-video-id', ContentBindingType.VIDEO_ID)),
+            (PoTokenContext.SUBS, False, ('example-video-id', ContentBindingType.VIDEO_ID)),
             (PoTokenContext.GVS, True, ('example-data-sync-id', ContentBindingType.DATASYNC_ID)),
         ]],
         ('WEB_REMIX', PoTokenContext.GVS, False, ('example-visitor-data', ContentBindingType.VISITOR_DATA)),
diff --git a/yt_dlp/extractor/youtube/_base.py b/yt_dlp/extractor/youtube/_base.py
index 4194e1c21779..9c5bb75fe4ff 100644
--- a/yt_dlp/extractor/youtube/_base.py
+++ b/yt_dlp/extractor/youtube/_base.py
@@ -35,6 +35,7 @@
 class _PoTokenContext(enum.Enum):
     PLAYER = 'player'
     GVS = 'gvs'
+    SUBS = 'subs'
 
 
 # any clients starting with _ cannot be explicitly requested by the user
@@ -787,6 +788,7 @@ def _download_webpage_with_retries(self, *args, retry_fatal=False, retry_on_stat
 
     def _download_ytcfg(self, client, video_id):
         url = {
+            'mweb': 'https://m.youtube.com',
             'web': 'https://www.youtube.com',
             'web_music': 'https://music.youtube.com',
             'web_embedded': f'https://www.youtube.com/embed/{video_id}?html5=1',
diff --git a/yt_dlp/extractor/youtube/_video.py b/yt_dlp/extractor/youtube/_video.py
index b4c6ba45380e..9f929664fbee 100644
--- a/yt_dlp/extractor/youtube/_video.py
+++ b/yt_dlp/extractor/youtube/_video.py
@@ -72,6 +72,9 @@
 
 STREAMING_DATA_CLIENT_NAME = '__yt_dlp_client'
 STREAMING_DATA_INITIAL_PO_TOKEN = '__yt_dlp_po_token'
+STREAMING_DATA_FETCH_SUBS_PO_TOKEN = '__yt_dlp_fetch_subs_po_token'
+STREAMING_DATA_INNERTUBE_CONTEXT = '__yt_dlp_innertube_context'
+
 PO_TOKEN_GUIDE_URL = 'https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide'
 
 
@@ -2863,7 +2866,8 @@ def _get_config_po_token(self, client: str, context: _PoTokenContext):
                 continue
 
     def fetch_po_token(self, client='web', context=_PoTokenContext.GVS, ytcfg=None, visitor_data=None,
-                       data_sync_id=None, session_index=None, player_url=None, video_id=None, webpage=None, **kwargs):
+                       data_sync_id=None, session_index=None, player_url=None, video_id=None, webpage=None,
+                       required=False, **kwargs):
         """
         Fetch a PO Token for a given client and context. This function will validate required parameters for a given context and client.
 
@@ -2878,6 +2882,7 @@ def fetch_po_token(self, client='web', context=_PoTokenContext.GVS, ytcfg=None,
         @param player_url: player URL.
         @param video_id: video ID.
         @param webpage: video webpage.
+        @param required: Whether the PO Token is required (i.e. try to fetch unless policy is "never").
         @param kwargs: Additional arguments to pass down. May be more added in the future.
         @return: The fetched PO Token. None if it could not be fetched.
         """
@@ -2926,6 +2931,7 @@ def fetch_po_token(self, client='web', context=_PoTokenContext.GVS, ytcfg=None,
             player_url=player_url,
             video_id=video_id,
             video_webpage=webpage,
+            required=required,
             **kwargs,
         )
 
@@ -2945,6 +2951,7 @@ def _fetch_po_token(self, client, **kwargs):
             or (
                 fetch_pot_policy == 'auto'
                 and _PoTokenContext(context) not in self._get_default_ytcfg(client)['PO_TOKEN_REQUIRED_CONTEXTS']
+                and not kwargs.get('required', False)
             )
         ):
             return None
@@ -3133,6 +3140,8 @@ def append_client(*client_names):
                 player_url = self._download_player_url(video_id)
                 tried_iframe_fallback = True
 
+            pr = initial_pr if client == 'web' else None
+
             visitor_data = visitor_data or self._extract_visitor_data(master_ytcfg, initial_pr, player_ytcfg)
             data_sync_id = data_sync_id or self._extract_data_sync_id(master_ytcfg, initial_pr, player_ytcfg)
 
@@ -3147,12 +3156,19 @@ def append_client(*client_names):
                 'ytcfg': player_ytcfg or self._get_default_ytcfg(client),
             }
 
-            player_po_token = self.fetch_po_token(
+            # Don't need a player PO token for WEB if using player response from webpage
+            player_po_token = None if pr else self.fetch_po_token(
                 context=_PoTokenContext.PLAYER, **fetch_po_token_args)
 
             gvs_po_token = self.fetch_po_token(
                 context=_PoTokenContext.GVS, **fetch_po_token_args)
 
+            fetch_subs_po_token_func = functools.partial(
+                self.fetch_po_token,
+                context=_PoTokenContext.SUBS,
+                **fetch_po_token_args,
+            )
+
             required_pot_contexts = self._get_default_ytcfg(client)['PO_TOKEN_REQUIRED_CONTEXTS']
 
             if (
@@ -3179,7 +3195,6 @@ def append_client(*client_names):
                     only_once=True)
                 deprioritize_pr = True
 
-            pr = initial_pr if client == 'web' else None
             try:
                 pr = pr or self._extract_player_response(
                     client, video_id,
@@ -3197,10 +3212,13 @@ def append_client(*client_names):
             if pr_id := self._invalid_player_response(pr, video_id):
                 skipped_clients[client] = pr_id
             elif pr:
-                # Save client name for introspection later
-                sd = traverse_obj(pr, ('streamingData', {dict})) or {}
+                # Save client details for introspection later
+                innertube_context = traverse_obj(player_ytcfg or self._get_default_ytcfg(client), 'INNERTUBE_CONTEXT')
+                sd = pr.setdefault('streamingData', {})
                 sd[STREAMING_DATA_CLIENT_NAME] = client
                 sd[STREAMING_DATA_INITIAL_PO_TOKEN] = gvs_po_token
+                sd[STREAMING_DATA_INNERTUBE_CONTEXT] = innertube_context
+                sd[STREAMING_DATA_FETCH_SUBS_PO_TOKEN] = fetch_subs_po_token_func
                 for f in traverse_obj(sd, (('formats', 'adaptiveFormats'), ..., {dict})):
                     f[STREAMING_DATA_CLIENT_NAME] = client
                     f[STREAMING_DATA_INITIAL_PO_TOKEN] = gvs_po_token
@@ -3262,6 +3280,25 @@ def _report_pot_format_skipped(self, video_id, client_name, proto):
         else:
             self.report_warning(msg, only_once=True)
 
+    def _report_pot_subtitles_skipped(self, video_id, client_name, msg=None):
+        msg = msg or (
+            f'{video_id}: Some {client_name} client subtitles require a PO Token which was not provided. '
+            'They will be discarded since they are not downloadable as-is. '
+            f'You can manually pass a Subtitles PO Token for this client with '
+            f'--extractor-args "youtube:po_token={client_name}.subs+XXX" . '
+            f'For more information, refer to  {PO_TOKEN_GUIDE_URL}')
+
+        subs_wanted = any((
+            self.get_param('writesubtitles'),
+            self.get_param('writeautomaticsub'),
+            self.get_param('listsubtitles')))
+
+        # Only raise a warning for non-default clients, to not confuse users.
+        if not subs_wanted or client_name in (*self._DEFAULT_CLIENTS, *self._DEFAULT_AUTHED_CLIENTS):
+            self.write_debug(msg, only_once=True)
+        else:
+            self.report_warning(msg, only_once=True)
+
     def _extract_formats_and_subtitles(self, streaming_data, video_id, player_url, live_status, duration):
         CHUNK_SIZE = 10 << 20
         PREFERRED_LANG_VALUE = 10
@@ -3553,6 +3590,9 @@ def process_manifest_format(f, proto, client_name, itag, po_token):
                     hls_manifest_url = hls_manifest_url.rstrip('/') + f'/pot/{po_token}'
                 fmts, subs = self._extract_m3u8_formats_and_subtitles(
                     hls_manifest_url, video_id, 'mp4', fatal=False, live=live_status == 'is_live')
+                for sub in traverse_obj(subs, (..., ..., {dict})):
+                    # HLS subs (m3u8) do not need a PO token; save client name for debugging
+                    sub[STREAMING_DATA_CLIENT_NAME] = client_name
                 subtitles = self._merge_subtitles(subs, subtitles)
                 for f in fmts:
                     if process_manifest_format(f, 'hls', client_name, self._search_regex(
@@ -3564,6 +3604,9 @@ def process_manifest_format(f, proto, client_name, itag, po_token):
                 if po_token:
                     dash_manifest_url = dash_manifest_url.rstrip('/') + f'/pot/{po_token}'
                 formats, subs = self._extract_mpd_formats_and_subtitles(dash_manifest_url, video_id, fatal=False)
+                for sub in traverse_obj(subs, (..., ..., {dict})):
+                    # TODO: Investigate if DASH subs ever need a PO token; save client name for debugging
+                    sub[STREAMING_DATA_CLIENT_NAME] = client_name
                 subtitles = self._merge_subtitles(subs, subtitles)  # Prioritize HLS subs over DASH
                 for f in formats:
                     if process_manifest_format(f, 'dash', client_name, f['format_id'], po_token):
@@ -3890,47 +3933,81 @@ def is_bad_format(fmt):
                 'quality', 'res', 'fps', 'hdr:12', 'source', 'vcodec', 'channels', 'acodec', 'lang', 'proto'),
         }
 
+        def get_lang_code(track):
+            return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
+                    or track.get('languageCode'))
+
+        def process_language(container, base_url, lang_code, sub_name, client_name, query):
+            lang_subs = container.setdefault(lang_code, [])
+            for fmt in self._SUBTITLE_FORMATS:
+                query = {**query, 'fmt': fmt}
+                lang_subs.append({
+                    'ext': fmt,
+                    'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
+                    'name': sub_name,
+                    STREAMING_DATA_CLIENT_NAME: client_name,
+                })
+
         subtitles = {}
-        pctr = traverse_obj(player_responses, (..., 'captions', 'playerCaptionsTracklistRenderer'), expected_type=dict)
-        if pctr:
-            def get_lang_code(track):
-                return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
-                        or track.get('languageCode'))
-
-            # Converted into dicts to remove duplicates
-            captions = {
-                get_lang_code(sub): sub
-                for sub in traverse_obj(pctr, (..., 'captionTracks', ...))}
-            translation_languages = {
-                lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
-                for lang in traverse_obj(pctr, (..., 'translationLanguages', ...))}
-
-            def process_language(container, base_url, lang_code, sub_name, query):
-                lang_subs = container.setdefault(lang_code, [])
-                for fmt in self._SUBTITLE_FORMATS:
-                    query.update({
-                        'fmt': fmt,
-                    })
-                    lang_subs.append({
-                        'ext': fmt,
-                        'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
-                        'name': sub_name,
-                    })
+        skipped_subs_clients = set()
+        prs = traverse_obj(player_responses, (
+            # Filter out initial_pr which does not have streamingData (smuggled client context)
+            lambda _, v: v['streamingData'] and v['captions']['playerCaptionsTracklistRenderer']))
+
+        pctrs = traverse_obj(prs, (..., 'captions', 'playerCaptionsTracklistRenderer', {dict}))
+        translation_languages = {
+            lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
+            for lang in traverse_obj(pctrs, (..., 'translationLanguages', ..., {dict}))}
+        # NB: Constructing the full subtitle dictionary is slow
+        get_translated_subs = 'translated_subs' not in self._configuration_arg('skip') and (
+            self.get_param('writeautomaticsub', False) or self.get_param('listsubtitles'))
+
+        all_captions = traverse_obj(pctrs, (..., 'captionTracks', ..., {dict}))
+        need_subs_langs = {get_lang_code(sub) for sub in all_captions if sub.get('kind') != 'asr'}
+        need_caps_langs = {
+            remove_start(get_lang_code(sub), 'a-')
+            for sub in all_captions if sub.get('kind') == 'asr'}
+
+        for pr in prs:
+            pctr = pr['captions']['playerCaptionsTracklistRenderer']
+            client_name = pr['streamingData'][STREAMING_DATA_CLIENT_NAME]
+            innertube_client_name = pr['streamingData'][STREAMING_DATA_INNERTUBE_CONTEXT]['client']['clientName']
+            required_contexts = self._get_default_ytcfg(client_name)['PO_TOKEN_REQUIRED_CONTEXTS']
+            fetch_subs_po_token_func = pr['streamingData'][STREAMING_DATA_FETCH_SUBS_PO_TOKEN]
+
+            pot_params = {}
+            already_fetched_pot = False
+
+            for caption_track in traverse_obj(pctr, ('captionTracks', lambda _, v: v['baseUrl'])):
+                base_url = caption_track['baseUrl']
+                qs = parse_qs(base_url)
+                lang_code = get_lang_code(caption_track)
+                requires_pot = (
+                    # We can detect the experiment for now
+                    any(e in traverse_obj(qs, ('exp', ...)) for e in ('xpe', 'xpv'))
+                    or _PoTokenContext.SUBS in required_contexts)
+
+                if not already_fetched_pot:
+                    already_fetched_pot = True
+                    if subs_po_token := fetch_subs_po_token_func(required=requires_pot):
+                        pot_params.update({
+                            'pot': subs_po_token,
+                            'potc': '1',
+                            'c': innertube_client_name,
+                        })
 
-            # NB: Constructing the full subtitle dictionary is slow
-            get_translated_subs = 'translated_subs' not in self._configuration_arg('skip') and (
-                self.get_param('writeautomaticsub', False) or self.get_param('listsubtitles'))
-            for lang_code, caption_track in captions.items():
-                base_url = caption_track.get('baseUrl')
-                orig_lang = parse_qs(base_url).get('lang', [None])[-1]
-                if not base_url:
-                    continue
+                if not pot_params and requires_pot:
+                    skipped_subs_clients.add(client_name)
+                    self._report_pot_subtitles_skipped(video_id, client_name)
+                    break
+
+                orig_lang = qs.get('lang', [None])[-1]
                 lang_name = self._get_text(caption_track, 'name', max_runs=1)
                 if caption_track.get('kind') != 'asr':
                     if not lang_code:
                         continue
                     process_language(
-                        subtitles, base_url, lang_code, lang_name, {})
+                        subtitles, base_url, lang_code, lang_name, client_name, pot_params)
                     if not caption_track.get('isTranslatable'):
                         continue
                 for trans_code, trans_name in translation_languages.items():
@@ -3950,10 +4027,25 @@ def process_language(container, base_url, lang_code, sub_name, query):
                         # Add an "-orig" label to the original language so that it can be distinguished.
                         # The subs are returned without "-orig" as well for compatibility
                         process_language(
-                            automatic_captions, base_url, f'{trans_code}-orig', f'{trans_name} (Original)', {})
+                            automatic_captions, base_url, f'{trans_code}-orig',
+                            f'{trans_name} (Original)', client_name, pot_params)
                     # Setting tlang=lang returns damaged subtitles.
-                    process_language(automatic_captions, base_url, trans_code, trans_name,
-                                     {} if orig_lang == orig_trans_code else {'tlang': trans_code})
+                    process_language(
+                        automatic_captions, base_url, trans_code, trans_name, client_name,
+                        pot_params if orig_lang == orig_trans_code else {'tlang': trans_code, **pot_params})
+
+            # Avoid duplication if we've already got everything we need
+            need_subs_langs.difference_update(subtitles)
+            need_caps_langs.difference_update(automatic_captions)
+            if not (need_subs_langs or need_caps_langs):
+                break
+
+        if skipped_subs_clients and (need_subs_langs or need_caps_langs):
+            self._report_pot_subtitles_skipped(video_id, True, msg=join_nonempty(
+                f'{video_id}: There are missing subtitles languages because a PO token was not provided.',
+                need_subs_langs and f'Subtitles for these languages are missing: {", ".join(need_subs_langs)}.',
+                need_caps_langs and f'Automatic captions for {len(need_caps_langs)} languages are missing.',
+                delim=' '))
 
         info['automatic_captions'] = automatic_captions
         info['subtitles'] = subtitles
diff --git a/yt_dlp/extractor/youtube/pot/provider.py b/yt_dlp/extractor/youtube/pot/provider.py
index 53af92d30bb2..13b3b1f9bb74 100644
--- a/yt_dlp/extractor/youtube/pot/provider.py
+++ b/yt_dlp/extractor/youtube/pot/provider.py
@@ -39,6 +39,7 @@
 class PoTokenContext(enum.Enum):
     GVS = 'gvs'
     PLAYER = 'player'
+    SUBS = 'subs'
 
 
 @dataclasses.dataclass
diff --git a/yt_dlp/extractor/youtube/pot/utils.py b/yt_dlp/extractor/youtube/pot/utils.py
index 1c0db243bf62..7a5b7d4ab3b5 100644
--- a/yt_dlp/extractor/youtube/pot/utils.py
+++ b/yt_dlp/extractor/youtube/pot/utils.py
@@ -51,7 +51,7 @@ def get_webpo_content_binding(
                     return visitor_id, ContentBindingType.VISITOR_ID
             return request.visitor_data, ContentBindingType.VISITOR_DATA
 
-    elif request.context == PoTokenContext.PLAYER or client_name != 'WEB_REMIX':
+    elif request.context in (PoTokenContext.PLAYER, PoTokenContext.SUBS):
         return request.video_id, ContentBindingType.VIDEO_ID
 
     return None, None


Reviews:
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T07:16:33Z
user: login: coletdjnz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T07:17:49Z
user: login: coletdjnz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T07:31:31Z
user: login: coletdjnz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T07:34:40Z
user: login: gamer191
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T03:37:00Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T06:57:34Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T07:00:35Z
user: login: coletdjnz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-22T07:49:12Z
user: login: coletdjnz
body: lgtm. testing and working with session with the experiment
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T09:05:45Z

Review comments:
diff_hunk: @@ -3890,47 +3925,79 @@ def is_bad_format(fmt):
                 'quality', 'res', 'fps', 'hdr:12', 'source', 'vcodec', 'channels', 'acodec', 'lang', 'proto'),
         }
 
+        def get_lang_code(track):
+            return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
+                    or track.get('languageCode'))
+
+        def process_language(container, base_url, lang_code, sub_name, client_name, query):
+            lang_subs = container.setdefault(lang_code, [])
+            for fmt in self._SUBTITLE_FORMATS:
+                query = {**query, 'fmt': fmt}
+                lang_subs.append({
+                    'ext': fmt,
+                    'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
+                    'name': sub_name,
+                    STREAMING_DATA_CLIENT_NAME: client_name,
+                })
+
         subtitles = {}
-        pctr = traverse_obj(player_responses, (..., 'captions', 'playerCaptionsTracklistRenderer'), expected_type=dict)
-        if pctr:
-            def get_lang_code(track):
-                return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
-                        or track.get('languageCode'))
-
-            # Converted into dicts to remove duplicates
-            captions = {
-                get_lang_code(sub): sub
-                for sub in traverse_obj(pctr, (..., 'captionTracks', ...))}
-            translation_languages = {
-                lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
-                for lang in traverse_obj(pctr, (..., 'translationLanguages', ...))}
-
-            def process_language(container, base_url, lang_code, sub_name, query):
-                lang_subs = container.setdefault(lang_code, [])
-                for fmt in self._SUBTITLE_FORMATS:
-                    query.update({
-                        'fmt': fmt,
-                    })
-                    lang_subs.append({
-                        'ext': fmt,
-                        'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
-                        'name': sub_name,
+        skipped_subs_clients = set()
+        prs = traverse_obj(player_responses, (
+            # Filter out initial_pr which does not have streamingData (smuggled client context)
+            lambda _, v: v['streamingData'] and v['captions']['playerCaptionsTracklistRenderer']))
+
+        pctrs = traverse_obj(prs, (..., 'captions', 'playerCaptionsTracklistRenderer', {dict}))
+        translation_languages = {
+            lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
+            for lang in traverse_obj(pctrs, (..., 'translationLanguages', ..., {dict}))}
+        # NB: Constructing the full subtitle dictionary is slow
+        get_translated_subs = 'translated_subs' not in self._configuration_arg('skip') and (
+            self.get_param('writeautomaticsub', False) or self.get_param('listsubtitles'))
+
+        all_captions = traverse_obj(pctrs, (..., 'captionTracks', ..., {dict}))
+        need_subs_langs = {get_lang_code(sub) for sub in all_captions if sub.get('kind') != 'asr'}
+        need_caps_langs = {
+            remove_start(get_lang_code(sub), 'a-')
+            for sub in all_captions if sub.get('kind') == 'asr'}
+
+        for pr in prs:
+            pctr = pr['captions']['playerCaptionsTracklistRenderer']
+            client_name = pr['streamingData'][STREAMING_DATA_CLIENT_NAME]
+            innertube_client_name = traverse_obj(
+                pr, ('streamingData', STREAMING_DATA_INNERTUBE_CONTEXT, 'client', 'clientName'))
path: yt_dlp/extractor/youtube/_video.py
user: login: bashonly
body: can we be bold here
```suggestion
            innertube_client_name = pr['streamingData'][STREAMING_DATA_INNERTUBE_CONTEXT]['client']['clientName']
```
created_at: 2025-05-20T07:09:44Z
author_association: MEMBER
diff_hunk: @@ -3950,10 +4017,30 @@ def process_language(container, base_url, lang_code, sub_name, query):
                         # Add an "-orig" label to the original language so that it can be distinguished.
                         # The subs are returned without "-orig" as well for compatibility
                         process_language(
-                            automatic_captions, base_url, f'{trans_code}-orig', f'{trans_name} (Original)', {})
+                            automatic_captions, base_url, f'{trans_code}-orig',
+                            f'{trans_name} (Original)', client_name, pot_params)
                     # Setting tlang=lang returns damaged subtitles.
-                    process_language(automatic_captions, base_url, trans_code, trans_name,
-                                     {} if orig_lang == orig_trans_code else {'tlang': trans_code})
+                    process_language(
+                        automatic_captions, base_url, trans_code, trans_name, client_name,
+                        pot_params if orig_lang == orig_trans_code else {'tlang': trans_code, **pot_params})
+
+            # Avoid duplication if we got everything we needed from the first player reponse
path: yt_dlp/extractor/youtube/_video.py
user: login: bashonly
body: ```suggestion
            # Avoid duplication if we've already got everything we need
```
created_at: 2025-05-20T07:10:55Z
author_association: MEMBER
diff_hunk: @@ -3950,10 +4017,30 @@ def process_language(container, base_url, lang_code, sub_name, query):
                         # Add an "-orig" label to the original language so that it can be distinguished.
                         # The subs are returned without "-orig" as well for compatibility
                         process_language(
-                            automatic_captions, base_url, f'{trans_code}-orig', f'{trans_name} (Original)', {})
+                            automatic_captions, base_url, f'{trans_code}-orig',
+                            f'{trans_name} (Original)', client_name, pot_params)
                     # Setting tlang=lang returns damaged subtitles.
-                    process_language(automatic_captions, base_url, trans_code, trans_name,
-                                     {} if orig_lang == orig_trans_code else {'tlang': trans_code})
+                    process_language(
+                        automatic_captions, base_url, trans_code, trans_name, client_name,
+                        pot_params if orig_lang == orig_trans_code else {'tlang': trans_code, **pot_params})
+
+            # Avoid duplication if we got everything we needed from the first player reponse
+            need_subs_langs.difference_update(subtitles)
+            need_caps_langs.difference_update(automatic_captions)
+            if not (need_subs_langs or need_caps_langs):
+                break
+
+        subs_wanted = any((
+            self.get_param('writesubtitles'),
+            self.get_param('writeautomaticsub'),
+            self.get_param('listsubtitles')))
path: yt_dlp/extractor/youtube/_video.py
user: login: bashonly
body: we could move this up and pass it to `_report_pot_subtitle_skipped` so that it's always reported as debug unless the user actually cares about subs
created_at: 2025-05-20T07:13:04Z
author_association: MEMBER
diff_hunk: @@ -3950,10 +4017,30 @@ def process_language(container, base_url, lang_code, sub_name, query):
                         # Add an "-orig" label to the original language so that it can be distinguished.
                         # The subs are returned without "-orig" as well for compatibility
                         process_language(
-                            automatic_captions, base_url, f'{trans_code}-orig', f'{trans_name} (Original)', {})
+                            automatic_captions, base_url, f'{trans_code}-orig',
+                            f'{trans_name} (Original)', client_name, pot_params)
                     # Setting tlang=lang returns damaged subtitles.
-                    process_language(automatic_captions, base_url, trans_code, trans_name,
-                                     {} if orig_lang == orig_trans_code else {'tlang': trans_code})
+                    process_language(
+                        automatic_captions, base_url, trans_code, trans_name, client_name,
+                        pot_params if orig_lang == orig_trans_code else {'tlang': trans_code, **pot_params})
+
+            # Avoid duplication if we got everything we needed from the first player reponse
+            need_subs_langs.difference_update(subtitles)
+            need_caps_langs.difference_update(automatic_captions)
+            if not (need_subs_langs or need_caps_langs):
+                break
+
+        subs_wanted = any((
+            self.get_param('writesubtitles'),
+            self.get_param('writeautomaticsub'),
+            self.get_param('listsubtitles')))
+        if subs_wanted and skipped_subs_clients and (need_subs_langs or need_caps_langs):
+            self.report_warning(join_nonempty(
+                'One or more clients are requiring PO tokens for access to',
+                f'subtitles and automatic captions ({", ".join(skipped_subs_clients)}).',
+                need_subs_langs and f'Subtitles for these languages are missing: {", ".join(need_subs_langs)}.',
+                need_caps_langs and f'Automatic captions for these languages are missing: {", ".join(need_caps_langs)}',
+                delim=' '), video_id=video_id)
path: yt_dlp/extractor/youtube/_video.py
user: login: bashonly
body: Originally I liked the idea of showing the user what exactly they're missing, but now I realize that if **all** clients are skipped, then the automatic captions list could be like a full page warning. We could keep the sub langs list, but for captions just say "Some automatic captions are missing" ?
created_at: 2025-05-20T07:16:01Z
author_association: MEMBER
diff_hunk: @@ -3950,10 +4017,30 @@ def process_language(container, base_url, lang_code, sub_name, query):
                         # Add an "-orig" label to the original language so that it can be distinguished.
                         # The subs are returned without "-orig" as well for compatibility
                         process_language(
-                            automatic_captions, base_url, f'{trans_code}-orig', f'{trans_name} (Original)', {})
+                            automatic_captions, base_url, f'{trans_code}-orig',
+                            f'{trans_name} (Original)', client_name, pot_params)
                     # Setting tlang=lang returns damaged subtitles.
-                    process_language(automatic_captions, base_url, trans_code, trans_name,
-                                     {} if orig_lang == orig_trans_code else {'tlang': trans_code})
+                    process_language(
+                        automatic_captions, base_url, trans_code, trans_name, client_name,
+                        pot_params if orig_lang == orig_trans_code else {'tlang': trans_code, **pot_params})
+
+            # Avoid duplication if we got everything we needed from the first player reponse
+            need_subs_langs.difference_update(subtitles)
+            need_caps_langs.difference_update(automatic_captions)
+            if not (need_subs_langs or need_caps_langs):
+                break
+
+        subs_wanted = any((
+            self.get_param('writesubtitles'),
+            self.get_param('writeautomaticsub'),
+            self.get_param('listsubtitles')))
+        if subs_wanted and skipped_subs_clients and (need_subs_langs or need_caps_langs):
+            self.report_warning(join_nonempty(
+                'One or more clients are requiring PO tokens for access to',
+                f'subtitles and automatic captions ({", ".join(skipped_subs_clients)}).',
+                need_subs_langs and f'Subtitles for these languages are missing: {", ".join(need_subs_langs)}.',
+                need_caps_langs and f'Automatic captions for these languages are missing: {", ".join(need_caps_langs)}',
+                delim=' '), video_id=video_id)
path: yt_dlp/extractor/youtube/_video.py
user: login: coletdjnz
body: I think that works. Or if the size of the captions list is too long, simplify it to a number like "42 subtitle langs were skipped" perhaps?
created_at: 2025-05-20T07:17:48Z
author_association: MEMBER
diff_hunk: @@ -3890,47 +3925,79 @@ def is_bad_format(fmt):
                 'quality', 'res', 'fps', 'hdr:12', 'source', 'vcodec', 'channels', 'acodec', 'lang', 'proto'),
         }
 
+        def get_lang_code(track):
+            return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
+                    or track.get('languageCode'))
+
+        def process_language(container, base_url, lang_code, sub_name, client_name, query):
+            lang_subs = container.setdefault(lang_code, [])
+            for fmt in self._SUBTITLE_FORMATS:
+                query = {**query, 'fmt': fmt}
+                lang_subs.append({
+                    'ext': fmt,
+                    'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
+                    'name': sub_name,
+                    STREAMING_DATA_CLIENT_NAME: client_name,
+                })
+
         subtitles = {}
-        pctr = traverse_obj(player_responses, (..., 'captions', 'playerCaptionsTracklistRenderer'), expected_type=dict)
-        if pctr:
-            def get_lang_code(track):
-                return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
-                        or track.get('languageCode'))
-
-            # Converted into dicts to remove duplicates
-            captions = {
-                get_lang_code(sub): sub
-                for sub in traverse_obj(pctr, (..., 'captionTracks', ...))}
-            translation_languages = {
-                lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
-                for lang in traverse_obj(pctr, (..., 'translationLanguages', ...))}
-
-            def process_language(container, base_url, lang_code, sub_name, query):
-                lang_subs = container.setdefault(lang_code, [])
-                for fmt in self._SUBTITLE_FORMATS:
-                    query.update({
-                        'fmt': fmt,
-                    })
-                    lang_subs.append({
-                        'ext': fmt,
-                        'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
-                        'name': sub_name,
+        skipped_subs_clients = set()
+        prs = traverse_obj(player_responses, (
+            # Filter out initial_pr which does not have streamingData (smuggled client context)
+            lambda _, v: v['streamingData'] and v['captions']['playerCaptionsTracklistRenderer']))
+
+        pctrs = traverse_obj(prs, (..., 'captions', 'playerCaptionsTracklistRenderer', {dict}))
+        translation_languages = {
+            lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
+            for lang in traverse_obj(pctrs, (..., 'translationLanguages', ..., {dict}))}
+        # NB: Constructing the full subtitle dictionary is slow
+        get_translated_subs = 'translated_subs' not in self._configuration_arg('skip') and (
+            self.get_param('writeautomaticsub', False) or self.get_param('listsubtitles'))
+
+        all_captions = traverse_obj(pctrs, (..., 'captionTracks', ..., {dict}))
+        need_subs_langs = {get_lang_code(sub) for sub in all_captions if sub.get('kind') != 'asr'}
+        need_caps_langs = {
+            remove_start(get_lang_code(sub), 'a-')
+            for sub in all_captions if sub.get('kind') == 'asr'}
+
+        for pr in prs:
+            pctr = pr['captions']['playerCaptionsTracklistRenderer']
+            client_name = pr['streamingData'][STREAMING_DATA_CLIENT_NAME]
+            innertube_client_name = traverse_obj(
+                pr, ('streamingData', STREAMING_DATA_INNERTUBE_CONTEXT, 'client', 'clientName'))
+            required_contexts = self._get_default_ytcfg(client_name)['PO_TOKEN_REQUIRED_CONTEXTS']
+            fetch_subs_po_token_func = pr['streamingData'][STREAMING_DATA_FETCH_SUBS_PO_TOKEN]
+            pot_params = {}
+
+            for caption_track in traverse_obj(pctr, ('captionTracks', lambda _, v: v['baseUrl'])):
+                base_url = caption_track['baseUrl']
+                qs = parse_qs(base_url)
+                lang_code = get_lang_code(caption_track)
+                requires_pot = (
+                    # We can detect the experiment for now
+                    any(e in traverse_obj(qs, ('exp', ...)) for e in ('xpe', 'xpv'))
+                    or _PoTokenContext.SUBS in required_contexts)
+
+                subs_po_token = fetch_subs_po_token_func(required=requires_pot)
+                if not subs_po_token and requires_pot:
+                    skipped_subs_clients.add(client_name)
+                    self._report_pot_subtitle_skipped(video_id, client_name)
+                    break
+
+                if not pot_params:
path: yt_dlp/extractor/youtube/_video.py
user: login: coletdjnz
body: ```suggestion
                if subs_po_token and not pot_params:
```
created_at: 2025-05-20T07:31:31Z
author_association: MEMBER
diff_hunk: @@ -3890,47 +3925,79 @@ def is_bad_format(fmt):
                 'quality', 'res', 'fps', 'hdr:12', 'source', 'vcodec', 'channels', 'acodec', 'lang', 'proto'),
         }
 
+        def get_lang_code(track):
+            return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
+                    or track.get('languageCode'))
+
+        def process_language(container, base_url, lang_code, sub_name, client_name, query):
+            lang_subs = container.setdefault(lang_code, [])
+            for fmt in self._SUBTITLE_FORMATS:
+                query = {**query, 'fmt': fmt}
+                lang_subs.append({
+                    'ext': fmt,
+                    'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
+                    'name': sub_name,
+                    STREAMING_DATA_CLIENT_NAME: client_name,
+                })
+
         subtitles = {}
-        pctr = traverse_obj(player_responses, (..., 'captions', 'playerCaptionsTracklistRenderer'), expected_type=dict)
-        if pctr:
-            def get_lang_code(track):
-                return (remove_start(track.get('vssId') or '', '.').replace('.', '-')
-                        or track.get('languageCode'))
-
-            # Converted into dicts to remove duplicates
-            captions = {
-                get_lang_code(sub): sub
-                for sub in traverse_obj(pctr, (..., 'captionTracks', ...))}
-            translation_languages = {
-                lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
-                for lang in traverse_obj(pctr, (..., 'translationLanguages', ...))}
-
-            def process_language(container, base_url, lang_code, sub_name, query):
-                lang_subs = container.setdefault(lang_code, [])
-                for fmt in self._SUBTITLE_FORMATS:
-                    query.update({
-                        'fmt': fmt,
-                    })
-                    lang_subs.append({
-                        'ext': fmt,
-                        'url': urljoin('https://www.youtube.com', update_url_query(base_url, query)),
-                        'name': sub_name,
+        skipped_subs_clients = set()
+        prs = traverse_obj(player_responses, (
+            # Filter out initial_pr which does not have streamingData (smuggled client context)
+            lambda _, v: v['streamingData'] and v['captions']['playerCaptionsTracklistRenderer']))
+
+        pctrs = traverse_obj(prs, (..., 'captions', 'playerCaptionsTracklistRenderer', {dict}))
+        translation_languages = {
+            lang.get('languageCode'): self._get_text(lang.get('languageName'), max_runs=1)
+            for lang in traverse_obj(pctrs, (..., 'translationLanguages', ..., {dict}))}
+        # NB: Constructing the full subtitle dictionary is slow
+        get_translated_subs = 'translated_subs' not in self._configuration_arg('skip') and (
+            self.get_param('writeautomaticsub', False) or self.get_param('listsubtitles'))
+
+        all_captions = traverse_obj(pctrs, (..., 'captionTracks', ..., {dict}))
+        need_subs_langs = {get_lang_code(sub) for sub in all_captions if sub.get('kind') != 'asr'}
+        need_caps_langs = {
+            remove_start(get_lang_code(sub), 'a-')
+            for sub in all_captions if sub.get('kind') == 'asr'}
+
+        for pr in prs:
+            pctr = pr['captions']['playerCaptionsTracklistRenderer']
+            client_name = pr['streamingData'][STREAMING_DATA_CLIENT_NAME]
+            innertube_client_name = traverse_obj(
+                pr, ('streamingData', STREAMING_DATA_INNERTUBE_CONTEXT, 'client', 'clientName'))
+            required_contexts = self._get_default_ytcfg(client_name)['PO_TOKEN_REQUIRED_CONTEXTS']
+            fetch_subs_po_token_func = pr['streamingData'][STREAMING_DATA_FETCH_SUBS_PO_TOKEN]
+            pot_params = {}
+
+            for caption_track in traverse_obj(pctr, ('captionTracks', lambda _, v: v['baseUrl'])):
+                base_url = caption_track['baseUrl']
+                qs = parse_qs(base_url)
+                lang_code = get_lang_code(caption_track)
+                requires_pot = (
+                    # We can detect the experiment for now
+                    any(e in traverse_obj(qs, ('exp', ...)) for e in ('xpe', 'xpv'))
+                    or _PoTokenContext.SUBS in required_contexts)
+
+                subs_po_token = fetch_subs_po_token_func(required=requires_pot)
path: yt_dlp/extractor/youtube/_video.py
user: login: coletdjnz
body: save this outside the loop, only fetch once for the client
created_at: 2025-05-20T07:34:40Z
author_association: MEMBER
diff_hunk: @@ -51,7 +51,7 @@ def get_webpo_content_binding(
                     return visitor_id, ContentBindingType.VISITOR_ID
             return request.visitor_data, ContentBindingType.VISITOR_DATA
 
-    elif request.context == PoTokenContext.PLAYER or client_name != 'WEB_REMIX':
+    elif request.context in (PoTokenContext.PLAYER, PoTokenContext.SUBS) or client_name != 'WEB_REMIX':
path: yt_dlp/extractor/youtube/pot/utils.py
user: login: coletdjnz
body: I think the OR clause can be removed here, just need a check for context
created_at: 2025-05-21T08:50:18Z
author_association: MEMBER
diff_hunk: @@ -1805,7 +1805,7 @@ The following extractors use this feature:
 * `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
 * `data_sync_id`: Overrides the account Data Sync ID used in Innertube API requests. This may be needed if you are using an account with `youtube:player_skip=webpage,configs` or `youtubetab:skip=webpage`
 * `visitor_data`: Overrides the Visitor Data used in Innertube API requests. This should be used with `player_skip=webpage,configs` and without cookies. Note: this may have adverse effects if used improperly. If a session from a browser is wanted, you should pass cookies instead (which contain the Visitor ID)
-* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs) or `player` (Innertube player request)
+* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs), `player` (Innertube player request) or `subs` (Subtitles)
path: README.md
user: login: gamer191
body: I'm still kinda confused why `subs` is a separate PoToken context to `player`. Is it just to give the user more fine grained control over where their PoTokens are used?


EDIT: resolved over Discord
created_at: 2025-05-22T03:36:45Z
author_association: MEMBER
diff_hunk: @@ -3262,6 +3280,19 @@ def _report_pot_format_skipped(self, video_id, client_name, proto):
         else:
             self.report_warning(msg, only_once=True)
 
+    def _report_pot_subtitle_skipped(self, video_id, client_name):
+        msg = (
+            f'{video_id}: {client_name} client subtitles require a PO Token which was not provided. '
+            'They will be skipped as they may not work without it. '
+            f'You can manually pass a Subtitle PO Token for this client with --extractor-args "youtube:po_token={client_name}.subs+XXX". '
+            f'For more information, refer to  {PO_TOKEN_GUIDE_URL} . ')
+
+        # Only raise a warning for non-default clients, to not confuse users.
+        if client_name in (*self._DEFAULT_CLIENTS, *self._DEFAULT_AUTHED_CLIENTS):
+            self.write_debug(msg, only_once=True)
+        else:
+            self.report_warning(msg, only_once=True)
path: yt_dlp/extractor/youtube/_video.py
user: login: bashonly
body: add "some" because there may still be subs from m3u8 formats (ios, web_safari)

```suggestion
    def _report_pot_subtitle_skipped(self, video_id, client_name):
        msg = (
            f'{video_id}: Some {client_name} client subtitles require a PO Token which was not provided. '
            'They will be discarded since they are not downloadable as-is. '
            f'You can manually pass a Subtitles PO Token for this client with --extractor-args "youtube:po_token={client_name}.subs+XXX" . '
            f'For more information, refer to  {PO_TOKEN_GUIDE_URL}')

        subs_wanted = any((
            self.get_param('writesubtitles'),
            self.get_param('writeautomaticsub'),
            self.get_param('listsubtitles')))

        # Only raise a warning for non-default clients, to not confuse users.
        if not subs_wanted or client_name in (*self._DEFAULT_CLIENTS, *self._DEFAULT_AUTHED_CLIENTS):
            self.write_debug(msg, only_once=True)
        else:
            self.report_warning(msg, only_once=True)
```
created_at: 2025-05-22T06:57:13Z
author_association: MEMBER
diff_hunk: @@ -1805,7 +1805,7 @@ The following extractors use this feature:
 * `raise_incomplete_data`: `Incomplete Data Received` raises an error instead of reporting a warning
 * `data_sync_id`: Overrides the account Data Sync ID used in Innertube API requests. This may be needed if you are using an account with `youtube:player_skip=webpage,configs` or `youtubetab:skip=webpage`
 * `visitor_data`: Overrides the Visitor Data used in Innertube API requests. This should be used with `player_skip=webpage,configs` and without cookies. Note: this may have adverse effects if used improperly. If a session from a browser is wanted, you should pass cookies instead (which contain the Visitor ID)
-* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs) or `player` (Innertube player request)
+* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be either `gvs` (Google Video Server URLs), `player` (Innertube player request) or `subs` (Subtitles)
path: README.md
user: login: bashonly
body: ```suggestion
* `po_token`:  Proof of Origin (PO) Token(s) to use. Comma seperated list of PO Tokens in the format `CLIENT.CONTEXT+PO_TOKEN`, e.g. `youtube:po_token=web.gvs+XXX,web.player=XXX,web_safari.gvs+YYY`. Context can be any of `gvs` (Google Video Server URLs), `player` (Innertube player request) or `subs` (Subtitles)
```
created_at: 2025-05-22T07:00:34Z
author_association: MEMBER

PR comments:
user: login: debugzxcv
created_at: 2025-05-22T19:19:46Z
body: can't get automatic captions: `yt-dlp --extractor-args youtube:player_client=android_vr --flat-playlist --sub-langs "ja.*" --write-auto-sub --write-sub -J -v --list-subs -- https://www.youtube.com/watch?v=VjjKPXgQquQ`

![PlayerResponses](https://github.com/user-attachments/assets/e5971081-4549-4138-8cc7-3d3adc1b1cde)

author_association: NONE
user: login: bashonly
created_at: 2025-05-22T19:29:43Z
body: @debugzxcv please open a new issue
author_association: MEMBER
user: login: bashonly
created_at: 2025-05-22T23:39:46Z
body: @debugzxcv fixed in 53ea743a9c158f8ca2d75a09ca44ba68606042d8

update to nightly, or else use `--extractor-args "youtube:player_client=android_vr,web"` as a workaround
author_association: MEMBER