From 2363edc36615f187082b6693e71f5c8b0f450cce Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 17 May 2026 16:57:57 +0800 Subject: [PATCH 1/6] docs: add network proxy settings design --- ...026-05-17-network-proxy-settings-design.md | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-17-network-proxy-settings-design.md diff --git a/docs/superpowers/specs/2026-05-17-network-proxy-settings-design.md b/docs/superpowers/specs/2026-05-17-network-proxy-settings-design.md new file mode 100644 index 0000000..6fc90a8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-network-proxy-settings-design.md @@ -0,0 +1,420 @@ +# Network Proxy Settings Design + +## Summary + +为桌面播放器新增“高级设置 > 网络代理”能力,覆盖应用内主要外部网络请求,并把 `yt-dlp` 一并纳入代理控制。 + +第一版目标: + +- 高级设置对话框改为标签页结构,至少分为 `元数据` 和 `网络代理` +- 支持互斥代理模式:`直连` / `系统代理` / `HTTP` / `HTTPS` / `SOCKS5` +- 手动代理支持带认证的完整 URL,例如 `http://user:pass@host:port` +- 支持用户可编辑的直连规则 +- 将代理策略统一接入 `ApiClient`、元数据抓取、弹幕、解析源、海报、插件下载、HLS 代理上游请求和 `yt-dlp` + +本轮重点不是做完整“代理中心”,而是先建立稳定的数据模型、代理决策层和跨模块接线方式,为后续“按域名分流”预留演进空间。 + +## Goals + +- 用户可以在应用内统一配置网络代理,而不是依赖外部环境变量。 +- 第一版代理配置能覆盖用户实际最依赖的外网链路,尤其是 `YouTube`、`TikTok`、`Instagram` 和各类解析/弹幕请求。 +- 本地 API、局域网资源和其他需要直连的地址可以通过规则显式绕过代理。 +- 不把代理判断逻辑散落在各个业务模块里。 +- 为后续“按域名分流”保留清晰的扩展位,而不推翻第一版结构。 + +## Non-Goals + +- 本轮不实现按域名分流或多代理路由。 +- 本轮不实现“代理配置列表”“多个代理方案切换”“优先级规则编辑器”。 +- 本轮不新增“按模块单独开关代理”的 UI。 +- 本轮不做代理可用性测试、出口 IP 检测或诊断面板。 +- 本轮不改造成完整统一网络会话工厂。 + +## Scope + +主要改动: + +- `src/atv_player/models.py` +- `src/atv_player/storage.py` +- `src/atv_player/ui/advanced_settings_dialog.py` +- `src/atv_player/app.py` +- `src/atv_player/api.py` +- 新增网络代理决策模块,例如 `src/atv_player/network_proxy.py` +- 逐步接入主要网络调用点: + - `src/atv_player/ui/poster_loader.py` + - `src/atv_player/playback_parsers.py` + - `src/atv_player/danmaku/*` + - `src/atv_player/metadata/providers/*` + - `src/atv_player/plugins/loader.py` + - `src/atv_player/proxy/*` + - `src/atv_player/yt_dlp_service.py` + +主要验证: + +- `tests/test_storage.py` +- 新增代理决策/规则测试 +- `tests/test_api_client.py` +- `tests/test_main_window_ui.py` +- 新增高级设置对话框测试 +- `tests/test_yt_dlp_service.py` +- 按需补充解析源/海报/弹幕相关测试 + +## Current Problem + +当前项目里的外部网络访问没有统一代理入口: + +- 高级设置目前只有元数据增强相关项,没有任何网络代理配置。 +- 网络请求分散在 `ApiClient`、元数据 provider、弹幕 provider、海报加载、解析器、插件下载、HLS 代理和 `yt-dlp` 子进程中。 +- 如果只追加 UI 而不建立统一代理决策层,后续会出现“部分请求走代理、部分不走”的不一致行为。 +- `yt-dlp` 是用户最依赖代理的链路之一,但它使用子进程调用,不会自动复用应用内 `httpx` 配置。 + +## Approach Options + +### Option A: Scatter proxy params into each call site + +做法: + +- 在各个 `httpx.get/post`、provider 和 `yt-dlp` 调用点分别读取当前配置并拼接代理参数。 + +优点: + +- 早期代码改动表面上最直接。 + +缺点: + +- 判断逻辑会复制到大量模块里。 +- 后续加系统代理、直连规则、按域名分流时会迅速失控。 +- 很难保证 `httpx`、`requests` 和 `yt-dlp` 的行为一致。 + +### Option B: Add a centralized proxy decision layer and thin adapters + +做法: + +- 新增统一的代理配置模型和代理决策层。 +- 业务模块只向代理层询问“这个 URL 应该直连还是走哪个代理”。 +- 再用薄适配层分别对接 `httpx`、`requests` 兼容调用和 `yt-dlp`。 + +优点: + +- 第一版复杂度可控,但不会堵死后续演进路径。 +- 代理规则、模式判断和协议转换集中管理,行为更一致。 +- `yt-dlp` 这种特殊出口也能复用同一套决策语义。 + +缺点: + +- 需要引入一个新模块和一组配套测试。 + +### Option C: Build a full network session factory now + +做法: + +- 立即把所有网络请求都重构到统一网络工厂/会话层,再由工厂负责代理、超时和重试等横切逻辑。 + +优点: + +- 长期结构最干净。 + +缺点: + +- 对当前需求明显过重。 +- 改动面过大,回归风险不适合第一版代理功能。 + +## Decision + +采用 **Option B**。 + +原因: + +- 当前最缺的是“统一代理语义”,不是“完整网络基础设施重构”。 +- Option B 可以覆盖本轮最重要的用户场景,同时把规则判断收拢到一个位置。 +- 这样第一版就能把 `httpx`、`requests` 兼容调用和 `yt-dlp` 三类出口纳入同一套配置,而不把项目拖进大规模重构。 + +## Design + +### 1. AppConfig and persistence + +在 `AppConfig` 中新增: + +- `network_proxy_mode: str = "direct"` +- `network_proxy_url: str = ""` +- `network_proxy_bypass_rules: list[str] = field(default_factory=list)` + +约束: + +- `network_proxy_mode` 仅允许: + - `direct` + - `system` + - `http` + - `https` + - `socks5` +- `network_proxy_url` 保存完整代理 URL,手动模式时要求包含协议头 +- `network_proxy_bypass_rules` 为用户输入后的规范化规则列表,按行存储 + +`SettingsRepository` 同步: + +- 初始化建表时新增对应列 +- 老库迁移时补列 +- `load_config()` / `save_config()` 支持完整 round-trip + +存储建议: + +- `network_proxy_mode`:`TEXT` +- `network_proxy_url`:`TEXT` +- `network_proxy_bypass_rules`:`TEXT`,以 JSON 数组持久化 + +默认值: + +- 模式默认为 `direct` +- 代理 URL 默认为空 +- 直连规则默认为内置建议值,但用户可编辑保存: + - `localhost` + - `127.0.0.1` + - `::1` + - `10.0.0.0/8` + - `172.16.0.0/12` + - `192.168.0.0/16` + - `.local` + +### 2. Advanced settings dialog + +现有 `AdvancedSettingsDialog` 调整为标签页结构: + +- `元数据` +- `网络代理` + +`网络代理` 页字段: + +- `代理模式` + - 互斥单选或等价互斥控件 + - 选项:`直连` / `系统代理` / `HTTP` / `HTTPS` / `SOCKS5` + - 语义:用户在三种手动代理类型中选择其一,所选代理对全部 `http/https` 目标请求生效,而不是“按目标协议分别填写不同代理” +- `手动代理地址` + - 单行输入框 + - 支持 `user:pass` + - 示例提示: + - `http://127.0.0.1:7890` + - `socks5://user:pass@127.0.0.1:1080` +- `直连规则` + - 多行文本框 + - 一行一条 +- `覆盖范围说明` + - 只读文案,明确第一版会影响: + - API + - 元数据 + - 解析源 + - 弹幕 + - 海报 + - 插件下载 + - HLS 代理上游请求 + - `yt-dlp` + +行为: + +- 选择 `直连` 或 `系统代理` 时,手动代理输入框禁用但保留值。 +- 选择手动模式时,要求代理地址非空且协议与模式一致。 +- 保存前逐行校验直连规则;非法规则直接提示具体行号,不静默忽略。 +- 点击保存时回写 `AppConfig` 并调用现有 `save_config`。 +- 点击取消时不落盘。 + +本轮不做: + +- 测试连接按钮 +- 自动检测系统代理内容并展示详情 +- 代理连通性日志面板 + +### 3. Proxy model, rules, and decision layer + +新增统一代理模块,例如 `src/atv_player/network_proxy.py`。 + +建议拆为三个小单元: + +#### `ProxyConfig` + +承载规范化后的配置: + +- `mode` +- `proxy_url` +- `bypass_rules` + +#### `ProxyBypassRule` + +封装单条直连规则及匹配逻辑。第一版支持四类语义: + +- 精确主机:`localhost`、`api.example.com` +- 域名后缀:`.local`、`.example.com` +- 单个 IP:`127.0.0.1` +- CIDR 网段:`10.0.0.0/8` + +匹配规则: + +- 只基于目标 URL 的 `host` +- 不匹配路径、查询参数、端口 +- 域名大小写不敏感 +- IP/CIDR 使用标准库 `ipaddress` 解析 + +#### `ProxyDecider` + +输入目标 URL,输出统一决策结果: + +- `direct` +- `system` +- `manual(proxy_url)` + +判定顺序: + +1. 目标 URL 不是 `http` 或 `https`:直连 +2. 目标主机命中直连规则:直连 +3. 模式是 `direct`:直连 +4. 模式是 `system`:系统代理 +5. 模式是手动代理:返回配置的代理 URL + +设计约束: + +- `ProxyDecider` 只负责决策,不直接发请求 +- 业务代码不自行解析直连规则 +- 后续“按域名分流”时,在 `ProxyDecider` 基础上扩展,不重写 UI 和存储结构 + +### 4. Adapters for different network clients + +由于项目中同时存在 `httpx`、`requests` 风格调用和 `yt-dlp` 子进程,代理层需要提供薄适配方法。 + +建议能力: + +- 为 `httpx` 生成合适的请求/客户端参数 +- 为 `requests` 兼容调用生成 `proxies` +- 为 `yt-dlp` 生成 `--proxy` + +约束: + +- `system` 模式不在应用内重复解析系统代理;直接让底层客户端使用环境默认行为 +- 手动代理模式下统一把代理 URL 透传给目标客户端 +- 对于命中直连规则的 URL,适配层必须显式产生“禁用代理”的效果,而不是依赖隐式默认值 + +### 5. Integration points + +第一版统一覆盖下列调用路径: + +#### `ApiClient` + +- 所有到上游服务的 `httpx.Client` 请求都纳入代理决策 +- 本地 `127.0.0.1` API 默认通过直连规则绕过代理,但用户可编辑规则 + +#### Metadata providers + +- `TMDBClient` +- `BangumiClient` +- 本地豆瓣抓取客户端 +- 其他直接使用 `httpx.get/post` 的 provider + +#### Danmaku providers and direct parse + +- `bilibili` +- `iqiyi` +- `mgtv` +- `tencent` +- `youku` +- `direct_parse` + +#### Poster loading and playback helpers + +- 海报下载 +- 解析源网络请求 +- 远程蓝光/播放预处理中的外部请求 + +#### Plugin and proxy infrastructure + +- 远程插件下载 +- HLS 代理拉取上游 m3u8、分片和密钥资源 + +#### `yt-dlp` + +- 解析 `YouTube`、`TikTok`、`Instagram` 等来源时,命令行显式携带代理参数 +- 若目标 URL 命中直连规则,则该次 `yt-dlp` 调用不传手动代理 + +### 6. Validation and error handling + +保存配置时的校验: + +- `direct` / `system`:允许代理地址为空 +- 手动 `http` / `https` / `socks5`:代理地址必填 +- 手动模式下,代理 URL 协议必须与模式一致 + - `http` 模式要求 `http://` + - `https` 模式要求 `https://` + - `socks5` 模式要求 `socks5://` +- 直连规则逐行解析,任何非法行都中止保存并提示: + - 行号 + - 原始内容 + - 基本错误原因 + +运行时错误策略: + +- 不在第一版引入“代理失败自动回退直连” +- 请求失败仍按现有业务错误链路上抛 +- 保存时尽可能前置拦截格式问题,减少运行时歧义 + +### 7. Future extension path + +第一版设计必须为“按域名分流”保留扩展位: + +- UI 已有独立 `网络代理` 标签页 +- 数据模型已独立出代理配置字段 +- 决策逻辑集中在 `ProxyDecider` + +后续做域名分流时,可以新增: + +- 规则列表:`pattern -> mode/proxy` +- 更细粒度的匹配优先级 +- 多代理方案 + +这些扩展不应要求重写当前第一版的存储结构和主接线方式。 + +## Testing Strategy + +至少覆盖以下层级: + +### Storage + +- 新字段默认值 +- 老库迁移补列 +- `network_proxy_bypass_rules` JSON round-trip + +### Rule parsing and matching + +- 精确主机命中 +- 域名后缀命中 +- 单个 IP 命中 +- CIDR 命中 +- 非法规则报错 + +### Decision layer + +- `direct` 模式始终直连 +- `system` 模式在未命中直连规则时返回系统代理 +- 手动代理模式在未命中直连规则时返回代理 URL +- 非 `http/https` URL 直连 + +### Adapters + +- `httpx` 适配输出 +- `requests` 适配输出 +- `yt-dlp` `--proxy` 参数生成 + +### UI + +- 标签页展示 +- 模式切换导致输入框启用状态变化 +- 非法代理 URL 阻止保存 +- 非法直连规则阻止保存 + +### Key integration points + +- `ApiClient` 请求能够读取代理决策 +- `yt-dlp` 调用能按配置追加/省略代理参数 +- 关键外部请求入口至少选择一两个代表性模块补回归测试 + +## Risks + +- 项目内部分请求使用的是一次性 `httpx.get/post`,部分是 `httpx.Client`,接线方式不统一,容易漏接。 +- `yt-dlp` 的代理参数是进程级行为,和应用内请求并不是一套机制,必须单独测试。 +- 用户可编辑直连规则后,默认本地直连保护不再是“硬编码安全网”,文案需要足够明确。 +- 第一版如果对“系统代理”语义处理过重,反而会与各平台环境变量行为打架,因此应保持薄适配。 From 2f050e7f4d8b2e8039bf801a8ac0aa8ca139264c Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 17 May 2026 17:09:58 +0800 Subject: [PATCH 2/6] feat: persist network proxy settings --- src/atv_player/models.py | 15 +++++++ src/atv_player/storage.py | 83 ++++++++++++++++++++++++++++++++++++++- tests/test_storage.py | 64 ++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/atv_player/models.py b/src/atv_player/models.py index 43d5e68..0df9393 100644 --- a/src/atv_player/models.py +++ b/src/atv_player/models.py @@ -8,6 +8,18 @@ from atv_player.danmaku.models import DanmakuSourceGroup +def _default_network_proxy_bypass_rules() -> list[str]: + return [ + "localhost", + "127.0.0.1", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ".local", + ] + + @dataclass(slots=True) class AppConfig: base_url: str = "http://127.0.0.1:4567" @@ -18,6 +30,9 @@ class AppConfig: metadata_douban_cookie: str = "" metadata_tmdb_api_key: str = "" metadata_bangumi_access_token: str = "" + network_proxy_mode: str = "direct" + network_proxy_url: str = "" + network_proxy_bypass_rules: list[str] = field(default_factory=_default_network_proxy_bypass_rules) episode_title_enhancement_enabled: bool = True last_path: str = "/" last_active_window: str = "main" diff --git a/src/atv_player/storage.py b/src/atv_player/storage.py index aef4b55..d72db25 100644 --- a/src/atv_player/storage.py +++ b/src/atv_player/storage.py @@ -7,6 +7,16 @@ _VALID_DANMAKU_RENDER_MODES = {"static", "scroll_only", "mixed"} _VALID_DANMAKU_COLOR_MODES = {"uniform", "source"} _VALID_DANMAKU_POSITION_PRESETS = {"top", "upper", "mid_upper", "bottom"} +_VALID_NETWORK_PROXY_MODES = {"direct", "system", "http", "https", "socks5"} +_DEFAULT_NETWORK_PROXY_BYPASS_RULES = [ + "localhost", + "127.0.0.1", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ".local", +] def _normalize_danmaku_line_count(value: object) -> int: @@ -74,6 +84,36 @@ def _normalize_global_search_history(value: object) -> list[str]: return history[:10] +def _normalize_network_proxy_mode(value: object) -> str: + text = str(value or "").strip().lower() + return text if text in _VALID_NETWORK_PROXY_MODES else "direct" + + +def _normalize_network_proxy_url(value: object) -> str: + return str(value or "").strip() + + +def _normalize_network_proxy_bypass_rules(value: object) -> list[str]: + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return list(_DEFAULT_NETWORK_PROXY_BYPASS_RULES) + if value is None: + return list(_DEFAULT_NETWORK_PROXY_BYPASS_RULES) + if not isinstance(value, list): + return list(_DEFAULT_NETWORK_PROXY_BYPASS_RULES) + rules: list[str] = [] + seen: set[str] = set() + for item in value: + text = str(item or "").strip() + if not text or text in seen: + continue + rules.append(text) + seen.add(text) + return rules + + class SettingsRepository: def __init__(self, db_path: Path) -> None: self._db_path = Path(db_path) @@ -102,6 +142,9 @@ def _init_db(self) -> None: metadata_douban_cookie TEXT NOT NULL DEFAULT '', metadata_tmdb_api_key TEXT NOT NULL DEFAULT '', metadata_bangumi_access_token TEXT NOT NULL DEFAULT '', + network_proxy_mode TEXT NOT NULL DEFAULT 'direct', + network_proxy_url TEXT NOT NULL DEFAULT '', + network_proxy_bypass_rules TEXT NOT NULL DEFAULT '["localhost","127.0.0.1","::1","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16",".local"]', last_path TEXT NOT NULL, last_active_window TEXT NOT NULL DEFAULT 'main', last_playback_source TEXT NOT NULL DEFAULT 'browse', @@ -164,6 +207,18 @@ def _init_db(self) -> None: conn.execute( "ALTER TABLE app_config ADD COLUMN metadata_bangumi_access_token TEXT NOT NULL DEFAULT ''" ) + if "network_proxy_mode" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN network_proxy_mode TEXT NOT NULL DEFAULT 'direct'" + ) + if "network_proxy_url" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN network_proxy_url TEXT NOT NULL DEFAULT ''" + ) + if "network_proxy_bypass_rules" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN network_proxy_bypass_rules TEXT NOT NULL DEFAULT '[\"localhost\",\"127.0.0.1\",\"::1\",\"10.0.0.0/8\",\"172.16.0.0/12\",\"192.168.0.0/16\",\".local\"]'" + ) if "last_active_window" not in columns: conn.execute( "ALTER TABLE app_config ADD COLUMN last_active_window TEXT NOT NULL DEFAULT 'main'" @@ -248,6 +303,14 @@ def _init_db(self) -> None: conn.execute( "ALTER TABLE app_config ADD COLUMN preferred_danmaku_font_size INTEGER NOT NULL DEFAULT 32" ) + if "main_window_geometry" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN main_window_geometry BLOB" + ) + if "player_window_geometry" not in columns: + conn.execute( + "ALTER TABLE app_config ADD COLUMN player_window_geometry BLOB" + ) if "player_main_splitter_state" not in columns: conn.execute( "ALTER TABLE app_config ADD COLUMN player_main_splitter_state BLOB" @@ -289,6 +352,9 @@ def _init_db(self) -> None: metadata_douban_cookie, metadata_tmdb_api_key, metadata_bangumi_access_token, + network_proxy_mode, + network_proxy_url, + network_proxy_bypass_rules, last_path, last_active_window, last_playback_source, @@ -322,7 +388,7 @@ def _init_db(self) -> None: global_search_hot_source ) VALUES ( - 1, 'http://127.0.0.1:4567', '', '', '', 1, 1, '', '', '', '/', 'main', 'browse', '', '', '', '', '', + 1, 'http://127.0.0.1:4567', '', '', '', 1, 1, '', '', '', 'direct', '', '["localhost","127.0.0.1","::1","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16",".local"]', '/', 'main', 'browse', '', '', '', '', '', 0, 100, 0, 0, 1, '', 1, 1, 'static', 'source', '#FFFFFF', 'top', 1.0, 32, NULL, NULL, NULL, NULL, 'douban', '', '', '[]', '360' ) @@ -344,6 +410,9 @@ def load_config(self) -> AppConfig: metadata_douban_cookie, metadata_tmdb_api_key, metadata_bangumi_access_token, + network_proxy_mode, + network_proxy_url, + network_proxy_bypass_rules, last_path, last_active_window, last_playback_source, @@ -390,6 +459,9 @@ def load_config(self) -> AppConfig: metadata_douban_cookie, metadata_tmdb_api_key, metadata_bangumi_access_token, + network_proxy_mode, + network_proxy_url, + network_proxy_bypass_rules, last_path, last_active_window, last_playback_source, @@ -432,6 +504,9 @@ def load_config(self) -> AppConfig: metadata_douban_cookie=str(metadata_douban_cookie or "").strip(), metadata_tmdb_api_key=str(metadata_tmdb_api_key or "").strip(), metadata_bangumi_access_token=str(metadata_bangumi_access_token or "").strip(), + network_proxy_mode=_normalize_network_proxy_mode(network_proxy_mode), + network_proxy_url=_normalize_network_proxy_url(network_proxy_url), + network_proxy_bypass_rules=_normalize_network_proxy_bypass_rules(network_proxy_bypass_rules), last_path=last_path, last_active_window=last_active_window, last_playback_source=last_playback_source, @@ -480,6 +555,9 @@ def save_config(self, config: AppConfig) -> None: metadata_douban_cookie = ?, metadata_tmdb_api_key = ?, metadata_bangumi_access_token = ?, + network_proxy_mode = ?, + network_proxy_url = ?, + network_proxy_bypass_rules = ?, last_path = ?, last_active_window = ?, last_playback_source = ?, @@ -523,6 +601,9 @@ def save_config(self, config: AppConfig) -> None: str(config.metadata_douban_cookie or "").strip(), str(config.metadata_tmdb_api_key or "").strip(), str(config.metadata_bangumi_access_token or "").strip(), + _normalize_network_proxy_mode(config.network_proxy_mode), + _normalize_network_proxy_url(config.network_proxy_url), + json.dumps(_normalize_network_proxy_bypass_rules(config.network_proxy_bypass_rules), ensure_ascii=False), config.last_path, config.last_active_window, config.last_playback_source, diff --git a/tests/test_storage.py b/tests/test_storage.py index c6e976a..af07b42 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -386,6 +386,24 @@ def test_settings_repository_round_trip_persists_metadata_enhancement_toggle(tmp assert saved == config +def test_settings_repository_round_trip_persists_network_proxy_fields(tmp_path: Path) -> None: + db_path = tmp_path / "app.db" + repo = SettingsRepository(db_path) + + config = AppConfig( + network_proxy_mode="socks5", + network_proxy_url="socks5://user:pass@127.0.0.1:1080", + network_proxy_bypass_rules=["localhost", "127.0.0.1", "10.0.0.0/8"], + ) + + repo.save_config(config) + saved = repo.load_config() + + assert saved.network_proxy_mode == "socks5" + assert saved.network_proxy_url == "socks5://user:pass@127.0.0.1:1080" + assert saved.network_proxy_bypass_rules == ["localhost", "127.0.0.1", "10.0.0.0/8"] + + def test_settings_repository_round_trip_persists_episode_title_enhancement_toggle(tmp_path: Path) -> None: db_path = tmp_path / "app.db" repo = SettingsRepository(db_path) @@ -639,6 +657,52 @@ def test_settings_repository_migrates_missing_episode_title_enhancement_column(t assert config.episode_title_enhancement_enabled is True +def test_settings_repository_migrates_missing_network_proxy_columns(tmp_path: Path) -> None: + db_path = tmp_path / "app.db" + with sqlite3.connect(db_path) as conn: + conn.execute( + """ + CREATE TABLE app_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + base_url TEXT NOT NULL, + username TEXT NOT NULL, + token TEXT NOT NULL, + vod_token TEXT NOT NULL, + metadata_enhancement_enabled INTEGER NOT NULL DEFAULT 1, + episode_title_enhancement_enabled INTEGER NOT NULL DEFAULT 1, + metadata_douban_cookie TEXT NOT NULL DEFAULT '', + metadata_tmdb_api_key TEXT NOT NULL DEFAULT '', + metadata_bangumi_access_token TEXT NOT NULL DEFAULT '', + last_path TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + INSERT INTO app_config ( + id, base_url, username, token, vod_token, metadata_enhancement_enabled, + episode_title_enhancement_enabled, metadata_douban_cookie, + metadata_tmdb_api_key, metadata_bangumi_access_token, last_path + ) + VALUES (1, 'http://127.0.0.1:4567', '', '', '', 1, 1, '', '', '', '/') + """ + ) + + config = SettingsRepository(db_path).load_config() + + assert config.network_proxy_mode == "direct" + assert config.network_proxy_url == "" + assert config.network_proxy_bypass_rules == [ + "localhost", + "127.0.0.1", + "::1", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ".local", + ] + + def test_settings_repository_migrates_missing_global_search_hot_source_column(tmp_path: Path) -> None: db_path = tmp_path / "app.db" with sqlite3.connect(db_path) as conn: From 624eae952afc7dfba55af198ea55d7219a697554 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 17 May 2026 17:10:14 +0800 Subject: [PATCH 3/6] feat: add network proxy decision layer --- src/atv_player/network_proxy.py | 123 ++++++++++++++++++++++++++++++++ tests/test_network_proxy.py | 97 +++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/atv_player/network_proxy.py create mode 100644 tests/test_network_proxy.py diff --git a/src/atv_player/network_proxy.py b/src/atv_player/network_proxy.py new file mode 100644 index 0000000..f0d6806 --- /dev/null +++ b/src/atv_player/network_proxy.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import ipaddress +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class ProxyConfig: + mode: str = "direct" + proxy_url: str = "" + bypass_rules: list[str] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class ProxyDecision: + kind: str + proxy_url: str = "" + + +class ProxyRuleError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class _ParsedRule: + kind: str + value: object + + +class ProxyDecider: + def __init__(self, config: ProxyConfig) -> None: + self._config = ProxyConfig( + mode=str(config.mode or "").strip().lower() or "direct", + proxy_url=str(config.proxy_url or "").strip(), + bypass_rules=[str(rule or "").strip() for rule in config.bypass_rules if str(rule or "").strip()], + ) + self._rules = [self._parse_rule(rule) for rule in self._config.bypass_rules] + + def decide(self, target_url: str) -> ProxyDecision: + parsed = urlparse(str(target_url or "").strip()) + if parsed.scheme not in {"http", "https"}: + return ProxyDecision("direct") + host = (parsed.hostname or "").strip().lower() + if not host: + return ProxyDecision("direct") + if self._matches_bypass(host): + return ProxyDecision("direct") + if self._config.mode == "direct": + return ProxyDecision("direct") + if self._config.mode == "system": + return ProxyDecision("system") + return ProxyDecision("manual", self._config.proxy_url) + + def _matches_bypass(self, host: str) -> bool: + for rule in self._rules: + if rule.kind == "suffix" and host.endswith(str(rule.value)): + return True + if rule.kind == "exact" and host == str(rule.value): + return True + if rule.kind == "ip": + try: + ip = ipaddress.ip_address(host) + except ValueError: + continue + if ip == rule.value: + return True + if rule.kind == "network": + try: + ip = ipaddress.ip_address(host) + except ValueError: + continue + if ip in rule.value: + return True + return False + + def _parse_rule(self, rule: str) -> _ParsedRule: + normalized = str(rule or "").strip().lower() + if not normalized: + raise ProxyRuleError("empty rule") + if normalized.startswith("."): + return _ParsedRule("suffix", normalized) + if "/" in normalized: + try: + network = ipaddress.ip_network(normalized, strict=False) + except ValueError as exc: + raise ProxyRuleError(f"invalid cidr rule: {rule}") from exc + return _ParsedRule("network", network) + try: + return _ParsedRule("ip", ipaddress.ip_address(normalized)) + except ValueError: + return _ParsedRule("exact", normalized) + + +def build_httpx_kwargs_for_url(decider: ProxyDecider | None, target_url: str) -> dict[str, object]: + if decider is None: + return {} + decision = decider.decide(target_url) + if decision.kind == "direct": + return {"trust_env": False} + if decision.kind == "system": + return {"trust_env": True} + return {"proxy": decision.proxy_url, "trust_env": False} + + +def build_requests_proxies_for_url(decider: ProxyDecider | None, target_url: str) -> dict[str, str | None]: + if decider is None: + return {} + decision = decider.decide(target_url) + if decision.kind == "direct": + return {"http": None, "https": None} + if decision.kind == "manual": + return {"http": decision.proxy_url, "https": decision.proxy_url} + return {} + + +def build_ytdlp_proxy_args(decider: ProxyDecider | None, target_url: str) -> list[str]: + if decider is None: + return [] + decision = decider.decide(target_url) + if decision.kind != "manual": + return [] + return ["--proxy", decision.proxy_url] diff --git a/tests/test_network_proxy.py b/tests/test_network_proxy.py new file mode 100644 index 0000000..3c803ed --- /dev/null +++ b/tests/test_network_proxy.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest + +from atv_player.network_proxy import ( + ProxyConfig, + ProxyDecider, + ProxyRuleError, + build_httpx_kwargs_for_url, + build_requests_proxies_for_url, + build_ytdlp_proxy_args, +) + + +def test_proxy_decider_returns_direct_for_bypass_host() -> None: + decider = ProxyDecider( + ProxyConfig( + mode="socks5", + proxy_url="socks5://127.0.0.1:1080", + bypass_rules=["localhost", ".local", "10.0.0.0/8"], + ) + ) + + decision = decider.decide("http://10.12.0.5:4567/api/capabilities") + + assert decision.kind == "direct" + assert decision.proxy_url == "" + + +def test_proxy_decider_returns_manual_proxy_for_remote_url() -> None: + decider = ProxyDecider( + ProxyConfig( + mode="http", + proxy_url="http://user:pass@127.0.0.1:7890", + bypass_rules=["localhost"], + ) + ) + + decision = decider.decide("https://api.themoviedb.org/3/search/tv") + + assert decision.kind == "manual" + assert decision.proxy_url == "http://user:pass@127.0.0.1:7890" + + +def test_proxy_decider_returns_system_for_non_bypass_remote_url() -> None: + decider = ProxyDecider(ProxyConfig(mode="system", proxy_url="", bypass_rules=["127.0.0.1"])) + + decision = decider.decide("https://api.bgm.tv/v0/search/subjects") + + assert decision.kind == "system" + assert decision.proxy_url == "" + + +def test_proxy_decider_returns_direct_for_non_http_url() -> None: + decider = ProxyDecider( + ProxyConfig(mode="http", proxy_url="http://127.0.0.1:7890", bypass_rules=[]) + ) + + decision = decider.decide("file:///tmp/demo.mp4") + + assert decision.kind == "direct" + + +def test_build_httpx_kwargs_for_url_disables_env_for_bypass() -> None: + decider = ProxyDecider(ProxyConfig(mode="system", proxy_url="", bypass_rules=["127.0.0.1"])) + + assert build_httpx_kwargs_for_url(decider, "http://127.0.0.1:4567/api") == { + "trust_env": False, + } + + +def test_build_requests_proxies_for_url_applies_manual_proxy() -> None: + decider = ProxyDecider( + ProxyConfig(mode="https", proxy_url="https://127.0.0.1:8443", bypass_rules=[]) + ) + + assert build_requests_proxies_for_url(decider, "https://sec.example.com/check") == { + "http": "https://127.0.0.1:8443", + "https": "https://127.0.0.1:8443", + } + + +def test_build_ytdlp_proxy_args_skips_bypass_targets() -> None: + decider = ProxyDecider( + ProxyConfig( + mode="socks5", + proxy_url="socks5://127.0.0.1:1080", + bypass_rules=["youtu.be"], + ) + ) + + assert build_ytdlp_proxy_args(decider, "https://youtu.be/test123") == [] + + +def test_proxy_decider_rejects_invalid_cidr_rule() -> None: + with pytest.raises(ProxyRuleError): + ProxyDecider(ProxyConfig(mode="direct", proxy_url="", bypass_rules=["10.0.0.0/99"])) From 6ce6f6608691c15272cb7a0c6197bfeb237355aa Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 17 May 2026 17:12:12 +0800 Subject: [PATCH 4/6] feat: add proxy controls to advanced settings --- src/atv_player/ui/advanced_settings_dialog.py | 85 +++++++++++++++++- tests/test_main_window_ui.py | 86 +++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src/atv_player/ui/advanced_settings_dialog.py b/src/atv_player/ui/advanced_settings_dialog.py index d7d52e5..4cc1b0c 100644 --- a/src/atv_player/ui/advanced_settings_dialog.py +++ b/src/atv_player/ui/advanced_settings_dialog.py @@ -4,18 +4,23 @@ from PySide6.QtWidgets import ( QCheckBox, + QComboBox, QDialog, QFormLayout, QGroupBox, QHBoxLayout, + QLabel, QLineEdit, + QMessageBox, QPlainTextEdit, QPushButton, + QTabWidget, QVBoxLayout, QWidget, ) from atv_player.models import AppConfig +from atv_player.network_proxy import ProxyConfig, ProxyDecider, ProxyRuleError class AdvancedSettingsDialog(QDialog): @@ -29,8 +34,11 @@ def __init__( self._config = config self._save_config = save_config self.setWindowTitle("高级设置") - self.resize(640, 360) + self.resize(680, 440) + self.settings_tabs = QTabWidget() + self.metadata_tab = QWidget() + self.network_proxy_tab = QWidget() self.metadata_group = QGroupBox("元数据增强配置") self.metadata_enabled_checkbox = QCheckBox("启用元数据增强") self.episode_title_enhancement_checkbox = QCheckBox("启用剧集标题增强") @@ -40,6 +48,21 @@ def __init__( self.tmdb_api_key_edit.setPlaceholderText("填写 TMDB API Key") self.bangumi_access_token_edit = QLineEdit() self.bangumi_access_token_edit.setPlaceholderText("可选;留空时使用匿名访问") + self.network_proxy_group = QGroupBox("网络代理配置") + self.network_proxy_mode_combo = QComboBox() + self.network_proxy_mode_combo.addItem("直连", "direct") + self.network_proxy_mode_combo.addItem("系统代理", "system") + self.network_proxy_mode_combo.addItem("HTTP", "http") + self.network_proxy_mode_combo.addItem("HTTPS", "https") + self.network_proxy_mode_combo.addItem("SOCKS5", "socks5") + self.network_proxy_url_edit = QLineEdit() + self.network_proxy_url_edit.setPlaceholderText("例如 socks5://user:pass@127.0.0.1:1080") + self.network_proxy_bypass_rules_edit = QPlainTextEdit() + self.network_proxy_bypass_rules_edit.setPlaceholderText("一行一条,例如 localhost 或 10.0.0.0/8") + self.network_proxy_scope_label = QLabel( + "覆盖范围:API、元数据、解析源、弹幕、海报、插件下载、HLS 上游请求、yt-dlp" + ) + self.network_proxy_scope_label.setWordWrap(True) self.save_button = QPushButton("保存") self.cancel_button = QPushButton("取消") @@ -48,6 +71,11 @@ def __init__( self.douban_cookie_edit.setPlainText(config.metadata_douban_cookie) self.tmdb_api_key_edit.setText(config.metadata_tmdb_api_key) self.bangumi_access_token_edit.setText(config.metadata_bangumi_access_token) + self.network_proxy_mode_combo.setCurrentIndex( + max(0, self.network_proxy_mode_combo.findData(config.network_proxy_mode)) + ) + self.network_proxy_url_edit.setText(config.network_proxy_url) + self.network_proxy_bypass_rules_edit.setPlainText("\n".join(config.network_proxy_bypass_rules)) metadata_layout = QFormLayout() metadata_layout.addRow(self.metadata_enabled_checkbox) @@ -56,6 +84,22 @@ def __init__( metadata_layout.addRow("Bangumi Access Token", self.bangumi_access_token_edit) metadata_layout.addRow("豆瓣 Cookie", self.douban_cookie_edit) self.metadata_group.setLayout(metadata_layout) + metadata_tab_layout = QVBoxLayout(self.metadata_tab) + metadata_tab_layout.addWidget(self.metadata_group) + metadata_tab_layout.addStretch(1) + + network_proxy_layout = QFormLayout() + network_proxy_layout.addRow("代理模式", self.network_proxy_mode_combo) + network_proxy_layout.addRow("代理地址", self.network_proxy_url_edit) + network_proxy_layout.addRow("直连规则", self.network_proxy_bypass_rules_edit) + network_proxy_layout.addRow("覆盖范围", self.network_proxy_scope_label) + self.network_proxy_group.setLayout(network_proxy_layout) + network_proxy_tab_layout = QVBoxLayout(self.network_proxy_tab) + network_proxy_tab_layout.addWidget(self.network_proxy_group) + network_proxy_tab_layout.addStretch(1) + + self.settings_tabs.addTab(self.metadata_tab, "元数据") + self.settings_tabs.addTab(self.network_proxy_tab, "网络代理") button_row = QHBoxLayout() button_row.addStretch(1) @@ -63,13 +107,15 @@ def __init__( button_row.addWidget(self.cancel_button) layout = QVBoxLayout(self) - layout.addWidget(self.metadata_group) + layout.addWidget(self.settings_tabs) layout.addLayout(button_row) self.metadata_enabled_checkbox.toggled.connect(self._sync_metadata_inputs) + self.network_proxy_mode_combo.currentIndexChanged.connect(self._sync_network_proxy_inputs) self.save_button.clicked.connect(self._save) self.cancel_button.clicked.connect(self.reject) self._sync_metadata_inputs(self.metadata_enabled_checkbox.isChecked()) + self._sync_network_proxy_inputs() def _sync_metadata_inputs(self, enabled: bool) -> None: self.episode_title_enhancement_checkbox.setEnabled(enabled) @@ -77,11 +123,46 @@ def _sync_metadata_inputs(self, enabled: bool) -> None: self.tmdb_api_key_edit.setEnabled(enabled) self.bangumi_access_token_edit.setEnabled(enabled) + def _sync_network_proxy_inputs(self) -> None: + manual_mode = self.network_proxy_mode_combo.currentData() in {"http", "https", "socks5"} + self.network_proxy_url_edit.setEnabled(manual_mode) + + def _validated_network_proxy_values(self) -> tuple[str, str, list[str]] | None: + mode = str(self.network_proxy_mode_combo.currentData() or "direct") + proxy_url = self.network_proxy_url_edit.text().strip() + bypass_rules = [ + line.strip() + for line in self.network_proxy_bypass_rules_edit.toPlainText().splitlines() + if line.strip() + ] + if mode in {"http", "https", "socks5"} and not proxy_url: + QMessageBox.warning(self, "代理地址无效", "手动代理模式需要填写代理地址") + return None + scheme_errors = { + "http": "HTTP 模式要求 http:// 代理地址", + "https": "HTTPS 模式要求 https:// 代理地址", + "socks5": "SOCKS5 模式要求 socks5:// 代理地址", + } + expected_prefix = f"{mode}://" + if mode in scheme_errors and proxy_url and not proxy_url.startswith(expected_prefix): + QMessageBox.warning(self, "代理地址无效", scheme_errors[mode]) + return None + try: + ProxyDecider(ProxyConfig(mode="direct", proxy_url="", bypass_rules=bypass_rules)) + except ProxyRuleError as exc: + QMessageBox.warning(self, "直连规则无效", str(exc)) + return None + return mode, proxy_url, bypass_rules + def _save(self) -> None: + proxy_values = self._validated_network_proxy_values() + if proxy_values is None: + return self._config.metadata_enhancement_enabled = self.metadata_enabled_checkbox.isChecked() self._config.episode_title_enhancement_enabled = self.episode_title_enhancement_checkbox.isChecked() self._config.metadata_douban_cookie = self.douban_cookie_edit.toPlainText().strip() self._config.metadata_tmdb_api_key = self.tmdb_api_key_edit.text().strip() self._config.metadata_bangumi_access_token = self.bangumi_access_token_edit.text().strip() + self._config.network_proxy_mode, self._config.network_proxy_url, self._config.network_proxy_bypass_rules = proxy_values self._save_config() self.accept() diff --git a/tests/test_main_window_ui.py b/tests/test_main_window_ui.py index 6c8e236..60755b2 100644 --- a/tests/test_main_window_ui.py +++ b/tests/test_main_window_ui.py @@ -3516,6 +3516,92 @@ def test_advanced_settings_dialog_saves_trimmed_values(qtbot) -> None: assert len(saved) == 1 +def test_advanced_settings_dialog_loads_network_proxy_values(qtbot) -> None: + from atv_player.ui.advanced_settings_dialog import AdvancedSettingsDialog + + config = AppConfig( + network_proxy_mode="socks5", + network_proxy_url="socks5://user:pass@127.0.0.1:1080", + network_proxy_bypass_rules=["localhost", "127.0.0.1"], + ) + dialog = AdvancedSettingsDialog(config, save_config=lambda: None) + qtbot.addWidget(dialog) + + assert dialog.settings_tabs.tabText(0) == "元数据" + assert dialog.settings_tabs.tabText(1) == "网络代理" + assert dialog.network_proxy_mode_combo.currentData() == "socks5" + assert dialog.network_proxy_url_edit.text() == "socks5://user:pass@127.0.0.1:1080" + assert dialog.network_proxy_bypass_rules_edit.toPlainText() == "localhost\n127.0.0.1" + + +def test_advanced_settings_dialog_disables_proxy_url_for_system_mode(qtbot) -> None: + from atv_player.ui.advanced_settings_dialog import AdvancedSettingsDialog + + dialog = AdvancedSettingsDialog(AppConfig(network_proxy_mode="system"), save_config=lambda: None) + qtbot.addWidget(dialog) + + assert dialog.network_proxy_url_edit.isEnabled() is False + + +def test_advanced_settings_dialog_toggles_proxy_url_enabled_state(qtbot) -> None: + from atv_player.ui.advanced_settings_dialog import AdvancedSettingsDialog + + dialog = AdvancedSettingsDialog(AppConfig(network_proxy_mode="direct"), save_config=lambda: None) + qtbot.addWidget(dialog) + + assert dialog.network_proxy_url_edit.isEnabled() is False + + dialog.network_proxy_mode_combo.setCurrentIndex(dialog.network_proxy_mode_combo.findData("http")) + + assert dialog.network_proxy_url_edit.isEnabled() is True + + dialog.network_proxy_mode_combo.setCurrentIndex(dialog.network_proxy_mode_combo.findData("system")) + + assert dialog.network_proxy_url_edit.isEnabled() is False + + +def test_advanced_settings_dialog_saves_trimmed_network_proxy_values(qtbot) -> None: + from atv_player.ui.advanced_settings_dialog import AdvancedSettingsDialog + + saved: list[AppConfig] = [] + config = AppConfig() + dialog = AdvancedSettingsDialog(config, save_config=lambda: saved.append(config)) + qtbot.addWidget(dialog) + + dialog.network_proxy_mode_combo.setCurrentIndex(dialog.network_proxy_mode_combo.findData("socks5")) + dialog.network_proxy_url_edit.setText(" socks5://user:pass@127.0.0.1:1080 ") + dialog.network_proxy_bypass_rules_edit.setPlainText(" localhost \n127.0.0.1\n\n") + dialog._save() + + assert config.network_proxy_mode == "socks5" + assert config.network_proxy_url == "socks5://user:pass@127.0.0.1:1080" + assert config.network_proxy_bypass_rules == ["localhost", "127.0.0.1"] + assert len(saved) == 1 + + +def test_advanced_settings_dialog_rejects_invalid_proxy_url(qtbot, monkeypatch) -> None: + from atv_player.ui import advanced_settings_dialog as module + from atv_player.ui.advanced_settings_dialog import AdvancedSettingsDialog + + messages: list[str] = [] + + def fake_warning(_parent, _title: str, text: str) -> int: + messages.append(text) + return 0 + + monkeypatch.setattr(module.QMessageBox, "warning", fake_warning) + saved: list[AppConfig] = [] + dialog = AdvancedSettingsDialog(AppConfig(), save_config=lambda: saved.append(dialog._config)) + qtbot.addWidget(dialog) + + dialog.network_proxy_mode_combo.setCurrentIndex(dialog.network_proxy_mode_combo.findData("socks5")) + dialog.network_proxy_url_edit.setText("http://127.0.0.1:7890") + dialog._save() + + assert messages == ["SOCKS5 模式要求 socks5:// 代理地址"] + assert saved == [] + + def test_advanced_settings_dialog_loads_episode_title_enhancement_checkbox(qtbot) -> None: from atv_player.ui.advanced_settings_dialog import AdvancedSettingsDialog From 80558747d741bc62924e37e92b2f86f5483a355e Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 17 May 2026 17:15:27 +0800 Subject: [PATCH 5/6] feat: apply proxy settings to metadata and api clients --- src/atv_player/api.py | 8 ++- src/atv_player/app.py | 44 +++++++++++++++-- .../metadata/providers/bangumi_client.py | 19 ++++++- .../metadata/providers/local_douban_client.py | 9 +++- .../metadata/providers/tmdb_client.py | 9 +++- tests/test_api_client.py | 49 +++++++++++++++++++ tests/test_local_douban_client.py | 25 ++++++++++ tests/test_metadata_tmdb_client.py | 21 ++++++++ 8 files changed, 174 insertions(+), 10 deletions(-) diff --git a/src/atv_player/api.py b/src/atv_player/api.py index daed57b..80c7950 100644 --- a/src/atv_player/api.py +++ b/src/atv_player/api.py @@ -1,11 +1,13 @@ from __future__ import annotations import logging +from collections.abc import Callable from typing import Any import httpx from atv_player.models import HistoryRecord +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url class ApiError(RuntimeError): @@ -26,15 +28,19 @@ def __init__( token: str = "", vod_token: str = "", transport: httpx.BaseTransport | None = None, + proxy_decider: ProxyDecider | None = None, + client_factory: Callable[..., httpx.Client] = httpx.Client, ) -> None: headers = {"Authorization": token} if token else {} self._vod_token = vod_token - self._client = httpx.Client( + client_kwargs: dict[str, Any] = dict( base_url=base_url.rstrip("/"), headers=headers, transport=transport, timeout=30.0, ) + client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, base_url)) + self._client = client_factory(**client_kwargs) def set_token(self, token: str) -> None: if token: diff --git a/src/atv_player/app.py b/src/atv_player/app.py index 641519c..9f474dd 100644 --- a/src/atv_player/app.py +++ b/src/atv_player/app.py @@ -63,6 +63,7 @@ from atv_player.metadata.providers.tmdb import TMDBProvider, infer_tmdb_media_type from atv_player.metadata.providers.tmdb_client import TMDBClient from atv_player.models import AppConfig, LiveEpgConfig, PlayItem, VodItem +from atv_player.network_proxy import ProxyConfig, ProxyDecider from atv_player.paths import app_cache_dir, app_data_dir from atv_player.live_source_repository import LiveSourceRepository from atv_player.plugins import SpiderPluginLoader, SpiderPluginManager @@ -348,6 +349,16 @@ def _close_api_client(self) -> None: close_client() self._api_client = None + def _build_proxy_decider(self) -> ProxyDecider: + config = self.repo.load_config() + return ProxyDecider( + ProxyConfig( + mode=config.network_proxy_mode, + proxy_url=config.network_proxy_url, + bypass_rules=list(config.network_proxy_bypass_rules), + ) + ) + def start(self) -> QWidget: config = self.repo.load_config() logger.info("App start view=%s", decide_start_view(config)) @@ -356,6 +367,7 @@ def start(self) -> QWidget: config.base_url, token=config.token, vod_token=config.vod_token, + proxy_decider=self._build_proxy_decider(), ) try: self._ensure_vod_token(self._api_client) @@ -375,6 +387,7 @@ def _build_api_client(self) -> ApiClient: config.base_url, token=config.token, vod_token=config.vod_token, + proxy_decider=self._build_proxy_decider(), ) self._ensure_vod_token(api_client) return api_client @@ -441,19 +454,37 @@ def _build_metadata_providers( raw_detail=None, ) -> list[object]: providers: list[object] = [] + proxy_decider = self._build_proxy_decider() if source_kind == "plugin": plugin_payload = self._build_plugin_metadata_payload(raw_detail) if plugin_payload is not None: providers.append(CustomPluginProvider(plugin_payload)) - providers.append(BangumiMetadataProvider(BangumiClient(access_token=config.metadata_bangumi_access_token))) + providers.append( + BangumiMetadataProvider( + BangumiClient( + access_token=config.metadata_bangumi_access_token, + proxy_decider=proxy_decider, + ) + ) + ) providers.append(BilibiliMetadataProvider()) providers.append(IqiyiMetadataProvider()) providers.append(TencentMetadataProvider()) if str(config.metadata_douban_cookie or "").strip(): - local_douban_client = LocalDoubanClient(cookie=config.metadata_douban_cookie) + local_douban_client = LocalDoubanClient( + cookie=config.metadata_douban_cookie, + proxy_decider=proxy_decider, + ) providers.append(OfficialDoubanProvider(local_douban_client)) if str(config.metadata_tmdb_api_key or "").strip(): - providers.append(TMDBProvider(TMDBClient(api_key=config.metadata_tmdb_api_key))) + providers.append( + TMDBProvider( + TMDBClient( + api_key=config.metadata_tmdb_api_key, + proxy_decider=proxy_decider, + ) + ) + ) providers.append(LocalDoubanProvider(api_client)) return providers @@ -789,7 +820,10 @@ def factory(*, request=None, source_kind: str = "", source_key: str = "", vod=No query = MetadataContext(vod=vod, source_kind=source_kind).to_query() if infer_tmdb_media_type(query) == "movie": return None - tmdb_client = TMDBClient(api_key=config.metadata_tmdb_api_key) + tmdb_client = TMDBClient( + api_key=config.metadata_tmdb_api_key, + proxy_decider=self._build_proxy_decider(), + ) def enhance(session) -> list | None: session_vod = getattr(session, "vod", None) or vod @@ -953,7 +987,7 @@ def _show_login(self, error_message: str = "") -> LoginWindow: self._close_api_client() login_controller = LoginController( self.repo, - lambda base_url: ApiClient(base_url), + lambda base_url: ApiClient(base_url, proxy_decider=self._build_proxy_decider()), ) self.login_window = LoginWindow(login_controller) if error_message and hasattr(self.login_window, "set_error_message"): diff --git a/src/atv_player/metadata/providers/bangumi_client.py b/src/atv_player/metadata/providers/bangumi_client.py index 0af2071..aac4908 100644 --- a/src/atv_player/metadata/providers/bangumi_client.py +++ b/src/atv_player/metadata/providers/bangumi_client.py @@ -1,17 +1,32 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import httpx +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url + class BangumiClient: _BASE_URL = "https://api.bgm.tv" _USER_AGENT = "ATVPlayer/1.0 (metadata integration)" - def __init__(self, access_token: str = "", transport: httpx.BaseTransport | None = None) -> None: + def __init__( + self, + access_token: str = "", + transport: httpx.BaseTransport | None = None, + proxy_decider: ProxyDecider | None = None, + client_factory: Callable[..., httpx.Client] = httpx.Client, + ) -> None: self._access_token = str(access_token or "").strip() - self._client = httpx.Client(base_url=self._BASE_URL, transport=transport, timeout=10.0) + client_kwargs: dict[str, Any] = dict( + base_url=self._BASE_URL, + transport=transport, + timeout=10.0, + ) + client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, self._BASE_URL)) + self._client = client_factory(**client_kwargs) def _headers(self) -> dict[str, str]: headers = {"User-Agent": self._USER_AGENT} diff --git a/src/atv_player/metadata/providers/local_douban_client.py b/src/atv_player/metadata/providers/local_douban_client.py index 42389f2..297dcf9 100644 --- a/src/atv_player/metadata/providers/local_douban_client.py +++ b/src/atv_player/metadata/providers/local_douban_client.py @@ -1,11 +1,14 @@ from __future__ import annotations +from collections.abc import Callable import json import re from html import unescape import httpx +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url + class DoubanBlockedError(RuntimeError): pass @@ -23,13 +26,17 @@ def __init__( self, cookie: str = "", transport: httpx.BaseTransport | None = None, + proxy_decider: ProxyDecider | None = None, + client_factory: Callable[..., httpx.Client] = httpx.Client, ) -> None: self._cookie = cookie.strip() - self._client = httpx.Client( + client_kwargs = dict( transport=transport, timeout=15.0, follow_redirects=True, ) + client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, self._SEARCH_URL)) + self._client = client_factory(**client_kwargs) def _headers(self) -> dict[str, str]: headers = { diff --git a/src/atv_player/metadata/providers/tmdb_client.py b/src/atv_player/metadata/providers/tmdb_client.py index 91c6b1f..a0cf2e3 100644 --- a/src/atv_player/metadata/providers/tmdb_client.py +++ b/src/atv_player/metadata/providers/tmdb_client.py @@ -1,9 +1,12 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import httpx +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url + class TMDBClient: _BASE_URL = "https://api.themoviedb.org/3" @@ -12,13 +15,17 @@ def __init__( self, api_key: str, transport: httpx.BaseTransport | None = None, + proxy_decider: ProxyDecider | None = None, + client_factory: Callable[..., httpx.Client] = httpx.Client, ) -> None: self._api_key = str(api_key or "").strip() - self._client = httpx.Client( + client_kwargs: dict[str, Any] = dict( base_url=self._BASE_URL, transport=transport, timeout=20.0, ) + client_kwargs.update(build_httpx_kwargs_for_url(proxy_decider, self._BASE_URL)) + self._client = client_factory(**client_kwargs) self._image_config: dict[str, Any] | None = None def _request(self, path: str, **params: object) -> dict[str, Any]: diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 699f3df..acce7f1 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -5,6 +5,7 @@ from atv_player.api import ApiClient, ApiError, UnauthorizedError from atv_player.models import HistoryRecord +from atv_player.network_proxy import ProxyConfig, ProxyDecider class RaisingTransport(httpx.BaseTransport): @@ -15,6 +16,54 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: raise self.exc +def test_api_client_builds_direct_httpx_client_for_local_base_url() -> None: + captured: dict[str, object] = {} + + def fake_client_factory(**kwargs): + captured.update(kwargs) + return httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))) + + client = ApiClient( + base_url="http://127.0.0.1:4567", + proxy_decider=ProxyDecider( + ProxyConfig( + mode="socks5", + proxy_url="socks5://127.0.0.1:1080", + bypass_rules=["127.0.0.1"], + ) + ), + client_factory=fake_client_factory, + ) + + assert captured["trust_env"] is False + assert "proxy" not in captured + client.close() + + +def test_api_client_builds_manual_proxy_httpx_client_for_remote_base_url() -> None: + captured: dict[str, object] = {} + + def fake_client_factory(**kwargs): + captured.update(kwargs) + return httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={}))) + + client = ApiClient( + base_url="https://demo.remote.example", + proxy_decider=ProxyDecider( + ProxyConfig( + mode="http", + proxy_url="http://127.0.0.1:7890", + bypass_rules=[], + ) + ), + client_factory=fake_client_factory, + ) + + assert captured["proxy"] == "http://127.0.0.1:7890" + assert captured["trust_env"] is False + client.close() + + def test_api_client_attaches_authorization_header() -> None: seen_headers: dict[str, str] = {} diff --git a/tests/test_local_douban_client.py b/tests/test_local_douban_client.py index 5a249ae..a666931 100644 --- a/tests/test_local_douban_client.py +++ b/tests/test_local_douban_client.py @@ -2,6 +2,31 @@ import pytest from atv_player.metadata.providers.local_douban_client import DoubanBlockedError, LocalDoubanClient +from atv_player.network_proxy import ProxyConfig, ProxyDecider + + +def test_local_douban_client_builds_direct_httpx_client_for_bypass() -> None: + captured: dict[str, object] = {} + + def fake_client_factory(**kwargs): + captured.update(kwargs) + return httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, text="[]"))) + + client = LocalDoubanClient( + cookie="bid=demo;", + proxy_decider=ProxyDecider( + ProxyConfig( + mode="socks5", + proxy_url="socks5://127.0.0.1:1080", + bypass_rules=["movie.douban.com"], + ) + ), + client_factory=fake_client_factory, + ) + + assert captured["trust_env"] is False + assert "proxy" not in captured + client._client.close() def test_local_douban_client_raises_when_html_matches_block_markers() -> None: diff --git a/tests/test_metadata_tmdb_client.py b/tests/test_metadata_tmdb_client.py index db87398..df4d51f 100644 --- a/tests/test_metadata_tmdb_client.py +++ b/tests/test_metadata_tmdb_client.py @@ -1,6 +1,27 @@ import httpx from atv_player.metadata.providers.tmdb_client import TMDBClient +from atv_player.network_proxy import ProxyConfig, ProxyDecider + + +def test_tmdb_client_builds_manual_proxy_httpx_client() -> None: + captured: dict[str, object] = {} + + def fake_client_factory(**kwargs): + captured.update(kwargs) + return httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"results": []}))) + + client = TMDBClient( + api_key="tmdb-key", + proxy_decider=ProxyDecider( + ProxyConfig(mode="http", proxy_url="http://127.0.0.1:7890", bypass_rules=[]) + ), + client_factory=fake_client_factory, + ) + + assert captured["proxy"] == "http://127.0.0.1:7890" + assert captured["trust_env"] is False + client._client.close() def test_tmdb_client_search_movie_sends_api_key_language_and_year() -> None: From 4aa551e9d971c71ba5f26a2568360e4897183573 Mon Sep 17 00:00:00 2001 From: Har01d Date: Sun, 17 May 2026 17:29:29 +0800 Subject: [PATCH 6/6] feat: route proxy settings through runtime fetchers --- src/atv_player/app.py | 60 +++++++++-- src/atv_player/danmaku/direct_parse.py | 11 +- src/atv_player/danmaku/service.py | 13 +-- src/atv_player/playback_parsers.py | 5 + src/atv_player/player/ytdlp_runtime.py | 4 +- src/atv_player/plugins/compat/base/spider.py | 17 ++++ src/atv_player/plugins/loader.py | 17 +++- src/atv_player/proxy/segment.py | 6 ++ src/atv_player/ui/poster_loader.py | 18 ++++ src/atv_player/yt_dlp_service.py | 5 +- tests/test_direct_parse_danmaku.py | 31 ++++++ tests/test_hls_proxy_segment.py | 35 +++++++ tests/test_playback_parsers.py | 29 ++++++ tests/test_poster_loader.py | 30 ++++++ tests/test_spider_plugin_loader.py | 101 +++++++++++++++++++ tests/test_yt_dlp_service.py | 39 +++++++ 16 files changed, 401 insertions(+), 20 deletions(-) diff --git a/src/atv_player/app.py b/src/atv_player/app.py index 9f474dd..676a321 100644 --- a/src/atv_player/app.py +++ b/src/atv_player/app.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from dataclasses import replace import gc +import httpx import inspect import threading import time @@ -63,16 +64,19 @@ from atv_player.metadata.providers.tmdb import TMDBProvider, infer_tmdb_media_type from atv_player.metadata.providers.tmdb_client import TMDBClient from atv_player.models import AppConfig, LiveEpgConfig, PlayItem, VodItem -from atv_player.network_proxy import ProxyConfig, ProxyDecider +from atv_player.network_proxy import ProxyConfig, ProxyDecider, build_httpx_kwargs_for_url from atv_player.paths import app_cache_dir, app_data_dir from atv_player.live_source_repository import LiveSourceRepository from atv_player.plugins import SpiderPluginLoader, SpiderPluginManager +from atv_player.plugins.compat.base.spider import set_proxy_decider_loader as set_spider_proxy_decider_loader from atv_player.plugins.repository import SpiderPluginRepository from atv_player.playback_parsers import BuiltInPlaybackParserService from atv_player.player.m3u8_ad_filter import M3U8AdFilter +from atv_player.proxy.server import LocalHlsProxyServer from atv_player.yt_dlp_service import YtdlpPlaybackService from atv_player.storage import SettingsRepository from atv_player.time_utils import is_refresh_stale +from atv_player.ui.poster_loader import set_proxy_decider_loader from atv_player.ui.login_window import LoginWindow from atv_player.ui.main_window import MainWindow, load_direct_parse_detail from atv_player.ui.icon_cache import load_icon @@ -299,17 +303,31 @@ def __init__(self, repo: SettingsRepository) -> None: self.login_window: LoginWindow | None = None self.main_window: MainWindow | None = None self._api_client: ApiClient | None = None - self._m3u8_ad_filter = M3U8AdFilter() - self._playback_parser_service = BuiltInPlaybackParserService() - self._yt_dlp_service = YtdlpPlaybackService() - self._danmaku_service = create_default_danmaku_service() + set_proxy_decider_loader(self._build_proxy_decider) + set_spider_proxy_decider_loader(self._build_proxy_decider) + self._m3u8_ad_filter = M3U8AdFilter( + proxy_server=LocalHlsProxyServer( + get=self._proxy_http_get(), + stream=self._proxy_http_stream(), + ), + get=self._proxy_http_get(), + ) + self._playback_parser_service = BuiltInPlaybackParserService( + get=self._proxy_http_get(), + post=self._proxy_http_post(), + ) + self._yt_dlp_service = YtdlpPlaybackService(proxy_decider=self._build_proxy_decider()) + self._danmaku_service = create_default_danmaku_service( + get=self._proxy_http_get(), + post=self._proxy_http_post(), + ) if hasattr(repo, "database_path"): self._live_source_repository = LiveSourceRepository(repo.database_path) self._live_epg_repository = LiveEpgRepository(repo.database_path) self._plugin_repository = SpiderPluginRepository(repo.database_path) self._playback_history_repository = LocalPlaybackHistoryRepository(repo.database_path) cache_dir = app_cache_dir() / "plugins" - self._plugin_loader = SpiderPluginLoader(cache_dir) + self._plugin_loader = SpiderPluginLoader(cache_dir, get=self._proxy_http_get()) self._plugin_manager = SpiderPluginManager( self._plugin_repository, self._plugin_loader, @@ -359,6 +377,30 @@ def _build_proxy_decider(self) -> ProxyDecider: ) ) + def _proxy_http_get(self): + def run(url: str, **kwargs): + request_kwargs = dict(kwargs) + request_kwargs.update(build_httpx_kwargs_for_url(self._build_proxy_decider(), url)) + return httpx.get(url, **request_kwargs) + + return run + + def _proxy_http_post(self): + def run(url: str, **kwargs): + request_kwargs = dict(kwargs) + request_kwargs.update(build_httpx_kwargs_for_url(self._build_proxy_decider(), url)) + return httpx.post(url, **request_kwargs) + + return run + + def _proxy_http_stream(self): + def run(method: str, url: str, **kwargs): + request_kwargs = dict(kwargs) + request_kwargs.update(build_httpx_kwargs_for_url(self._build_proxy_decider(), url)) + return httpx.stream(method, url, **request_kwargs) + + return run + def start(self) -> QWidget: config = self.repo.load_config() logger.info("App start view=%s", decide_start_view(config)) @@ -1179,7 +1221,11 @@ def plugin_loader_task(): drive_detail_loader=drive_detail_loader, offline_download_detail_loader=offline_download_detail_loader, direct_parse_detail_loader=load_direct_parse_detail, - direct_parse_danmaku_loader=load_direct_parse_danmaku, + direct_parse_danmaku_loader=lambda url: load_direct_parse_danmaku( + url, + get=self._proxy_http_get(), + proxy_decider=self._build_proxy_decider(), + ), direct_parse_playback_history_loader=None if self._playback_history_repository is None else lambda vod_id: self._playback_history_repository.get_history("direct_parse", vod_id), diff --git a/src/atv_player/danmaku/direct_parse.py b/src/atv_player/danmaku/direct_parse.py index be1fdbc..895963d 100644 --- a/src/atv_player/danmaku/direct_parse.py +++ b/src/atv_player/danmaku/direct_parse.py @@ -10,18 +10,24 @@ from atv_player.danmaku.cache import load_cached_danmaku_xml, save_cached_danmaku_xml from atv_player.danmaku.models import DanmakuSourceGroup, DanmakuSourceOption, DanmakuSourceSearchResult from atv_player.models import PlayItem +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url _DIRECT_PARSE_DANMAKU_API = "https://dmku.hls.one/" _DIRECT_PARSE_PROVIDER = "direct_parse" _DIRECT_PARSE_PROVIDER_LABEL = "全局解析" -def load_direct_parse_danmaku(url: str) -> dict[str, Any]: - response = httpx.get( +def load_direct_parse_danmaku( + url: str, + get=httpx.get, + proxy_decider: ProxyDecider | None = None, +) -> dict[str, Any]: + response = get( _DIRECT_PARSE_DANMAKU_API, params={"ac": "dm", "url": url}, timeout=10.0, follow_redirects=True, + **build_httpx_kwargs_for_url(proxy_decider, _DIRECT_PARSE_DANMAKU_API), ) response.raise_for_status() payload = response.json() @@ -165,4 +171,3 @@ def _danmaku_color(self, value: object) -> int: return int(text, 16) except ValueError: return 16777215 - diff --git a/src/atv_player/danmaku/service.py b/src/atv_player/danmaku/service.py index 37a9f9e..2bc314a 100644 --- a/src/atv_player/danmaku/service.py +++ b/src/atv_player/danmaku/service.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import replace +import httpx import logging import re @@ -728,12 +729,12 @@ def resolve_danmu(self, page_url: str, option: DanmakuSourceOption | None = None raise ProviderNotSupportedError(f"不支持的弹幕来源: {page_url}") -def create_default_danmaku_service() -> DanmakuService: +def create_default_danmaku_service(get=httpx.get, post=httpx.post) -> DanmakuService: providers = { - "tencent": TencentDanmakuProvider(), - "youku": YoukuDanmakuProvider(), - "bilibili": BilibiliDanmakuProvider(), - "iqiyi": IqiyiDanmakuProvider(), - "mgtv": MgtvDanmakuProvider(), + "tencent": TencentDanmakuProvider(get=get, post=post), + "youku": YoukuDanmakuProvider(get=get, post=post), + "bilibili": BilibiliDanmakuProvider(get=get), + "iqiyi": IqiyiDanmakuProvider(get=get), + "mgtv": MgtvDanmakuProvider(get=get), } return DanmakuService(providers, provider_order=["tencent", "youku", "bilibili", "iqiyi", "mgtv"]) diff --git a/src/atv_player/playback_parsers.py b/src/atv_player/playback_parsers.py index b36b3fe..aba1ec4 100644 --- a/src/atv_player/playback_parsers.py +++ b/src/atv_player/playback_parsers.py @@ -13,6 +13,7 @@ from Crypto.Cipher import AES from Crypto.Util.Padding import unpad +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url from atv_player.player.resolve_cache import PlaybackResolveCache, ResolveCacheValue @@ -75,10 +76,12 @@ def __init__( get: Callable[..., httpx.Response] = httpx.get, post: Callable[..., httpx.Response] = httpx.post, resolve_cache: PlaybackResolveCache | None = None, + proxy_decider: ProxyDecider | None = None, ) -> None: self._get = get self._post = post self._resolve_cache = resolve_cache or PlaybackResolveCache() + self._proxy_decider = proxy_decider self._parsers = [ BuiltInPlaybackParser( key="xm", @@ -184,6 +187,7 @@ def _resolve_with_parser(self, parser: BuiltInPlaybackParser, flag: str, url: st headers=dict(parser.headers), timeout=15.0, follow_redirects=True, + **build_httpx_kwargs_for_url(self._proxy_decider, parser.api), ) payload = response.json() media_url = _normalize_media_url(str(payload.get("url") or "")) @@ -216,6 +220,7 @@ def _resolve_xm(self, parser: BuiltInPlaybackParser, url: str) -> BuiltInPlaybac headers=headers, timeout=15.0, follow_redirects=True, + **build_httpx_kwargs_for_url(self._proxy_decider, "https://api.hls.one:4433/Api"), ) payload = response.json() encrypted = str(payload.get("data") or "").strip() diff --git a/src/atv_player/player/ytdlp_runtime.py b/src/atv_player/player/ytdlp_runtime.py index 9bbb4bc..1a4cb1d 100644 --- a/src/atv_player/player/ytdlp_runtime.py +++ b/src/atv_player/player/ytdlp_runtime.py @@ -90,8 +90,10 @@ def resolve_mpv_ytdlp_path() -> str: return resolve_system_ytdlp_path() -def build_ytdlp_command_args() -> list[str]: +def build_ytdlp_command_args(proxy_args: list[str] | None = None) -> list[str]: args: list[str] = [] + if proxy_args: + args.extend(proxy_args) browser = _resolved_cookie_browser() if browser: args.extend(["--cookies-from-browser", browser]) diff --git a/src/atv_player/plugins/compat/base/spider.py b/src/atv_player/plugins/compat/base/spider.py index cae2f41..5665527 100644 --- a/src/atv_player/plugins/compat/base/spider.py +++ b/src/atv_player/plugins/compat/base/spider.py @@ -4,13 +4,17 @@ import re import time from abc import ABCMeta +from collections.abc import Callable from hashlib import sha256 from pathlib import Path import requests from lxml import etree +from atv_player.network_proxy import ProxyDecider, build_requests_proxies_for_url + _CACHE_ROOT = Path.home() / ".cache" / "atv-player" / "plugins" / "spider-cache" +_proxy_decider_loader: Callable[[], ProxyDecider | None] | None = None def set_cache_root(path: Path | str) -> None: @@ -19,6 +23,17 @@ def set_cache_root(path: Path | str) -> None: _CACHE_ROOT.mkdir(parents=True, exist_ok=True) +def set_proxy_decider_loader(loader: Callable[[], ProxyDecider | None] | None) -> None: + global _proxy_decider_loader + _proxy_decider_loader = loader + + +def _effective_proxy_decider() -> ProxyDecider | None: + if _proxy_decider_loader is None: + return None + return _proxy_decider_loader() + + def _cache_path(key: str) -> Path: _CACHE_ROOT.mkdir(parents=True, exist_ok=True) return _CACHE_ROOT / f"{sha256(key.encode('utf-8')).hexdigest()}.cache" @@ -92,6 +107,7 @@ def fetch( verify=verify, stream=stream, allow_redirects=allow_redirects, + proxies=build_requests_proxies_for_url(_effective_proxy_decider(), url), ) response.encoding = "utf-8" return _buffer_and_close_response(response) @@ -120,6 +136,7 @@ def post( verify=verify, stream=stream, allow_redirects=allow_redirects, + proxies=build_requests_proxies_for_url(_effective_proxy_decider(), url), ) response.encoding = "utf-8" return _buffer_and_close_response(response) diff --git a/src/atv_player/plugins/loader.py b/src/atv_player/plugins/loader.py index 2e7a8ef..af4b76c 100644 --- a/src/atv_player/plugins/loader.py +++ b/src/atv_player/plugins/loader.py @@ -10,6 +10,7 @@ import httpx from atv_player.models import SpiderPluginConfig +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url import atv_player.plugins.compat.base.spider as compat_spider_module from atv_player.plugins.compat.base.spider import Spider as CompatSpider from atv_player.plugins.spider_crypto.errors import ( @@ -36,12 +37,19 @@ class LoadedSpiderPlugin: class SpiderPluginLoader: - def __init__(self, cache_dir: Path, get=httpx.get, keyring=None) -> None: + def __init__( + self, + cache_dir: Path, + get=httpx.get, + keyring=None, + proxy_decider: ProxyDecider | None = None, + ) -> None: self._cache_dir = Path(cache_dir) self._cache_dir.mkdir(parents=True, exist_ok=True) self._get = get self._keyring = keyring self._runtime = SecSpiderRuntime(keyring) if keyring is not None else None + self._proxy_decider = proxy_decider def load(self, config: SpiderPluginConfig, force_refresh: bool = False) -> LoadedSpiderPlugin: compat_spider_module.set_cache_root(self._cache_dir / "spider-cache") @@ -134,7 +142,12 @@ def _load_secspider_module(self, module_name: str, source_path: Path): return module def _fetch_remote_text(self, url: str) -> str: - response = self._get(url, timeout=15.0, follow_redirects=True) + response = self._get( + url, + timeout=15.0, + follow_redirects=True, + **build_httpx_kwargs_for_url(self._proxy_decider, url), + ) if response.status_code >= 300: raise httpx.HTTPStatusError( f"Error response {response.status_code} while requesting {url}", diff --git a/src/atv_player/proxy/segment.py b/src/atv_player/proxy/segment.py index a0f9013..a0fc426 100644 --- a/src/atv_player/proxy/segment.py +++ b/src/atv_player/proxy/segment.py @@ -5,6 +5,7 @@ import httpx +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url from atv_player.proxy.cache import ProxyCache from atv_player.proxy.session import ProxySessionRegistry from atv_player.proxy.stripper import repair_segment_bytes @@ -18,10 +19,12 @@ def __init__( session_registry: ProxySessionRegistry, get=httpx.get, cache: ProxyCache | None = None, + proxy_decider: ProxyDecider | None = None, ) -> None: self._session_registry = session_registry self._get = get self._cache = cache or ProxyCache() + self._proxy_decider = proxy_decider def fetch_segment(self, token: str, index: int, *, prefetch: bool = False) -> bytes: session = self._session_registry.get(token) @@ -44,6 +47,7 @@ def fetch_segment(self, token: str, index: int, *, prefetch: bool = False) -> by headers=dict(session.headers), timeout=10.0, follow_redirects=True, + **build_httpx_kwargs_for_url(self._proxy_decider, segment.url), ) response.raise_for_status() repaired = repair_segment_bytes(bytes(response.content)) @@ -68,6 +72,7 @@ def fetch_media(self, token: str) -> bytes: headers=dict(session.headers), timeout=10.0, follow_redirects=True, + **build_httpx_kwargs_for_url(self._proxy_decider, session.playlist_url), ) response.raise_for_status() repaired = repair_segment_bytes(bytes(response.content)) @@ -83,6 +88,7 @@ def fetch_asset(self, token: str, url: str) -> bytes: headers=dict(session.headers), timeout=10.0, follow_redirects=True, + **build_httpx_kwargs_for_url(self._proxy_decider, url), ) response.raise_for_status() return bytes(response.content) diff --git a/src/atv_player/ui/poster_loader.py b/src/atv_player/ui/poster_loader.py index bc6b82d..c17a2e7 100644 --- a/src/atv_player/ui/poster_loader.py +++ b/src/atv_player/ui/poster_loader.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from io import BytesIO from hashlib import sha256 from pathlib import Path @@ -9,6 +10,7 @@ from PySide6.QtCore import QSize, Qt from PySide6.QtGui import QImage +from atv_player.network_proxy import ProxyDecider, build_httpx_kwargs_for_url from atv_player.paths import app_cache_dir try: @@ -38,6 +40,7 @@ "music.youtube.com", "youtu.be", } +_proxy_decider_loader: Callable[[], ProxyDecider | None] | None = None def _looks_like_unsupported_page_url(source: str) -> bool: @@ -86,6 +89,19 @@ def poster_cache_path(image_url: str) -> Path: return poster_cache_dir() / f"{digest}.img" +def set_proxy_decider_loader(loader: Callable[[], ProxyDecider | None] | None) -> None: + global _proxy_decider_loader + _proxy_decider_loader = loader + + +def _effective_proxy_decider(proxy_decider: ProxyDecider | None) -> ProxyDecider | None: + if proxy_decider is not None: + return proxy_decider + if _proxy_decider_loader is None: + return None + return _proxy_decider_loader() + + def _write_poster_cache_bytes(cache_path: Path, image_bytes: bytes) -> None: cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.write_bytes(image_bytes) @@ -166,6 +182,7 @@ def load_remote_poster_image( target_size: QSize, timeout: float = POSTER_REQUEST_TIMEOUT_SECONDS, get=httpx.get, + proxy_decider: ProxyDecider | None = None, ) -> QImage | None: normalized_url = normalize_poster_url(image_url) if not normalized_url: @@ -182,6 +199,7 @@ def load_remote_poster_image( headers=build_poster_request_headers(normalized_url), timeout=timeout, follow_redirects=True, + **build_httpx_kwargs_for_url(_effective_proxy_decider(proxy_decider), normalized_url), ) response.raise_for_status() except Exception: diff --git a/src/atv_player/yt_dlp_service.py b/src/atv_player/yt_dlp_service.py index 4ff9cae..6f42ab8 100644 --- a/src/atv_player/yt_dlp_service.py +++ b/src/atv_player/yt_dlp_service.py @@ -15,6 +15,7 @@ VideoQualityOption, VodItem, ) +from atv_player.network_proxy import ProxyDecider, build_ytdlp_proxy_args from atv_player.player.ytdlp_runtime import ( build_ytdlp_command_args, resolve_system_ytdlp_path, @@ -432,12 +433,14 @@ def __init__( self, ttl_seconds: float = 300.0, now: Callable[[], float] = monotonic, + proxy_decider: ProxyDecider | None = None, ) -> None: self._ytdlp_path: str | None = None self._supported_domains: frozenset[str] | None = None self._ttl_seconds = float(ttl_seconds) self._now = now self._cache: dict[str, _YtdlpCacheEntry] = {} + self._proxy_decider = proxy_decider def _cache_key(self, url: str, max_height: int | None) -> str: key = url.strip() @@ -484,7 +487,7 @@ def _extract_info_via_command(self, url: str, max_height: int | None) -> dict: "--all-subs", "--format", _build_format_selector(max_height), - *build_ytdlp_command_args(), + *build_ytdlp_command_args(build_ytdlp_proxy_args(self._proxy_decider, url)), "--", url, ] diff --git a/tests/test_direct_parse_danmaku.py b/tests/test_direct_parse_danmaku.py index 9ed9f09..f00e7e4 100644 --- a/tests/test_direct_parse_danmaku.py +++ b/tests/test_direct_parse_danmaku.py @@ -1,6 +1,7 @@ import atv_player.danmaku.direct_parse as direct_parse_module from atv_player.danmaku.direct_parse import DirectParseDanmakuController from atv_player.models import PlayItem +from atv_player.network_proxy import ProxyConfig, ProxyDecider def test_direct_parse_danmaku_controller_refreshes_single_source_candidate() -> None: @@ -78,3 +79,33 @@ def test_direct_parse_danmaku_controller_uses_cached_xml_before_network(monkeypa assert item.danmaku_pending is False assert "缓存" in item.danmaku_xml assert network_calls == [] + + +def test_load_direct_parse_danmaku_passes_proxy_kwargs() -> None: + seen: dict[str, object] = {} + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"danmuku": []} + + def fake_get(url: str, **kwargs) -> FakeResponse: + seen["url"] = url + seen["proxy"] = kwargs.get("proxy") + seen["trust_env"] = kwargs.get("trust_env") + return FakeResponse() + + payload = direct_parse_module.load_direct_parse_danmaku( + "https://www.youtube.com/watch?v=test123", + get=fake_get, + proxy_decider=ProxyDecider( + ProxyConfig(mode="http", proxy_url="http://127.0.0.1:7890", bypass_rules=[]) + ), + ) + + assert payload == {"danmuku": []} + assert seen["url"] == "https://dmku.hls.one/" + assert seen["proxy"] == "http://127.0.0.1:7890" + assert seen["trust_env"] is False diff --git a/tests/test_hls_proxy_segment.py b/tests/test_hls_proxy_segment.py index e535259..4234714 100644 --- a/tests/test_hls_proxy_segment.py +++ b/tests/test_hls_proxy_segment.py @@ -1,5 +1,6 @@ import threading +from atv_player.network_proxy import ProxyConfig, ProxyDecider from atv_player.proxy.segment import SegmentProxy from atv_player.proxy.session import PlaylistSegment, ProxySessionRegistry @@ -116,6 +117,40 @@ def fake_get(url: str, *, headers: dict[str, str], timeout: float, follow_redire assert seen_headers == [{"Referer": "https://site.example"}] +def test_segment_proxy_passes_proxy_kwargs_to_upstream_fetch() -> None: + seen: dict[str, object] = {} + + class FakeResponse: + def __init__(self, content: bytes) -> None: + self.content = content + + def raise_for_status(self) -> None: + return None + + def fake_get(url: str, *, headers: dict[str, str], timeout: float, follow_redirects: bool, **kwargs) -> FakeResponse: + seen.update({key: value for key, value in kwargs.items() if key in {"proxy", "trust_env"}}) + return FakeResponse(b"\x47" + b"\x00" * 187) + + registry = ProxySessionRegistry() + token = registry.create_session("https://media.example/path/index.m3u8", {}) + registry.get(token).segments = [ + PlaylistSegment(index=0, url="https://media.example/path/0001.ts", duration=5.0) + ] + proxy = SegmentProxy( + session_registry=registry, + get=fake_get, + proxy_decider=ProxyDecider( + ProxyConfig(mode="socks5", proxy_url="socks5://127.0.0.1:1080", bypass_rules=[]) + ), + ) + + payload = proxy.fetch_segment(token, 0) + + assert payload.startswith(b"\x47") + assert seen["proxy"] == "socks5://127.0.0.1:1080" + assert seen["trust_env"] is False + + def test_segment_proxy_waits_for_in_flight_duplicate_request_instead_of_returning_empty_bytes() -> None: requests: list[str] = [] started = threading.Event() diff --git a/tests/test_playback_parsers.py b/tests/test_playback_parsers.py index b0f59dd..b68c1a9 100644 --- a/tests/test_playback_parsers.py +++ b/tests/test_playback_parsers.py @@ -6,6 +6,7 @@ from Crypto.Cipher import AES from Crypto.Util.Padding import pad +from atv_player.network_proxy import ProxyConfig, ProxyDecider from atv_player.playback_parsers import BuiltInPlaybackParserService from atv_player.player.resolve_cache import PlaybackResolveCache @@ -89,6 +90,34 @@ def fake_get(url: str, params: dict[str, str], headers: dict[str, str], timeout: } +def test_parser_service_passes_proxy_kwargs_to_http_calls() -> None: + seen: dict[str, object] = {} + + def fake_get( + url: str, + params: dict[str, str], + headers: dict[str, str], + timeout: float, + follow_redirects: bool, + **kwargs, + ): + seen.update({key: value for key, value in kwargs.items() if key in {"proxy", "trust_env"}}) + return httpx.Response(200, json={"parse": 0, "jx": 0, "url": "https://media.example/direct.m3u8"}) + + service = BuiltInPlaybackParserService( + get=fake_get, + proxy_decider=ProxyDecider( + ProxyConfig(mode="socks5", proxy_url="socks5://127.0.0.1:1080", bypass_rules=[]) + ), + ) + + result = service.resolve("qq", "https://site.example/play?id=3", preferred_key="fish") + + assert result.url == "https://media.example/direct.m3u8" + assert seen["proxy"] == "socks5://127.0.0.1:1080" + assert seen["trust_env"] is False + + def test_parser_service_raises_when_all_parsers_fail() -> None: def fake_get(url: str, params: dict[str, str], headers: dict[str, str], timeout: float, follow_redirects: bool): return httpx.Response(200, json={"parse": 1, "jx": 1, "url": "https://page.example/watch"}) diff --git a/tests/test_poster_loader.py b/tests/test_poster_loader.py index 688dab0..97f6a10 100644 --- a/tests/test_poster_loader.py +++ b/tests/test_poster_loader.py @@ -6,6 +6,7 @@ from PySide6.QtCore import QSize from PySide6.QtGui import QImage +from atv_player.network_proxy import ProxyConfig, ProxyDecider from atv_player.ui.poster_loader import ( build_poster_request_headers, load_remote_poster_image, @@ -116,6 +117,35 @@ def fake_get( assert calls == [True] +def test_load_remote_poster_image_passes_proxy_kwargs(monkeypatch, tmp_path) -> None: + cache_dir = tmp_path / "posters" + monkeypatch.setattr(poster_loader_module, "poster_cache_dir", lambda: cache_dir) + seen: dict[str, object] = {} + + def fake_get( + url: str, + headers: dict[str, str], + timeout: float, + follow_redirects: bool = False, + **kwargs, + ) -> FakeResponse: + seen.update({key: value for key, value in kwargs.items() if key in {"proxy", "trust_env"}}) + return FakeResponse(_png_bytes()) + + loaded = load_remote_poster_image( + "https://img3.doubanio.com/view/photo/m/public/p123.jpg", + QSize(90, 130), + get=fake_get, + proxy_decider=ProxyDecider( + ProxyConfig(mode="http", proxy_url="http://127.0.0.1:7890", bypass_rules=[]) + ), + ) + + assert loaded is not None + assert seen["proxy"] == "http://127.0.0.1:7890" + assert seen["trust_env"] is False + + def test_load_remote_poster_image_reuses_cached_file(monkeypatch, tmp_path) -> None: cache_dir = tmp_path / "posters" cache_dir.mkdir() diff --git a/tests/test_spider_plugin_loader.py b/tests/test_spider_plugin_loader.py index cd73cab..6f25eea 100644 --- a/tests/test_spider_plugin_loader.py +++ b/tests/test_spider_plugin_loader.py @@ -5,7 +5,9 @@ import pytest from atv_player.models import SpiderPluginConfig +from atv_player.network_proxy import ProxyConfig, ProxyDecider from atv_player.plugins.loader import SpiderPluginLoader +from atv_player.plugins.compat.base.spider import Spider, set_proxy_decider_loader from tests.secspider_fixtures import build_secspider_package PLUGIN_SOURCE = """ @@ -107,6 +109,105 @@ def fake_get(url: str, timeout: float = 15.0, follow_redirects: bool = False) -> assert "class Spider(Spider):" in Path(loaded.config.cached_file_path).read_text(encoding="utf-8") +def test_loader_fetch_remote_text_passes_proxy_kwargs(tmp_path: Path) -> None: + seen: dict[str, object] = {} + + def fake_get(url: str, **kwargs) -> httpx.Response: + seen.update({key: value for key, value in kwargs.items() if key in {"proxy", "trust_env"}}) + return httpx.Response(200, text=PLUGIN_SOURCE, request=httpx.Request("GET", url)) + + loader = SpiderPluginLoader( + cache_dir=tmp_path / "cache", + get=fake_get, + proxy_decider=ProxyDecider( + ProxyConfig(mode="http", proxy_url="http://127.0.0.1:7890", bypass_rules=[]) + ), + ) + config = SpiderPluginConfig( + id=43, + source_type="remote", + source_value="https://example.com/plugin.py", + display_name="", + enabled=True, + sort_order=0, + ) + + loaded = loader.load(config, force_refresh=True) + + assert loaded.plugin_name == "红果短剧" + assert seen["proxy"] == "http://127.0.0.1:7890" + assert seen["trust_env"] is False + + +def test_compat_spider_fetch_uses_dynamic_proxy_loader(monkeypatch) -> None: + seen: dict[str, object] = {} + + class FakeResponse: + content = b"ok" + encoding = "" + + def close(self) -> None: + return None + + def fake_get(url: str, **kwargs) -> FakeResponse: + seen["url"] = url + seen["proxies"] = kwargs.get("proxies") + return FakeResponse() + + monkeypatch.setattr("atv_player.plugins.compat.base.spider.requests.get", fake_get) + set_proxy_decider_loader( + lambda: ProxyDecider( + ProxyConfig(mode="http", proxy_url="http://127.0.0.1:7890", bypass_rules=[]) + ) + ) + try: + response = Spider().fetch("https://example.com/data.json") + finally: + set_proxy_decider_loader(None) + + assert response.content == b"ok" + assert seen["url"] == "https://example.com/data.json" + assert seen["proxies"] == { + "http": "http://127.0.0.1:7890", + "https": "http://127.0.0.1:7890", + } + + +def test_compat_spider_post_bypasses_proxy_when_rule_matches(monkeypatch) -> None: + seen: dict[str, object] = {} + + class FakeResponse: + content = b"ok" + encoding = "" + + def close(self) -> None: + return None + + def fake_post(url: str, **kwargs) -> FakeResponse: + seen["url"] = url + seen["proxies"] = kwargs.get("proxies") + return FakeResponse() + + monkeypatch.setattr("atv_player.plugins.compat.base.spider.requests.post", fake_post) + set_proxy_decider_loader( + lambda: ProxyDecider( + ProxyConfig( + mode="socks5", + proxy_url="socks5://127.0.0.1:1080", + bypass_rules=["api.example.com"], + ) + ) + ) + try: + response = Spider().post("https://api.example.com/update", json={"ok": True}) + finally: + set_proxy_decider_loader(None) + + assert response.content == b"ok" + assert seen["url"] == "https://api.example.com/update" + assert seen["proxies"] == {"http": None, "https": None} + + def test_loader_treats_python_text_as_source_instead_of_indirect_url(tmp_path: Path) -> None: calls: list[str] = [] diff --git a/tests/test_yt_dlp_service.py b/tests/test_yt_dlp_service.py index 91c9235..dfcda0d 100644 --- a/tests/test_yt_dlp_service.py +++ b/tests/test_yt_dlp_service.py @@ -7,6 +7,7 @@ import pytest from atv_player.models import PlayItem, VodItem +from atv_player.network_proxy import ProxyConfig, ProxyDecider @pytest.fixture(autouse=True) @@ -123,6 +124,44 @@ def fake_run(command, **_kwargs): assert "--cookies-from-browser" in command assert command[command.index("--cookies-from-browser") + 1] == "chrome" + def test_extract_info_via_command_includes_proxy_when_manual_proxy_is_selected(self, monkeypatch, service): + run_calls: list[list[str]] = [] + + def fake_run(command, **_kwargs): + run_calls.append(command) + return SimpleNamespace(returncode=0, stdout=json.dumps(_sample_info()), stderr="") + + monkeypatch.setattr("atv_player.yt_dlp_service.subprocess.run", fake_run) + service._proxy_decider = ProxyDecider( + ProxyConfig(mode="socks5", proxy_url="socks5://127.0.0.1:1080", bypass_rules=[]) + ) + + service._extract_info_via_command("https://www.youtube.com/watch?v=test123", 1080) + + command = run_calls[0] + assert "--proxy" in command + assert command[command.index("--proxy") + 1] == "socks5://127.0.0.1:1080" + + def test_extract_info_via_command_skips_proxy_for_bypass_target(self, monkeypatch, service): + run_calls: list[list[str]] = [] + + def fake_run(command, **_kwargs): + run_calls.append(command) + return SimpleNamespace(returncode=0, stdout=json.dumps(_sample_info()), stderr="") + + monkeypatch.setattr("atv_player.yt_dlp_service.subprocess.run", fake_run) + service._proxy_decider = ProxyDecider( + ProxyConfig( + mode="socks5", + proxy_url="socks5://127.0.0.1:1080", + bypass_rules=["www.youtube.com"], + ) + ) + + service._extract_info_via_command("https://www.youtube.com/watch?v=test123", 1080) + + assert "--proxy" not in run_calls[0] + class TestCanResolve: def test_known_domain(self, service):