Repository: yt-dlp/yt-dlp (python)

Issue:
[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
```

Pull request:
[ie/youtube] Add PO token support for subtitles
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>


Patch:
diff --git a/README.md b/README.md
index 9f542844e..aaa3beb71 100644
--- a/README.md
+++ b/README.md
@@ -1805,7 +1805,7 @@ #### youtube
 * `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/yt_dlp/extractor/youtube/_base.py b/yt_dlp/extractor/youtube/_base.py
index 4194e1c21..9c5bb75fe 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 b4c6ba453..9f929664f 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 53af92d30..13b3b1f9b 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 1c0db243b..7a5b7d4ab 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


Test patch:
diff --git a/test/test_pot/test_pot_builtin_utils.py b/test/test_pot/test_pot_builtin_utils.py
index 1682e42a1..a95fc4e15 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)),