From e90fbfe194ccb796f5ce736c9cfd2beb3e61248b Mon Sep 17 00:00:00 2001 From: biyusku Date: Tue, 31 Mar 2026 09:56:16 +0300 Subject: [PATCH] fix(youtube): use platform-aware yt-dlp config path and locale-safe reads (#225) --- agent_reach/channels/youtube.py | 14 +++++----- agent_reach/utils/paths.py | 45 +++++++++++++++++++++++++++++++++ agent_reach/utils/text.py | 14 ++++++++++ tests/test_channel_contracts.py | 30 +++++++++++++++++++++- 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 agent_reach/utils/paths.py create mode 100644 agent_reach/utils/text.py diff --git a/agent_reach/channels/youtube.py b/agent_reach/channels/youtube.py index 8882cfa..b147c44 100644 --- a/agent_reach/channels/youtube.py +++ b/agent_reach/channels/youtube.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- """YouTube — check if yt-dlp is available with JS runtime.""" -import os import shutil + +from agent_reach.utils.paths import get_ytdlp_config_path, render_ytdlp_fix_command +from agent_reach.utils.text import read_utf8_text + from .base import Channel @@ -31,14 +34,13 @@ class YouTubeChannel(Channel): # Deno works out of the box; Node.js requires explicit config has_deno = shutil.which("deno") if not has_deno: - ytdlp_config = os.path.expanduser("~/.config/yt-dlp/config") + ytdlp_config = get_ytdlp_config_path() has_js_config = False - if os.path.exists(ytdlp_config): - with open(ytdlp_config, "r") as f: - has_js_config = "--js-runtimes" in f.read() + if ytdlp_config.exists(): + has_js_config = "--js-runtimes" in read_utf8_text(ytdlp_config) if not has_js_config: return "warn", ( "yt-dlp 已安装但未配置 JS runtime。运行:\n" - " mkdir -p ~/.config/yt-dlp && echo '--js-runtimes node' >> ~/.config/yt-dlp/config" + f" {render_ytdlp_fix_command()}" ) return "ok", "可提取视频信息和字幕" diff --git a/agent_reach/utils/paths.py b/agent_reach/utils/paths.py new file mode 100644 index 0000000..850ab7d --- /dev/null +++ b/agent_reach/utils/paths.py @@ -0,0 +1,45 @@ +"""Cross-platform path and remediation helpers for yt-dlp.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def get_ytdlp_config_dir() -> Path: + """Return the recommended yt-dlp user config directory for this OS.""" + + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if appdata: + return Path(appdata) / "yt-dlp" + return Path.home() / "AppData" / "Roaming" / "yt-dlp" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "yt-dlp" + return Path.home() / ".config" / "yt-dlp" + + +def get_ytdlp_config_path() -> Path: + """Return the yt-dlp user config file path for this OS.""" + + return get_ytdlp_config_dir() / "config" + + +def render_ytdlp_fix_command() -> str: + """Return an OS-appropriate command to enable Node.js as yt-dlp JS runtime.""" + + config_path = get_ytdlp_config_path() + if sys.platform == "win32": + return ( + f"$cfg = '{config_path}'\n" + "New-Item -ItemType Directory -Force -Path (Split-Path $cfg) | Out-Null\n" + "if (-not (Test-Path $cfg) -or -not (Select-String -Path $cfg -Pattern '--js-runtimes' -Quiet)) {\n" + " Add-Content -Path $cfg -Value '--js-runtimes node'\n" + "}" + ) + return ( + f"mkdir -p '{config_path.parent}' && " + f"grep -qxF -- '--js-runtimes node' '{config_path}' 2>/dev/null || " + f"printf '%s\n' '--js-runtimes node' >> '{config_path}'" + ) diff --git a/agent_reach/utils/text.py b/agent_reach/utils/text.py new file mode 100644 index 0000000..850a96d --- /dev/null +++ b/agent_reach/utils/text.py @@ -0,0 +1,14 @@ +"""UTF-8-safe text helpers for cross-platform file operations.""" + +from __future__ import annotations + +from pathlib import Path + + +def read_utf8_text(path: str | Path, default: str = "") -> str: + """Read text as UTF-8 with replacement semantics.""" + + target = Path(path) + if not target.exists(): + return default + return target.read_text(encoding="utf-8", errors="replace") diff --git a/tests/test_channel_contracts.py b/tests/test_channel_contracts.py index 5fe4b74..75536be 100644 --- a/tests/test_channel_contracts.py +++ b/tests/test_channel_contracts.py @@ -50,6 +50,28 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path): assert "--js-runtimes" in message +def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path): + """Windows guidance should use a PowerShell-style yt-dlp config command.""" + from agent_reach.channels.youtube import YouTubeChannel + + def fake_which(cmd): + if cmd == "yt-dlp": + return "C:/yt-dlp.exe" + if cmd == "node": + return "C:/node.exe" + return None + + monkeypatch.setattr("shutil.which", fake_which) + monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) + + ch = YouTubeChannel() + status, message = ch.check() + assert status == "warn" + assert "Select-String" in message + assert "--js-runtimes node" in message + + def test_youtube_ok_when_deno_installed(monkeypatch): """YouTube should return ok when Deno is installed (no config needed).""" from agent_reach.channels.youtube import YouTubeChannel @@ -81,19 +103,25 @@ def test_douyin_check_does_not_call_with_invalid_url(monkeypatch, tmp_path): calls.append(cmd) # Simulate mcporter config list returning douyin if "config" in cmd and "list" in cmd: + class R: stdout = "douyin http://localhost:18070/mcp" returncode = 0 + return R() # Simulate mcporter list douyin returning tools if "list" in cmd and "douyin" in cmd: + class R: stdout = "parse_douyin_video_info" returncode = 0 + return R() return original_run(cmd, **kwargs) - monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/mcporter" if cmd == "mcporter" else None) + monkeypatch.setattr( + "shutil.which", lambda cmd: "/usr/bin/mcporter" if cmd == "mcporter" else None + ) monkeypatch.setattr("subprocess.run", tracking_run) ch = DouyinChannel()