This commit is contained in:
2026-09-27 22:32:36 -07:00
parent c6af58cce9
commit ffc994e2ea
10 changed files with 367 additions and 141 deletions
@@ -5,8 +5,10 @@ description: Inspect and operate native Wayland and XWayland desktop apps on thi
# Hyprland computer use # Hyprland computer use
Use the `hyprland_desktop` MCP tools for the live desktop. The server is configured Use the `hyprland_desktop` MCP tools for the live desktop. Codex configures it in
in `~/.codex/config.toml`; its code lives in `~/.codex/bridges/hyprland-desktop`. `~/.codex/config.toml` with code in `~/.codex/bridges/hyprland-desktop`. Claude Code
registers the same server in `~/.claude.json` (user scope) through the symlink
`~/.claude/bridges/hyprland-desktop`; there the tools are `mcp__hyprland_desktop__*`.
It runs as the logged-in user and exposes specific desktop operations over stdio. It runs as the logged-in user and exposes specific desktop operations over stdio.
It is independent of the macOS/Windows Computer Use plugin. It is independent of the macOS/Windows Computer Use plugin.
+1
View File
@@ -0,0 +1 @@
../../.codex/bridges/hyprland-desktop
+39 -2
View File
@@ -56,6 +56,26 @@ env_vars = ["XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", "HYPRLAND_INSTANCE_SIGNATURE",
startup_timeout_sec = 15 startup_timeout_sec = 15
tool_timeout_sec = 60 tool_timeout_sec = 60
[mcp_servers.node_repl]
args = []
command = "/usr/lib/chatgpt/resources/cua_node/bin/node_repl"
startup_timeout_sec = 120
[mcp_servers.node_repl.env]
NODE_REPL_NATIVE_PIPE_CONNECT_TIMEOUT_MS = "1000"
NODE_REPL_NODE_MODULE_DIRS = "/usr/lib/chatgpt/resources/cua_node/lib/node_modules"
NODE_REPL_NODE_PATH = "/usr/lib/chatgpt/resources/cua_node/bin/node"
NODE_REPL_TRUSTED_CODE_PATHS = "/home/sudacode/.codex:/usr/lib/chatgpt/resources/cua_node/lib/node_modules"
CODEX_HOME = "/home/sudacode/.codex"
BROWSER_USE_AVAILABLE_BACKENDS = "chrome,iab"
BROWSER_USE_TINYSKY_ENABLED = "1"
NODE_REPL_INSTRUCTIONS_USE_CASE_BROWSER = ""
NODE_REPL_INSTRUCTIONS_USE_CASE_CHROME = ""
BROWSER_USE_CODEX_APP_BUILD_FLAVOR = "prod"
BROWSER_USE_CODEX_APP_VERSION = "26.915.31945"
NODE_REPL_TRUSTED_SERVICES = '{"browser":"/home/sudacode/.codex/plugins/cache/openai-bundled/browser/26.915.31945/scripts/browser-service.mjs"}'
CODEX_CLI_PATH = "/usr/lib/chatgpt/resources/codex"
[projects."/home/sudacode/projects"] [projects."/home/sudacode/projects"]
trust_level = "trusted" trust_level = "trusted"
@@ -275,6 +295,9 @@ trust_level = "trusted"
[projects."/home/sudacode/projects/japanese/hachidori"] [projects."/home/sudacode/projects/japanese/hachidori"]
trust_level = "trusted" trust_level = "trusted"
[projects."/home/sudacode/projects/japanese/yomitan-css"]
trust_level = "trusted"
[notice.model_migrations] [notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4" "gpt-5.3-codex" = "gpt-5.4"
@@ -299,6 +322,21 @@ enabled = true
[plugins."browser@openai-bundled"] [plugins."browser@openai-bundled"]
enabled = true enabled = true
[plugins."codex-app-tools@openai-bundled"]
enabled = true
[plugins."unified-computer-use@openai-bundled"]
enabled = true
[plugins."visualize@openai-bundled"]
enabled = true
[plugins."pdf@openai-primary-runtime"]
enabled = true
[plugins."template-creator@openai-primary-runtime"]
enabled = true
[desktop] [desktop]
sansFontSize = 18 sansFontSize = 18
codeFontSize = 16 codeFontSize = 16
@@ -307,6 +345,7 @@ appearanceLightCodeThemeId = "catppuccin"
appearanceDarkCodeThemeId = "catppuccin" appearanceDarkCodeThemeId = "catppuccin"
git-pull-request-merge-method = "squash" git-pull-request-merge-method = "squash"
git-show-sidebar-pr-icons = true git-show-sidebar-pr-icons = true
followUpQueueMode = "queue"
[desktop.appearanceLightChromeTheme] [desktop.appearanceLightChromeTheme]
accent = "#8839ef" accent = "#8839ef"
@@ -339,11 +378,9 @@ diffRemoved = "#f38ba8"
skill = "#cba6f7" skill = "#cba6f7"
[marketplaces.openai-bundled] [marketplaces.openai-bundled]
last_updated = "2026-06-03T01:54:25Z"
source_type = "local" source_type = "local"
source = "/home/sudacode/.codex/.tmp/bundled-marketplaces/openai-bundled" source = "/home/sudacode/.codex/.tmp/bundled-marketplaces/openai-bundled"
[marketplaces.openai-primary-runtime] [marketplaces.openai-primary-runtime]
last_updated = "2026-05-23T22:00:53Z"
source_type = "local" source_type = "local"
source = "/home/sudacode/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime" source = "/home/sudacode/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime"
+28 -2
View File
@@ -297,6 +297,19 @@ hl.window_rule({
-- }}} -- }}}
-- {{{ GSM Overlay and LunaTranslator tweaks -- {{{ GSM Overlay and LunaTranslator tweaks
-- Keep the audio timing review over the game on the current workspace.
hl.window_rule({
match = {
title = "^Confirm Anki Card Details$",
},
float = true,
center = true,
workspace = "unset",
no_initial_focus = false,
focus_on_activate = true,
stay_focused = true,
})
-- Open the GSM overlay at its manually aligned screen position. -- Open the GSM overlay at its manually aligned screen position.
hl.window_rule({ hl.window_rule({
match = { match = {
@@ -309,13 +322,26 @@ hl.window_rule({
stay_focused = true, stay_focused = true,
}) })
-- Raise on open; the overlay rule retains focus until it is hidden. -- Explicitly focus GSM popups so the fullscreen game releases input.
hl.on("window.open", function(window) hl.on("window.open", function(window)
if window.class ~= "com.beangate.gamesentenceminer" or window.title ~= "GSM Overlay" then local is_overlay = window.class == "com.beangate.gamesentenceminer" and window.title == "GSM Overlay"
local is_review = window.title == "Confirm Anki Card Details"
if not is_overlay and not is_review then
return return
end end
hl.dispatch(hl.dsp.focus({ window = window })) hl.dispatch(hl.dsp.focus({ window = window }))
hl.dispatch(hl.dsp.window.alter_zorder({ window = window, mode = "top" })) hl.dispatch(hl.dsp.window.alter_zorder({ window = window, mode = "top" }))
-- Repeat after mapping finishes, when the game's input grab can be released.
if is_review then
hl.timer(function()
if not window.mapped or window.hidden then
return
end
hl.dispatch(hl.dsp.focus({ window = window }))
hl.dispatch(hl.dsp.window.alter_zorder({ window = window, mode = "top" }))
end, { timeout = 150, type = "oneshot" })
end
end) end)
hl.window_rule({ hl.window_rule({
+1 -1
View File
@@ -161,4 +161,4 @@ x-scheme-handler/claude-cli=claude-code-url-handler.desktop
x-scheme-handler/mux=mux.desktop x-scheme-handler/mux=mux.desktop
x-scheme-handler/claude=com.anthropic.claude-desktop.desktop x-scheme-handler/claude=com.anthropic.claude-desktop.desktop
x-scheme-handler/t3code=com.t3tools.T3Code.desktop x-scheme-handler/t3code=com.t3tools.T3Code.desktop
x-scheme-handler/codex=ChatGPT.desktop x-scheme-handler/codex=chatgpt.desktop
+5 -5
View File
@@ -10,15 +10,15 @@
"cmp-nvim-lsp-document-symbol": { "branch": "main", "commit": "f94f7ba948e32cd302caba1c2ca3f7c697fb4fcf" }, "cmp-nvim-lsp-document-symbol": { "branch": "main", "commit": "f94f7ba948e32cd302caba1c2ca3f7c697fb4fcf" },
"cmp-nvim-lsp-signature-help": { "branch": "main", "commit": "fd3e882e56956675c620898bf1ffcf4fcbe7ec84" }, "cmp-nvim-lsp-signature-help": { "branch": "main", "commit": "fd3e882e56956675c620898bf1ffcf4fcbe7ec84" },
"cmp_luasnip": { "branch": "master", "commit": "98d9cb5c2c38532bd9bdb481067b20fea8f32e90" }, "cmp_luasnip": { "branch": "master", "commit": "98d9cb5c2c38532bd9bdb481067b20fea8f32e90" },
"codecompanion.nvim": { "branch": "main", "commit": "3e3de8479e6bf5773bfce8bbaada297925cae174" }, "codecompanion.nvim": { "branch": "main", "commit": "5319d842938d9a971d2aa38be57f4ecf120ef83b" },
"conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" }, "conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" },
"copilot-cmp": { "branch": "master", "commit": "15fc12af3d0109fa76b60b5cffa1373697e261d1" }, "copilot-cmp": { "branch": "master", "commit": "15fc12af3d0109fa76b60b5cffa1373697e261d1" },
"copilot-lualine": { "branch": "main", "commit": "222e90bd8dcdf16ca1efc4e784416afb5f011c31" }, "copilot-lualine": { "branch": "main", "commit": "222e90bd8dcdf16ca1efc4e784416afb5f011c31" },
"copilot.lua": { "branch": "master", "commit": "c0774241783717b2a26ef4becf08c00bfd05415d" }, "copilot.lua": { "branch": "master", "commit": "f76a0a281de089e27b605ec8017093f1aab73b07" },
"diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" }, "diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
"fidget.nvim": { "branch": "main", "commit": "9e0201673e08e997e7cf52afca5565c70bd117f3" }, "fidget.nvim": { "branch": "main", "commit": "9e0201673e08e997e7cf52afca5565c70bd117f3" },
"friendly-snippets": { "branch": "main", "commit": "b4d01b0fdf3c9a549961c2f9ffe8dc09be166219" }, "friendly-snippets": { "branch": "main", "commit": "b4d01b0fdf3c9a549961c2f9ffe8dc09be166219" },
"gitsigns.nvim": { "branch": "main", "commit": "8d79f2410c76e62b92e51c28c82e28c1c5a3daeb" }, "gitsigns.nvim": { "branch": "main", "commit": "070a5d7b985546cc57e1fc61e5bc507fecac6045" },
"goto-preview": { "branch": "main", "commit": "d2d6923c9b9e0e43f0b9b566f261a8b1ae016540" }, "goto-preview": { "branch": "main", "commit": "d2d6923c9b9e0e43f0b9b566f261a8b1ae016540" },
"hererocks": { "branch": "master", "commit": "5d77b0bafc8b96f82355ca2ce5637c00d78a065c" }, "hererocks": { "branch": "master", "commit": "5d77b0bafc8b96f82355ca2ce5637c00d78a065c" },
"image.nvim": { "branch": "master", "commit": "365e2ace0f619164ae6fd2f6f1d2807775dd6849" }, "image.nvim": { "branch": "master", "commit": "365e2ace0f619164ae6fd2f6f1d2807775dd6849" },
@@ -29,7 +29,7 @@
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" }, "lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
"mcphub.nvim": { "branch": "main", "commit": "7cd5db330f41b7bae02b2d6202218a061c3ebc1f" }, "mcphub.nvim": { "branch": "main", "commit": "7cd5db330f41b7bae02b2d6202218a061c3ebc1f" },
"mini.diff": { "branch": "main", "commit": "626b8a5b93874c4d05ca25aedec56cfff0b378fb" }, "mini.diff": { "branch": "main", "commit": "626b8a5b93874c4d05ca25aedec56cfff0b378fb" },
"mini.nvim": { "branch": "main", "commit": "81ed10ebe1144b0eb9a7f289ffae000c22772c20" }, "mini.nvim": { "branch": "main", "commit": "561751e839b99a4baca36b9d963166b66d2536a6" },
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" }, "noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
"nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" }, "nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" },
"nvim-autopairs": { "branch": "master", "commit": "430522f95fe4fb7c511ec64f8c1a90cc6a66c05c" }, "nvim-autopairs": { "branch": "master", "commit": "430522f95fe4fb7c511ec64f8c1a90cc6a66c05c" },
@@ -37,7 +37,7 @@
"nvim-colorizer.lua": { "branch": "master", "commit": "a065833f35a3a7cc3ef137ac88b5381da2ba302e" }, "nvim-colorizer.lua": { "branch": "master", "commit": "a065833f35a3a7cc3ef137ac88b5381da2ba302e" },
"nvim-html-css": { "branch": "main", "commit": "3f246f6166e75fb8afc1866ce8f1e4cb8d95757a" }, "nvim-html-css": { "branch": "main", "commit": "3f246f6166e75fb8afc1866ce8f1e4cb8d95757a" },
"nvim-lint": { "branch": "master", "commit": "3d55c8f67c6ae5c15e1042571e107c7a3d5c5f4e" }, "nvim-lint": { "branch": "master", "commit": "3d55c8f67c6ae5c15e1042571e107c7a3d5c5f4e" },
"nvim-lspconfig": { "branch": "master", "commit": "ffd261c09c3dabd0bf1a438f47a8ae3b22f3c3ff" }, "nvim-lspconfig": { "branch": "master", "commit": "046b1a797af1effe8e0474b9af9657a3761ec58a" },
"nvim-nio": { "branch": "master", "commit": "edcc181a875301dd21840189aa2f2f9ad69fc172" }, "nvim-nio": { "branch": "master", "commit": "edcc181a875301dd21840189aa2f2f9ad69fc172" },
"nvim-treesitter": { "branch": "main", "commit": "f603a2f4da48728f80257fb5fbb90145fd1dc173" }, "nvim-treesitter": { "branch": "main", "commit": "f603a2f4da48728f80257fb5fbb90145fd1dc173" },
"nvim-treesitter-context": { "branch": "master", "commit": "f3061339b8eaf9fda873600bc425b8d2d8502533" }, "nvim-treesitter-context": { "branch": "master", "commit": "f3061339b8eaf9fda873600bc425b8d2d8502533" },
+1 -1
View File
@@ -10,7 +10,7 @@
--no-mtime --no-mtime
--user-agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36" --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
# --username oauth2 --password '' # --username oauth2 --password ''
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
__version__ = '1.2.2' __version__ = '2.0.0'
import abc import abc
import json import json
import os
from typing import TypeVar
from yt_dlp.extractor.youtube.pot.provider import ( from yt_dlp.extractor.youtube.pot.provider import (
ExternalRequestFeature, ExternalRequestFeature,
@@ -15,6 +17,8 @@ from yt_dlp.extractor.youtube.pot.utils import WEBPO_CLIENTS
from yt_dlp.utils import js_to_json from yt_dlp.utils import js_to_json
from yt_dlp.utils.traversal import traverse_obj from yt_dlp.utils.traversal import traverse_obj
T = TypeVar('T')
class BgUtilPTPBase(PoTokenProvider, abc.ABC): class BgUtilPTPBase(PoTokenProvider, abc.ABC):
PROVIDER_VERSION = __version__ PROVIDER_VERSION = __version__
@@ -36,8 +40,6 @@ class BgUtilPTPBase(PoTokenProvider, abc.ABC):
PoTokenContext.SUBS, PoTokenContext.SUBS,
) )
_GETPOT_TIMEOUT = 20.0 _GETPOT_TIMEOUT = 20.0
_GET_SERVER_VSN_TIMEOUT = 5.0
_MIN_NODE_VSN = (18, 0, 0)
def _info_and_raise(self, msg, raise_from=None): def _info_and_raise(self, msg, raise_from=None):
self.logger.info(msg) self.logger.info(msg)
@@ -47,6 +49,25 @@ class BgUtilPTPBase(PoTokenProvider, abc.ABC):
self.logger.warning(msg, once=once) self.logger.warning(msg, once=once)
raise PoTokenProviderRejectedRequest(msg) from raise_from raise PoTokenProviderRejectedRequest(msg) from raise_from
def _script_config_arg(self, key: str, default: T = None, *, casesense=True) -> str | T:
return self.ie._configuration_arg(
ie_key='youtubepot-bgutilscript', key=key, default=[default], casesense=casesense)[0]
@staticmethod
def _resolve_script_path(*ps: str):
# realpath resolves symlinks and internally calls abspath
return os.path.realpath(
os.path.expanduser(os.path.expandvars(os.path.join(*ps))))
def _script_path_provided(self) -> str | None:
if server_home := self._script_config_arg('server_home'):
return self._resolve_script_path(server_home)
if script_path := self._script_config_arg('script_path'):
return self._resolve_script_path(script_path, os.pardir, os.pardir)
return None
def _check_version(self, got_version, *, default='unknown', name): def _check_version(self, got_version, *, default='unknown', name):
def _major(version): def _major(version):
return version.split('.', 1)[0] return version.split('.', 1)[0]
@@ -58,34 +79,37 @@ class BgUtilPTPBase(PoTokenProvider, abc.ABC):
f'Please ensure they are on the same version. ' f'Please ensure they are on the same version. '
f'Otherwise, help will NOT be provided for any issues that arise. ' f'Otherwise, help will NOT be provided for any issues that arise. '
f'(plugin: {self.PROVIDER_VERSION}, {name}: {got_version or default})', f'(plugin: {self.PROVIDER_VERSION}, {name}: {got_version or default})',
once=True, once=True)
)
if not got_version or _major(got_version) != _major(self.PROVIDER_VERSION): if not got_version or _major(got_version) != _major(self.PROVIDER_VERSION):
self._warn_and_raise( self._warn_and_raise(
f'Plugin and {name} major versions are mismatched. ' f'Plugin and {name} major versions are mismatched. '
f'Update both the plugin and the {name} to the same version to proceed.' f'Update both the plugin and the {name} to the same version to proceed.')
)
def _get_attestation(self, webpage: str | None): def _get_attestation(self, webpage: str | None):
if not webpage: if not webpage:
return None return None
raw_challenge_data = self.ie._search_regex( raw_cd = (
r"""(?sx)window\.ytAtR\s*=\s*(?P<raw_cd>(?P<q>['"]) traverse_obj(
(?: self.ie._search_regex(
\\.| r'''(?sx)window\s*\.\s*ytAtN\s*\(\s*
(?!(?P=q)). (?P<js>\{.+?}\s*)
)* \s*\)\s*;''', webpage, 'ytAtP challenge', default=None),
(?P=q))\s*;""", ({js_to_json}, {json.loads}, 'R'))
webpage, or traverse_obj(
'raw challenge data', self.ie._search_regex(
default=None, r'''(?sx)window\.ytAtR\s*=\s*(?P<raw_cd>(?P<q>['"])
group='raw_cd', (?:
) \\.|
att_txt = traverse_obj(raw_challenge_data, ({js_to_json}, {json.loads}, {json.loads}, 'bgChallenge')) (?!(?P=q)).
if not att_txt: )*
self.logger.warning('Failed to extract initial attestation from the webpage') (?P=q))\s*;''', webpage, 'ytAtR challenge', default=None),
return None ({js_to_json}, {json.loads})))
return att_txt
if att_txt := traverse_obj(raw_cd, ({json.loads}, 'bgChallenge')):
return att_txt
self.logger.warning('Failed to extract initial attestation from the webpage')
return None
__all__ = ['__version__'] __all__ = ['__version__']
@@ -23,6 +23,7 @@ from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
class BgUtilHTTPPTP(BgUtilPTPBase): class BgUtilHTTPPTP(BgUtilPTPBase):
PROVIDER_NAME = 'bgutil:http' PROVIDER_NAME = 'bgutil:http'
DEFAULT_BASE_URL = 'http://127.0.0.1:4416' DEFAULT_BASE_URL = 'http://127.0.0.1:4416'
_GET_SERVER_VSN_TIMEOUT = 5.0
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -61,11 +62,8 @@ class BgUtilHTTPPTP(BgUtilPTPBase):
note=False)) note=False))
except TransportError as e: except TransportError as e:
# the server may be down # the server may be down
script_path_provided = self.ie._configuration_arg(
ie_key='youtubepot-bgutilscript', key='script_path', default=[None])[0] is not None
warning_base = f'Error reaching GET {self._base_url}/ping (caused by {e.__class__.__name__}). ' warning_base = f'Error reaching GET {self._base_url}/ping (caused by {e.__class__.__name__}). '
if script_path_provided: # server down is expected, log info if self._script_path_provided() is not None: # server down is expected, log info
self._info_and_raise( self._info_and_raise(
warning_base + 'This is expected if you are using the script method.') warning_base + 'This is expected if you are using the script method.')
else: else:
@@ -108,18 +106,18 @@ class BgUtilHTTPPTP(BgUtilPTPBase):
# used for CI check # used for CI check
self.logger.trace('Generating POT via HTTP server') self.logger.trace('Generating POT via HTTP server')
disable_innertube = bool(self._configuration_arg('disable_innertube', default=[None])[0]) if self._configuration_arg('disable_innertube', default=[None])[0] is not None:
challenge = self._get_attestation(None if disable_innertube else request.video_webpage) self._warn_and_raise(
"'youtubepot-bgutilhttp:disable_innertube' extractor arg is deprecated")
challenge = self._get_attestation(request.video_webpage)
# The challenge is falsy when the webpage and the challenge are unavailable # The challenge is falsy when the webpage and the challenge are unavailable
# In this case, we need to disable /att/get since it's broken for web_music # In this case, we need to disable /att/get since it's broken for web_music
if not challenge and request.internal_client_name == 'web_music': if not challenge and request.internal_client_name == 'web_music':
if not disable_innertube: # if not already set, warn the user self._warn_and_raise(
self.logger.warning( 'BotGuard challenges could not be obtained from the webpage, '
'BotGuard challenges could not be obtained from the webpage, ' 'a PO Token cannot be generated because InnerTube challenges '
'overriding disable_innertube=True because InnerTube challenges ' 'are currently broken for the web_music client. ')
'are currently broken for the web_music client. '
'Pass disable_innertube=1 to suppress this warning.')
disable_innertube = True
try: try:
response = self._request_webpage( response = self._request_webpage(
@@ -128,7 +126,6 @@ class BgUtilHTTPPTP(BgUtilPTPBase):
'bypass_cache': request.bypass_cache, 'bypass_cache': request.bypass_cache,
'challenge': challenge, 'challenge': challenge,
'content_binding': get_webpo_content_binding(request)[0], 'content_binding': get_webpo_content_binding(request)[0],
'disable_innertube': disable_innertube,
'disable_tls_verification': not request.request_verify_tls, 'disable_tls_verification': not request.request_verify_tls,
'proxy': request.request_proxy, 'proxy': request.request_proxy,
'innertube_context': request.innertube_context, 'innertube_context': request.innertube_context,
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
import abc
import functools import functools
import json import json
import os.path import os
import re import re
import shutil
import subprocess import subprocess
import sys
import sysconfig
from typing import Iterable
from yt_dlp.extractor.youtube.pot.provider import ( from yt_dlp.extractor.youtube.pot.provider import (
PoTokenProviderError, PoTokenProviderError,
@@ -15,106 +18,201 @@ from yt_dlp.extractor.youtube.pot.provider import (
register_provider, register_provider,
) )
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
from yt_dlp.utils import Popen from yt_dlp.utils import Popen, int_or_none, shell_quote
from yt_dlp.utils.traversal import traverse_obj
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
_FALLBACK_PATHEXT = ('.COM', '.EXE', '.BAT', '.CMD')
@register_provider
class BgUtilScriptPTP(BgUtilPTPBase): # Copied from https://github.com/yt-dlp/yt-dlp/blob/d9813a3da6959662841dfb34cad0ee6c07a65d1e/yt_dlp/utils/_jsruntime.py#L16-L64
PROVIDER_NAME = 'bgutil:script' # NOTE: keep in sync with upstream
def _find_exe(basename: str) -> str:
# Check in Python "scripts" path, e.g. for pipx-installed binaries
binary = os.path.join(
sysconfig.get_path('scripts'),
basename + sysconfig.get_config_var('EXE'))
if os.access(binary, os.F_OK | os.X_OK) and not os.path.isdir(binary):
return binary
if os.name != 'nt':
return basename
paths: list[str] = []
# binary dir
if getattr(sys, 'frozen', False):
paths.append(os.path.dirname(sys.executable))
# cwd
paths.append(os.getcwd())
# PATH items
if path := os.environ.get('PATH'):
paths.extend(filter(None, path.split(os.path.pathsep)))
pathext = os.environ.get('PATHEXT')
if pathext is None:
exts = _FALLBACK_PATHEXT
else:
exts = tuple(ext for ext in pathext.split(os.pathsep) if ext)
visited = set()
for path in map(os.path.realpath, paths):
normed = os.path.normcase(path)
if normed in visited:
continue
visited.add(normed)
for ext in exts:
binary = os.path.join(path, f'{basename}{ext}')
if os.access(binary, os.F_OK | os.X_OK) and not os.path.isdir(binary):
return binary
return basename
def _determine_runtime_path(path, basename):
if not path:
return _find_exe(basename)
if os.path.isdir(path):
return os.path.join(path, basename)
return path
class BgUtilScriptPTPBase(BgUtilPTPBase, abc.ABC):
_GET_SCRIPT_VSN_TIMEOUT = 15.0
@staticmethod
def _jsrt_vsn_tup(v: str):
return tuple(int_or_none(x, default=0) for x in v.split('.'))
def __init_subclass__(cls):
super().__init_subclass__()
pref = cls._JSRT_PREF
register_preference(cls)(lambda provider, request: pref)
_SCRIPT_BASENAME: str
_JSRT_NAME: str # Name of the JS Runtime shown in logs
_JSRT_EXEC: str # Name of the executable, and the name used in yt-dlp
_JSRT_VSN_REGEX: str
_JSRT_MIN_VER: tuple[int, ...]
_JSRT_PREF: int
@abc.abstractmethod
def _script_path_impl(self) -> str:
raise NotImplementedError
def _jsrt_args(self) -> Iterable[str]:
return ()
def _jsrt_envs(self) -> dict:
return os.environ.copy()
def _jsrt_path_impl(self) -> str | None:
jsrt_path = _determine_runtime_path(
traverse_obj(self.ie.get_param('js_runtimes'), (self._JSRT_EXEC, 'path')),
self._JSRT_EXEC)
try:
output, _, returncode = Popen.run(
[jsrt_path, '--version'], env=self._jsrt_envs(), timeout=5.0,
text=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = output.strip()
except subprocess.TimeoutExpired:
self.logger.debug(
f'Failed to check {self._JSRT_NAME} version: {self._JSRT_NAME} process '
'did not finish in 5.0 seconds', once=True)
return None
except FileNotFoundError:
self.logger.debug(
f'{self._JSRT_NAME} executable not found. Please ensure {self._JSRT_NAME} is '
'installed and available in PATH or passed to yt-dlp with --js-runtimes.', once=True)
return None
mobj = re.search(self._JSRT_VSN_REGEX, output)
if returncode or not mobj:
self.logger.debug(
f'Failed to check {self._JSRT_NAME} version. '
f'{self._JSRT_NAME} returned {returncode} exit status. '
f'Process output:\n{output}', once=True)
return None
if self._jsrt_has_support(mobj.group(1)):
return jsrt_path
def _jsrt_has_support(self, v: str) -> bool:
if self._jsrt_vsn_tup(v) >= self._JSRT_MIN_VER:
self.logger.trace(f'{self._JSRT_NAME} version: {v}')
return True
else:
min_vsn_str = '.'.join(map(str, self._JSRT_MIN_VER))
self.logger.debug(
f'{self._JSRT_NAME} version too low. '
f'(got {v}, but at least {min_vsn_str} is required)', once=True)
return False
@functools.cached_property
def _script_path(self) -> str:
return self._script_path_impl()
@functools.cached_property
def _jsrt_path(self) -> str | None:
return self._jsrt_path_impl()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._check_script = functools.cache(self._check_script_impl) self._check_script = functools.cache(self._check_script_impl)
@functools.cached_property @functools.cached_property
def _script_path(self): def _server_home(self) -> str:
script_path = self._configuration_arg( if path := self._script_path_provided():
'script_path', casesense=True, default=[None])[0] return path
if script_path:
return os.path.expandvars(script_path)
# check deprecated arg
deprecated_script_path = self.ie._configuration_arg(
ie_key='youtube', key='getpot_bgutil_script', default=[None])[0]
if deprecated_script_path:
self._warn_and_raise(
"'youtube:getpot_bgutil_script' extractor arg is deprecated, use 'youtubepot-bgutilscript:script_path' instead")
# default if no arg was passed # default if no arg was passed
home = os.path.expanduser('~') default_home = self._resolve_script_path('~', 'bgutil-ytdlp-pot-provider', 'server')
default_path = os.path.join(
home, 'bgutil-ytdlp-pot-provider', 'server', 'build', 'generate_once.js')
self.logger.debug( self.logger.debug(
f'No script path passed, defaulting to {default_path}') f'No server_home or script_path passed, defaulting to {default_home}', once=True)
return default_path return default_home
def is_available(self):
return self._check_script(self._script_path)
@functools.cached_property @functools.cached_property
def _node_path(self): def _script_cache_dir(self) -> str:
node_path = shutil.which('node') # don't use _HOMEDIR as the server is coded this way and accepts HOME and USERPROFILE regardless of the OS
if node_path is None: home_dir = os.getenv('HOME') or os.getenv('USERPROFILE')
self.logger.trace('node is not in PATH') if (xdg_cache := os.getenv('XDG_CACHE_HOME')) is not None:
vsn = self._check_node_version(node_path) return os.path.abspath(os.path.join(xdg_cache, 'bgutil-ytdlp-pot-provider'))
if vsn: elif home_dir:
self.logger.trace(f'Node version: {vsn}') return os.path.abspath(os.path.join(home_dir, '.cache', 'bgutil-ytdlp-pot-provider'))
return node_path else:
return self._server_home
def _check_script_impl(self, script_path): def is_available(self) -> bool:
return self._check_script(self._script_path)
def _check_script_impl(self, script_path) -> bool:
if not os.path.isfile(script_path): if not os.path.isfile(script_path):
self.logger.debug( self.logger.debug(
f"Script path doesn't exist: {script_path}") f"Script path doesn't exist: {script_path}", once=True)
return False return False
if os.path.basename(script_path) != 'generate_once.js': if os.path.basename(script_path) != self._SCRIPT_BASENAME:
self.logger.warning( self.logger.warning(
'Incorrect script passed to extractor args. Path to generate_once.js required', once=True) f'The script path passed in the extractor argument '
f'has a wrong base name, expected {self._SCRIPT_BASENAME}.', once=True)
return False return False
node_path = self._node_path if not self._jsrt_path:
if not node_path:
return False return False
stdout, stderr, returncode = Popen.run( stdout, _, returncode = Popen.run(
[self._node_path, script_path, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, [self._jsrt_path, *self._jsrt_args(), script_path, '--version'],
timeout=self._GET_SERVER_VSN_TIMEOUT) env=self._jsrt_envs(), timeout=self._GET_SCRIPT_VSN_TIMEOUT,
text=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
stdout = stdout.strip()
if returncode: if returncode:
self.logger.warning( self.logger.warning(
f'Failed to check script version. ' f'Failed to check script version. '
f'Script returned {returncode} exit status. ' f'Script returned {returncode} exit status. '
f'Script stdout: {stdout}; Script stderr: {stderr}', f'Script stdout:\n{stdout}',
once=True) once=True)
return False return False
else: else:
self._check_version(stdout.strip(), name='script') self._check_version(stdout, name='script')
return True return True
def _check_node_version(self, node_path):
try:
stdout, stderr, returncode = Popen.run(
[node_path, '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
timeout=self._GET_SERVER_VSN_TIMEOUT)
stdout = stdout.strip()
mobj = re.match(r'v(\d+)\.(\d+)\.(\d+)', stdout)
if returncode or not mobj:
raise ValueError
node_vsn = tuple(map(int, mobj.groups()))
if node_vsn >= self._MIN_NODE_VSN:
return node_vsn
raise RuntimeError
except RuntimeError:
min_vsn_str = 'v' + '.'.join(str(v) for v in self._MIN_NODE_VSN)
self.logger.warning(
f'Node version too low. '
f'(got {stdout}, but at least {min_vsn_str} is required)')
except (subprocess.TimeoutExpired, ValueError):
self.logger.warning(
f'Failed to check node version. '
f'Node returned {returncode} exit status. '
f'Node stdout: {stdout}; Node stderr: {stderr}')
def _real_request_pot( def _real_request_pot(
self, self,
request: PoTokenRequest, request: PoTokenRequest,
@@ -123,10 +221,11 @@ class BgUtilScriptPTP(BgUtilPTPBase):
self.logger.trace( self.logger.trace(
f'Generating POT via script: {self._script_path}') f'Generating POT via script: {self._script_path}')
command_args = [self._node_path, self._script_path] command_args = [self._jsrt_path, *self._jsrt_args(), self._script_path]
if proxy := request.request_proxy: if proxy := request.request_proxy:
command_args.extend(['-p', proxy]) command_args.extend(['-p', proxy])
command_args.extend(['-c', get_webpo_content_binding(request)[0]]) command_args.extend(['-c', get_webpo_content_binding(request)[0]])
command_args.extend(['--innertube-context', json.dumps(request.innertube_context)])
if request.bypass_cache: if request.bypass_cache:
command_args.append('--bypass-cache') command_args.append('--bypass-cache')
if request.request_source_address: if request.request_source_address:
@@ -140,12 +239,14 @@ class BgUtilScriptPTP(BgUtilPTPBase):
f'{request.internal_client_name} client via bgutil script', f'{request.internal_client_name} client via bgutil script',
) )
self.logger.debug( self.logger.debug(
f'Executing command to get POT via script: {" ".join(command_args)}') f'Executing command to get POT via script: {" ".join(map(shell_quote, command_args))}')
try: try:
stdout, stderr, returncode = Popen.run( stdout, _, returncode = Popen.run(
command_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, command_args, env=self._jsrt_envs(), timeout=self._GETPOT_TIMEOUT,
timeout=self._GETPOT_TIMEOUT) text=True, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
stdout_lines = stdout.strip().splitlines()
json_resp = stdout_lines.pop()
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
raise PoTokenProviderError( raise PoTokenProviderError(
f'_get_pot_via_script failed: Timeout expired when trying to run script (caused by {e!r})') f'_get_pot_via_script failed: Timeout expired when trying to run script (caused by {e!r})')
@@ -153,20 +254,13 @@ class BgUtilScriptPTP(BgUtilPTPBase):
raise PoTokenProviderError( raise PoTokenProviderError(
f'_get_pot_via_script failed: Unable to run script (caused by {e!r})') from e f'_get_pot_via_script failed: Unable to run script (caused by {e!r})') from e
msg = '' if stdout_extra := '\n'.join(stdout_lines):
if stdout_extra := stdout.strip().splitlines()[:-1]: self.logger.debug(f'script stdout:\n{stdout_extra}')
msg = f'stdout:\n{stdout_extra}\n'
if stderr_stripped := stderr.strip(): # Empty strings are falsy
msg += f'stderr:\n{stderr_stripped}\n'
msg = msg.strip()
if msg:
self.logger.trace(msg)
if returncode: if returncode:
raise PoTokenProviderError( raise PoTokenProviderError(
f'_get_pot_via_script failed with returncode {returncode}') f'_get_pot_via_script failed with returncode {returncode}')
try: try:
json_resp = stdout.splitlines()[-1]
self.logger.trace(f'JSON response:\n{json_resp}') self.logger.trace(f'JSON response:\n{json_resp}')
# The JSON response is always the last line # The JSON response is always the last line
script_data_resp = json.loads(json_resp) script_data_resp = json.loads(json_resp)
@@ -179,10 +273,55 @@ class BgUtilScriptPTP(BgUtilPTPBase):
return PoTokenResponse(po_token=script_data_resp['poToken']) return PoTokenResponse(po_token=script_data_resp['poToken'])
@register_preference(BgUtilScriptPTP) @register_provider
def bgutil_script_getpot_preference(provider, request): class BgUtilScriptNodePTP(BgUtilScriptPTPBase):
return 1 PROVIDER_NAME = 'bgutil:script-node'
_SCRIPT_BASENAME = 'generate_once.js'
_JSRT_NAME = 'Node.js'
_JSRT_EXEC = 'node'
_JSRT_VSN_REGEX = r'^v(\S+)'
_JSRT_MIN_VER = (22, 0, 0)
_JSRT_PREF = 10
def _script_path_impl(self) -> str:
return os.path.join(
self._server_home, 'build', self._SCRIPT_BASENAME)
__all__ = [BgUtilScriptPTP.__name__, @register_provider
bgutil_script_getpot_preference.__name__] class BgUtilScriptDenoPTP(BgUtilScriptPTPBase):
PROVIDER_NAME = 'bgutil:script-deno'
_SCRIPT_BASENAME = 'generate_once.ts'
_JSRT_NAME = 'Deno'
_JSRT_EXEC = 'deno'
_JSRT_VSN_REGEX = r'^deno (\S+)'
_JSRT_MIN_VER = (2, 4, 3)
_JSRT_PREF = 20
def _script_path_impl(self) -> str:
return os.path.join(
self._server_home, 'src', self._SCRIPT_BASENAME)
def _jsrt_args(self) -> Iterable[str]:
def escpath(*strs: str):
return ','.join(s.replace(',', ',,') for s in strs)
node_mods_path = os.path.join(self._server_home, 'node_modules')
return (
'run', '--allow-env', '--allow-net',
f'--allow-ffi={escpath(node_mods_path)}',
f'--allow-write={escpath(self._script_cache_dir)}',
f'--allow-read={escpath(self._script_cache_dir, node_mods_path)}',
)
def _jsrt_envs(self) -> dict:
process_env = os.environ.copy()
process_env['DENO_NO_PROMPT'] = '1'
process_env['DENO_NO_UPDATE_CHECK'] = '1'
process_env['FORCE_COLOR'] = 'false'
return process_env
__all__ = [
BgUtilScriptNodePTP.__name__,
BgUtilScriptDenoPTP.__name__,
]