├── pyproject.toml ├── LICENSE ├── README.md ├── .github └── workflows │ └── release.yml └── yt_dlp_plugins └── extractor └── getpot_wpc.py /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "yt-dlp-getpot-wpc" 7 | version = "1.0.0" 8 | readme = "README.md" 9 | requires-python = ">=3.9" 10 | license = { file = "LICENSE"} 11 | keywords = ["yt-dlp", "yt-dlp-plugin", "yt-dlp-pot-provider"] 12 | authors = [ 13 | { name = "coletdjnz", email = "coletdjnz@protonmail.com" }, 14 | ] 15 | dependencies = [ "nodriver"] 16 | 17 | [tool.hatch.env.default] 18 | installer = "uv" 19 | path = ".venv" 20 | 21 | [tool.hatch.build.targets.wheel] 22 | packages = ["yt_dlp_plugins"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 coletdjnz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebPoClient PO Token Provider 2 | 3 | An experimental yt-dlp [PO Token Provider](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide#po-token-provider-plugins) that uses [nodriver](https://github.com/ultrafunkamsterdam/nodriver) and YouTube's "WebPoClient" in the browser mint PO Tokens. 4 | 5 | Supports: 6 | - WebPO-based clients (`web`, `web_safari`, `web_music`, `mweb` `tv`, `tv_embedded`, `web_embedded`, `web_creator`, ...) 7 | - Minting GVS and Player PO Tokens 8 | - Minting PO Tokens for both guest and logged-in sessions 9 | 10 | 11 | * [WebPoClient PO Token Provider](#webpoclient-po-token-provider) 12 | * [Installing](#installing) 13 | * [pip/pipx](#pippipx) 14 | * [Usage](#usage) 15 | * [Options](#options) 16 | * [Custom Chrome Location](#custom-chrome-location) 17 | 18 | 19 | ## Installing 20 | 21 | **Requires yt-dlp `2025.09.26` or above.** 22 | 23 | Chrome or Chromium must be installed. 24 | 25 | ### pip/pipx 26 | 27 | ``` 28 | pipx inject yt-dlp yt-dlp-getpot-wpc 29 | ``` 30 | 31 | or 32 | 33 | ``` 34 | python3 -m pip install -U yt-dlp-getpot-wpc 35 | ``` 36 | 37 | 38 | If installed correctly, you should see the `wpc` PO Token provider in `yt-dlp -v YOUTUBE_URL` output 39 | 40 | [debug] [youtube] [pot] PO Token Providers: wpc-1.x.y (external) 41 | 42 | 43 | ## Usage 44 | 45 | This provider will automatically be used when a PO Token is requested by yt-dlp. It will launch a web browser while yt-dlp is running which it will use to mint PO Token(s). 46 | 47 | > [!WARNING] 48 | > Do not close the browser that is launched when yt-dlp is running! 49 | 50 | ### Options 51 | 52 | 53 | #### Custom Chrome Location 54 | 55 | Set the location of the Chrome browser executable to use. 56 | 57 | `--extractor-args "youtubepot-wpc:browser_path=/usr/bin/chromium"` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | on: [workflow_dispatch] 3 | 4 | jobs: 5 | # release: 6 | # permissions: 7 | # contents: write 8 | # runs-on: ubuntu-latest 9 | # steps: 10 | # - uses: actions/checkout@v4 11 | # with: 12 | # fetch-depth: 0 13 | # 14 | # - uses: actions/setup-python@v5 15 | # with: 16 | # python-version: "3.8" 17 | # 18 | # - name: Install Hatch 19 | # run: pipx install hatch 20 | # 21 | # - name: Set variables 22 | # id: set_variables 23 | # run: | 24 | # tag="$(git describe --tags --abbrev=0)" 25 | # echo "::group::Variables" 26 | # cat << EOF | tee -a "$GITHUB_OUTPUT" 27 | # tag=${tag} 28 | # version=$(hatch project metadata | jq -r .version) 29 | # project_name=$(hatch project metadata | jq -r .name) 30 | # EOF 31 | # echo "::endgroup::" 32 | # - name: Get Changelog Entry 33 | # id: changelog_reader 34 | # uses: mindsers/changelog-reader-action@v2 35 | # with: 36 | # validation_level: error 37 | # version: ${{ steps.set_variables.outputs.version }} 38 | # path: ./CHANGELOG.md 39 | # - name: Bundle release 40 | # env: 41 | # GH_TOKEN: ${{ github.token }} 42 | # tag: ${{ steps.set_variables.outputs.tag }} 43 | # version: v${{ steps.set_variables.outputs.version }} 44 | # if: | 45 | # env.tag != env.version 46 | # run: | 47 | # sources="$(\ 48 | # hatch run default:pip list --verbose --format json \ 49 | # | jq -r '.[] | select(.editable_project_location == null) | "\(.name);\(.location)"' \ 50 | # )" 51 | # echo "::group::Dependencies" 52 | # printf '%s\n' "${sources}" 53 | # echo "::endgroup::" 54 | # mkdir bundle/ 55 | # cp -r yt_dlp_plugins bundle/ 56 | # while IFS=';' read -r name path; do 57 | # if [[ ! "${name}" =~ ^(pip|setuptools|wheel|yt-dlp)$ ]]; then 58 | # package_name="$(tr '[:upper:]' '[:lower:]' <<<"${name}" | sed 's/-/_/g')" 59 | # cp -r "${path}/${package_name}" bundle/ 60 | # fi 61 | # done <<<"${sources}" 62 | # cd bundle/ 63 | # find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete 64 | # zip -9 --recurse-paths "${{ steps.set_variables.outputs.project_name }}" * 65 | # 66 | # - name: Create release 67 | # uses: ncipollo/release-action@v1 68 | # with: 69 | # tag: v${{ steps.set_variables.outputs.version }} 70 | # name: ${{ steps.set_variables.outputs.project_name }} v${{ steps.set_variables.outputs.version }} 71 | # body: ${{ steps.changelog_reader.outputs.changes }} 72 | # makeLatest: true 73 | # artifacts: bundle/${{ steps.set_variables.outputs.project_name }}.zip 74 | 75 | release_pypi: 76 | runs-on: ubuntu-latest 77 | permissions: 78 | id-token: write # mandatory for trusted publishing 79 | 80 | steps: 81 | - uses: actions/checkout@v4 82 | with: 83 | fetch-depth: 0 84 | - uses: actions/setup-python@v5 85 | with: 86 | python-version: "3.10" 87 | 88 | - name: Install dependencies 89 | run: | 90 | python -m pip install --upgrade pip 91 | pip install build hatchling 92 | 93 | - name: Build 94 | run: | 95 | rm -rf dist/* 96 | printf '%s\n\n' \ 97 | 'Official repository: ' > ./README.md.new 98 | cat ./README.md >> ./README.md.new && mv -f ./README.md.new ./README.md 99 | python -m build --no-isolation . 100 | 101 | - name: Publish to PyPI 102 | uses: pypa/gh-action-pypi-publish@release/v1 103 | with: 104 | verbose: true -------------------------------------------------------------------------------- /yt_dlp_plugins/extractor/getpot_wpc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import pathlib 4 | import nodriver 5 | import nodriver.core.config 6 | from nodriver import start, cdp, loop 7 | 8 | from yt_dlp.extractor.youtube.pot.provider import ( 9 | PoTokenRequest, 10 | PoTokenContext, 11 | PoTokenProvider, 12 | PoTokenResponse, 13 | PoTokenProviderError, 14 | register_provider, 15 | register_preference, 16 | ExternalRequestFeature, provider_bug_report_message, 17 | ) 18 | from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding, WEBPO_CLIENTS 19 | import json 20 | 21 | 22 | __version__ = '1.0.0' 23 | 24 | WEB_PO_BACKOFF_SECONDS = 1 25 | 26 | async def get_webpo_client_path(tab, logger): 27 | # todo: dynamically extract 28 | # note: this assumes "bg_st_hr" experiment is enabled 29 | webpo_client_path = "window.top['havuokmhhs-0']?.bevasrs?.wpc" 30 | 31 | count = 0 32 | while count < 10 and not await tab.evaluate(f"!!{webpo_client_path}"): 33 | logger.debug('Waiting for WebPoClient to be available in browser...') 34 | # check that ytcfg is loaded and bg_st_hr experiment is enabled 35 | if not await tab.evaluate( 36 | f"!window.top['ytcfg']?.get('EXPERIMENT_FLAGS') || !!ytcfg.get('EXPERIMENT_FLAGS')?.bg_st_hr" 37 | ): 38 | logger.warning( 39 | 'bg_st_hr experiment is not enabled, WebPoClient may not be available.', once=True) 40 | 41 | await asyncio.sleep(WEB_PO_BACKOFF_SECONDS) 42 | count += 1 43 | 44 | if count == 10: 45 | logger.error('Timed out waiting for WebPoClient to be available in browser') 46 | return False 47 | 48 | return webpo_client_path 49 | 50 | 51 | async def mint_po_token(tab, logger, content_binding, mint_cold_start_token=False, mint_error_token=False): 52 | webpo_client_path = await get_webpo_client_path(tab, logger) 53 | if not webpo_client_path: 54 | raise PoTokenProviderError('Could not find WebPoClient in browser') 55 | 56 | mws_params = { 57 | 'c': content_binding, 58 | 'mc': mint_cold_start_token, 59 | 'me': mint_error_token 60 | } 61 | 62 | mint_po_token_code = f""" 63 | {webpo_client_path}().then((client) => client.mws({json.dumps(mws_params)})).catch( 64 | (e) => {{ 65 | if (String(e).includes('SDF:notready')) {{ 66 | return 'backoff'; 67 | }} 68 | else {{ 69 | throw e; 70 | }} 71 | }} 72 | ) 73 | """ 74 | 75 | tries = 0 76 | while tries < 10: 77 | po_token = await tab.evaluate(mint_po_token_code, await_promise=True) 78 | if po_token != 'backoff': 79 | return po_token 80 | logger.debug('Waiting for WebPoClient to be ready in browser...') 81 | await asyncio.sleep(WEB_PO_BACKOFF_SECONDS) 82 | tries += 1 83 | 84 | raise PoTokenProviderError('Timed out waiting for WebPoClient to be ready in browser') 85 | 86 | 87 | async def launch_browser(config): 88 | # todo: allow to specify an existing nodriver browser instance 89 | try: 90 | browser = await start(config=config) 91 | except Exception as e: 92 | raise PoTokenProviderError(f'failed to start browser: {e}') from e 93 | await browser.connection.send(cdp.storage.clear_cookies()) 94 | await browser.get('https://www.youtube.com?themeRefresh=1') 95 | return browser 96 | 97 | @register_provider 98 | class WPCPTP(PoTokenProvider): 99 | PROVIDER_VERSION = __version__ 100 | # Define a unique display name for the provider 101 | PROVIDER_NAME = 'wpc' 102 | BUG_REPORT_LOCATION = 'https://github.com/coletdjnz/yt-dlp-getpot-wpc/issues' 103 | 104 | _SUPPORTED_CLIENTS = WEBPO_CLIENTS 105 | 106 | _SUPPORTED_CONTEXTS = ( 107 | PoTokenContext.GVS, 108 | PoTokenContext.PLAYER, 109 | PoTokenContext.SUBS, 110 | ) 111 | 112 | _SUPPORTED_EXTERNAL_REQUEST_FEATURES = ( 113 | ExternalRequestFeature.PROXY_SCHEME_HTTP, 114 | ExternalRequestFeature.PROXY_SCHEME_SOCKS4, 115 | ExternalRequestFeature.PROXY_SCHEME_SOCKS4A, 116 | ExternalRequestFeature.PROXY_SCHEME_SOCKS5, 117 | ExternalRequestFeature.PROXY_SCHEME_SOCKS5H, 118 | ) 119 | 120 | def __init__(self, *args, **kwargs): 121 | super().__init__(*args, **kwargs) 122 | self._browser = None 123 | self.__loop = None 124 | self._available = False 125 | 126 | @property 127 | def _loop(self): 128 | if not self.__loop: 129 | self.__loop = loop() 130 | return self.__loop 131 | 132 | def close(self): 133 | if self._browser: 134 | self._browser.stop() 135 | self._browser = None 136 | super().close() 137 | 138 | def get_nodriver_config(self, proxy=None): 139 | browser_executable_path = ( 140 | self._configuration_arg('browser_path', casesense=True, default=[None])[0] 141 | # backwards-compat 142 | or self.ie._configuration_arg('browser_path', [None], ie_key=f'youtube-wpc', casesense=True)[0] 143 | ) 144 | browser_args = [] 145 | if proxy: 146 | # xxx: potentially unsafe 147 | browser_args.extend([f'--proxy-server={proxy}']) 148 | 149 | return nodriver.core.config.Config( 150 | headless=False, 151 | browser_executable_path=browser_executable_path, 152 | browser_args=browser_args 153 | ) 154 | 155 | @functools.cache 156 | def is_available(self): 157 | # check that chrome is available 158 | missing_browser = False 159 | nodriver_config = None 160 | try: 161 | nodriver_config = self.get_nodriver_config() 162 | except FileNotFoundError as e: 163 | if 'chrome' in str(e): 164 | missing_browser = True 165 | else: 166 | self.logger.warning(f'Unexpected error while getting browser config: {e}{provider_bug_report_message(self)}') 167 | return False 168 | if ( 169 | missing_browser 170 | or not nodriver_config.browser_executable_path 171 | or not pathlib.Path(nodriver_config.browser_executable_path).exists() 172 | ): 173 | self.logger.debug( 174 | 'WPC PO Token Provider requires Chrome to be installed. ' 175 | 'You can specify a path to the browser with --extractor-args "youtubepot-wpc:browser_path=XYZ".') 176 | return False 177 | 178 | return True 179 | 180 | def _real_request_pot(self, request: PoTokenRequest) -> PoTokenResponse: 181 | proxy = request.request_proxy 182 | if proxy: 183 | proxy = proxy.replace('socks5h', 'socks5').replace('socks4a', 'socks4') 184 | 185 | browser_config = self.get_nodriver_config(proxy) 186 | if not self._browser or self._browser.stopped: 187 | self.logger.info(f'Launching youtube.com in browser to retrieve PO Token(s). ' 188 | f'This will stay open while yt-dlp is running. Do not close the browser window!') 189 | self._browser = self._loop.run_until_complete(launch_browser(browser_config)) 190 | 191 | self.logger.info(f"Minting {request.context.value} PO Token for {request.internal_client_name} using WebPoClient in browser") 192 | po_token = self._loop.run_until_complete( 193 | mint_po_token(tab=self._browser.main_tab, logger=self.logger, content_binding=get_webpo_content_binding(request)[0])) 194 | 195 | self.logger.trace(f'Retrieved {request.context.value} PO Token: {po_token}') 196 | return PoTokenResponse(po_token=po_token) 197 | 198 | 199 | @register_preference(WPCPTP) 200 | def wpc_preference(_, __): 201 | return -100 202 | --------------------------------------------------------------------------------