From bb7f7f7315011df0807627f0d760936ccecea8b6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 20:03:51 +0800 Subject: [PATCH] fix(types): clarify Ugreen session and media response boundaries --- app/modules/ugreen/ugreen.py | 84 +++++++++++-------- docs/refactor/optimization-checklist.md | 2 +- mypy.ini | 1 + .../fixtures/architecture/mypy-baseline.json | 13 +-- tests/test_ugreen_typed_boundaries.py | 41 +++++++++ 5 files changed, 92 insertions(+), 49 deletions(-) create mode 100644 tests/test_ugreen_typed_boundaries.py diff --git a/app/modules/ugreen/ugreen.py b/app/modules/ugreen/ugreen.py index 356d9dfe3..2fe744eac 100644 --- a/app/modules/ugreen/ugreen.py +++ b/app/modules/ugreen/ugreen.py @@ -43,11 +43,12 @@ class Ugreen: _username: Optional[str] = None _password: Optional[str] = None - _userinfo: Optional[dict] = None + # 用户资料、媒体库和会话负载沿用 API 的原始 JSON 字典合同。 + _userinfo: Optional[dict[str, Any]] = None _host: Optional[str] = None _playhost: Optional[str] = None - _libraries: dict[str, dict] = {} + _libraries: dict[str, dict[str, Any]] = {} _library_paths: dict[str, str] = {} _sync_libraries: List[str] = [] _scan_type: int = 2 @@ -63,13 +64,13 @@ class Ugreen: username: Optional[str] = None, password: Optional[str] = None, play_host: Optional[str] = None, - sync_libraries: Optional[list] = None, + sync_libraries: Optional[list[str]] = None, scan_mode: Optional[Union[str, int]] = None, scan_type: Optional[Union[str, int]] = None, verify_ssl: Optional[Union[bool, str, int]] = True, mode: Optional[str] = None, - **kwargs, - ): + **kwargs: object, + ) -> None: """ 保存连接配置,并按端口或显式模式连接绿联影视服务。 @@ -116,7 +117,7 @@ class Ugreen: """ return self._api - def close(self): + def close(self) -> None: """ 关闭绿联影视连接。 """ @@ -210,7 +211,7 @@ class Ugreen: return hashlib.sha256((self._password or "").encode("utf-8")).hexdigest() @staticmethod - def __load_all_session_cache() -> dict: + def __load_all_session_cache() -> dict[str, Any]: """ 读取系统配置中保存的所有绿联会话。 @@ -220,7 +221,7 @@ class Ugreen: return sessions if isinstance(sessions, dict) else {} @staticmethod - def __save_all_session_cache(sessions: dict): + def __save_all_session_cache(sessions: dict[str, Any]) -> None: """ 把所有绿联实例的会话字典写回系统配置。 @@ -228,7 +229,7 @@ class Ugreen: """ get_configured_system_config().set(SystemConfigKey.UgreenSessionCache, sessions) - def __remove_persisted_session(self): + def __remove_persisted_session(self) -> None: """ 从持久化会话映射中删除当前地址和账号对应的记录。 """ @@ -238,7 +239,7 @@ class Ugreen: sessions.pop(cache_key, None) self.__save_all_session_cache(sessions) - def __save_persisted_session(self): + def __save_persisted_session(self) -> None: """ 保存内部接口会话、Cookie、密码摘要和更新时间。 @@ -261,12 +262,13 @@ class Ugreen: } self.__save_all_session_cache(sessions) - def __restore_persisted_session(self) -> bool: + def __restore_persisted_session(self, host: str) -> bool: """ 验证缓存会话的密码摘要和服务端用户状态,成功后接管客户端。 密码变更或会话失效时删除旧记录;验证失败后关闭临时客户端,调用方可继续密码登录。 + :param host: 重连入口已校验的服务地址 :return: 恢复并验证成功时返回 True;记录缺失、配置变更或会话失效时返回 False """ cache_key = self.__session_cache_key() @@ -281,7 +283,7 @@ class Ugreen: self.__remove_persisted_session() return False - api = Api(host=self._host, verify_ssl=self._verify_ssl) + api = Api(host=host, verify_ssl=self._verify_ssl) if not api.import_session_state(cached): api.close() self.__remove_persisted_session() @@ -341,11 +343,11 @@ class Ugreen: logger.debug(f"{self._username} 成功登录绿联媒体库服务") return True - if self.__restore_persisted_session(): + if self.__restore_persisted_session(media_host): return True - self._api = Api(host=self._host, verify_ssl=self._verify_ssl) - if self._api.login(self._username, self._password) is None: + self._api = Api(host=media_host, verify_ssl=self._verify_ssl) + if self._api.login(media_username, media_password) is None: self.__remove_persisted_session() return False @@ -359,7 +361,7 @@ class Ugreen: logger.debug(f"{self._username} 成功登录绿联影视") return True - def disconnect(self, logout: bool = False): + def disconnect(self, logout: bool = False) -> None: """ 关闭客户端并清空内存中的用户、媒体库与路径状态。 @@ -488,7 +490,7 @@ class Ugreen: return False @staticmethod - def __parse_year(video_info: dict) -> Optional[Union[str, int]]: + def __parse_year(video_info: dict[str, Any]) -> Optional[Union[str, int]]: """ 读取有效年份,缺失时从发行时间戳推断。 @@ -525,7 +527,9 @@ class Ugreen: return "Video" @staticmethod - def __build_media_server_item(video_info: dict, play_status: Optional[dict] = None): + def __build_media_server_item( + video_info: dict[str, Any], play_status: Optional[dict[str, Any]] = None, + ) -> Optional[_SchemaMediaServerItem]: """ 将内部视频信息和播放状态转换为统一媒体模型。 @@ -752,14 +756,15 @@ class Ugreen: ) return image_url - def __build_play_item_from_wrapper(self, wrapper: dict) -> Optional[_SchemaMediaServerPlayItem]: + def __build_play_item_from_wrapper(self, wrapper: dict[str, Any]) -> Optional[_SchemaMediaServerPlayItem]: """ 将内部接口的视频与播放状态包装转换为仪表盘条目。 :param wrapper: 包含 video_info、play_status 的包装,或直接传入视频信息字典 :return: 播放条目;缺少视频标识时返回 None,进度转换并限制在 0 至 100 之间 """ - video_info = wrapper.get("video_info") if isinstance(wrapper.get("video_info"), dict) else wrapper + video_info_value = wrapper.get("video_info") + video_info = video_info_value if isinstance(video_info_value, dict) else wrapper if not isinstance(video_info, dict): return None @@ -767,8 +772,10 @@ class Ugreen: if item_id is None: return None - play_status = wrapper.get("play_status") if isinstance(wrapper.get("play_status"), dict) else {} - progress = play_status.get("progress") if isinstance(play_status.get("progress"), (int, float)) else 0 + play_status_value = wrapper.get("play_status") + play_status = play_status_value if isinstance(play_status_value, dict) else {} + progress_value = play_status.get("progress") + progress = progress_value if isinstance(progress_value, (int, float)) else 0 if video_info.get("type") == 2: subtitle = play_status.get("tv_name") or "剧集" @@ -1084,7 +1091,7 @@ class Ugreen: api.close() @staticmethod - def __extract_video_info_list(bucket: Any) -> list[dict]: + def __extract_video_info_list(bucket: object) -> list[dict[str, Any]]: """ 从搜索分类桶的 video_arr 中提取有效 video_info 字典。 @@ -1357,7 +1364,7 @@ class Ugreen: self, title: str, year: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - ) -> Optional[dict]: + ) -> Optional[dict[str, Any]]: """ 按标题、年份与统一媒体身份查找电视剧条目。 """ @@ -1392,7 +1399,7 @@ class Ugreen: media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, season: Optional[int] = None, - ) -> tuple[Optional[str], Optional[Dict[int, list]]]: + ) -> tuple[Optional[str], Optional[Dict[int, list[int]]]]: """ 根据标题、年份、媒体身份和季号查询绿联媒体库中的电视剧已入库集数。 @@ -1462,7 +1469,7 @@ class Ugreen: if category_id and isinstance(season_num, int): season_map[str(category_id)] = season_num - season_episodes: Dict[int, list] = {} + season_episodes: Dict[int, list[int]] = {} for ep in tv_detail.get("tv_info") or []: if not isinstance(ep, dict): continue @@ -1609,7 +1616,9 @@ class Ugreen: return self.__build_media_server_item(video_info, info.get("play_status")) - def _iter_library_videos(self, root_path: str, page_size: int = 100): + def _iter_library_videos( + self, root_path: str, page_size: int = 100, + ) -> Generator[dict[str, Any], None, None]: """ 广度优先分页遍历海报墙目录,最多访问 20000 个不同路径。 @@ -1664,7 +1673,7 @@ class Ugreen: parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, - ) -> Generator[_SchemaMediaServerItem, Any, None]: + ) -> Generator[_SchemaMediaServerItem, None, None]: """ 分页遍历媒体库服务用户可见的电影、剧集和音乐专辑。 """ @@ -1761,7 +1770,7 @@ class Ugreen: parent: Union[str, int], start_index: Optional[int] = 0, limit: Optional[int] = -1, - ) -> Generator[_SchemaMediaServerItem | None | Any, Any, None]: + ) -> Generator[_SchemaMediaServerItem, None, None]: """ 按连接模式逐项遍历媒体库中可同步的媒体。 @@ -1789,7 +1798,7 @@ class Ugreen: return None skip = max(0, start_index or 0) - remain = -1 if limit in [None, -1] else max(0, limit) + remain = -1 if limit is None or limit == -1 else max(0, limit) for video in self._iter_library_videos(root_path=root_path): video_type = video.get("type") @@ -1875,13 +1884,14 @@ class Ugreen: if data is None: return None - ret_resume = [] + ret_resume: list[_SchemaMediaServerPlayItem] = [] for item in data.get("video_arr") or []: if len(ret_resume) == page_size: break if not isinstance(item, dict): continue - video_info = item.get("video_info") if isinstance(item.get("video_info"), dict) else {} + video_info_value = item.get("video_info") + video_info = video_info_value if isinstance(video_info_value, dict) else {} library_id = str(video_info.get("media_lib_set_id") or "") if self.__is_library_blocked(library_id): continue @@ -1919,13 +1929,14 @@ class Ugreen: if data is None: return None - latest = [] + latest: list[_SchemaMediaServerPlayItem] = [] for item in data.get("video_arr") or []: if len(latest) == page_size: break if not isinstance(item, dict): continue - video_info = item.get("video_info") if isinstance(item.get("video_info"), dict) else {} + video_info_value = item.get("video_info") + video_info = video_info_value if isinstance(video_info_value, dict) else {} library_id = str(video_info.get("media_lib_set_id") or "") if self.__is_library_blocked(library_id): continue @@ -1973,7 +1984,8 @@ class Ugreen: if not isinstance(item, dict): continue - video_info = item.get("video_info") if isinstance(item.get("video_info"), dict) else {} + video_info_value = item.get("video_info") + video_info = video_info_value if isinstance(video_info_value, dict) else {} video_type = video_info.get("type") if isinstance(video_info, dict) else None if video_type != 1 and video_type != 2: continue @@ -1990,7 +2002,7 @@ class Ugreen: return images @staticmethod - def get_image_cookies(image_url: str): + def get_image_cookies(image_url: str) -> None: # 绿联图片流接口依赖加密鉴权头,当前图片代理仅支持Cookie注入。 """ 保留图片 Cookie 接口;绿联图片认证不通过 Cookie 提供。 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 01da836e3..dfd078ab1 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -108,7 +108,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 373,625 行 | 本次重新扫描物理行数,排除 `app/plugins/**`;74 个文件超过 1,000 行,12 个超过 2,000 行;源码尺寸仅作观察 | | 长方法 | 309 个超过 80 行 | 本次 AST 扫描排除 `app/plugins/**`;61 个超过 150 行,17 个超过 250 行;新增函数仍受 C901/嵌套深度门禁约束 | -| 全量 mypy 历史债务 | 4,443 / 252 文件 | 本分支为 Pydantic 字段补充描述并保留默认值,减少 3 项 assignment 错误;本次 NFO 返回类型与契约对齐,减少 2 项历史类型错误;本轮在消息、站点、诊断、备份、用户配置、订阅、AcoustID、TMDB、仪表板和命令生命周期边界补齐类型,历史债务再减少 53 项;TMDB v3 API 显式导出并保留集合 parts 的列表合同,逐文件逐错误码无增长;此前工作流筛选、动态插件路由和 stdio 流补齐边界类型,历史债务减少 3 项;Bitpt 与 CookieCloud 补齐边界类型,历史债务减少 16 项;知行站点解析器补齐数值属性与基类合同,历史债务减少 18 项;通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项;本批清理 Servarr、认证、存储、订阅策略和搜索/规则/通知 Schema 的 18 项类型错误,并修复系统配置中的 Python 2 异常语法;本批再清理订阅元数据、订阅写入、交互宿主、整理路径、删除统计和工作流动作的 14 项类型错误,逐文件逐错误码无增长;本批补齐 OCR 请求参数、工具 Schema 摘要和宿主模块配置快照的 5 项边界类型,逐文件逐错误码无增长;本批为 Rousi 用户消息分页解析补齐返回类型与数据结构兜底,减少 2 项类型错误;本批优先处理 TMDB v3 电影与电视剧对象模型,补齐 98 个同步/异步方法注解并减少 122 项历史类型错误;随后补齐剧集、季度、人物、搜索和趋势对象的 142 个方法注解,再减少 142 项类型错误;绿联 API 响应边界补齐具体泛型与运行时字典收窄,减少 31 项类型错误;运行时限流器补齐状态方法、装饰器边界和时间队列类型,减少 31 项类型错误;QQ Bot API 响应边界补齐具体泛型与 JSON 形状校验,减少 17 项类型错误;rTorrent 客户端清理 182 项错误,调用端同步明确字典合同,累计再减少 190 项错误;豆瓣 API、中心服务接口及订阅消费端补齐返回边界和参数收窄,结合 Context 方法签名与缓存代理类型清理,累计再减少 455 项错误;共享 HTTP 客户端清理 110 项错误,TMDB API 清理 84 项错误,索引器爬虫清理 87 项错误,并补充 U115、AliPan、OpenList 对可空流响应的防守;U115 其余存储基类契约债务暂留待测试完备后处理;缓存同步/异步继承冲突保留为设计债务,未纳入严格门禁 | +| 全量 mypy 历史债务 | 4,396 / 251 文件 | 本分支为 Pydantic 字段补充描述并保留默认值,减少 3 项 assignment 错误;本次 NFO 返回类型与契约对齐,减少 2 项历史类型错误;本轮在消息、站点、诊断、备份、用户配置、订阅、AcoustID、TMDB、仪表板和命令生命周期边界补齐类型,历史债务再减少 53 项;TMDB v3 API 显式导出并保留集合 parts 的列表合同,逐文件逐错误码无增长;此前工作流筛选、动态插件路由和 stdio 流补齐边界类型,历史债务减少 3 项;Bitpt 与 CookieCloud 补齐边界类型,历史债务减少 16 项;知行站点解析器补齐数值属性与基类合同,历史债务减少 18 项;通知排版补齐 Slack 消息块类型,历史债务减少 2 项;工作流资源过滤改用领域对象调用过滤链,恢复的节点输出在消费前归一化,并收紧可空资源列表的类型低水位;消息媒体交互类型债务已清零,canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降;微信 ClawBot 入站 item_list 媒体解析改为按子项键名分派后,类型错误减少 4 项;站点解析器分享率改用浮点初值,类型错误减少 20 项;本次下载与文件安全修复再减少 3 项;Transmission 进度回退值归一化再减少 2 项;本批清理 Servarr、认证、存储、订阅策略和搜索/规则/通知 Schema 的 18 项类型错误,并修复系统配置中的 Python 2 异常语法;本批再清理订阅元数据、订阅写入、交互宿主、整理路径、删除统计和工作流动作的 14 项类型错误,逐文件逐错误码无增长;本批补齐 OCR 请求参数、工具 Schema 摘要和宿主模块配置快照的 5 项边界类型,逐文件逐错误码无增长;本批为 Rousi 用户消息分页解析补齐返回类型与数据结构兜底,减少 2 项类型错误;本批优先处理 TMDB v3 电影与电视剧对象模型,补齐 98 个同步/异步方法注解并减少 122 项历史类型错误;随后补齐剧集、季度、人物、搜索和趋势对象的 142 个方法注解,再减少 142 项类型错误;绿联 API 响应边界补齐具体泛型与运行时字典收窄,减少 31 项类型错误;运行时限流器补齐状态方法、装饰器边界和时间队列类型,减少 31 项类型错误;QQ Bot API 响应边界补齐具体泛型与 JSON 形状校验,减少 17 项类型错误;rTorrent 客户端清理 182 项错误,调用端同步明确字典合同,累计再减少 190 项错误;豆瓣 API、中心服务接口及订阅消费端补齐返回边界和参数收窄,结合 Context 方法签名与缓存代理类型清理,累计再减少 455 项错误;共享 HTTP 客户端清理 110 项错误,TMDB API 清理 84 项错误,索引器爬虫清理 87 项错误,并补充 U115、AliPan、OpenList 对可空流响应的防守;U115 其余存储基类契约债务暂留待测试完备后处理;缓存同步/异步继承冲突保留为设计债务,未纳入严格门禁 | | Ruff 历史诊断 | 73 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率逐文件低水位 | 全 app(排除 app/plugins)当前聚合 77.68% | 存量文件比例不降;语句减少且未覆盖行数不增时允许比例变化,新文件至少 80% | diff --git a/mypy.ini b/mypy.ini index fd7ba189d..b959f7f12 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,6 +7,7 @@ follow_imports = skip show_error_codes = True pretty = True files = + app/modules/ugreen/ugreen.py, app/adapters/external/ocr.py, app/agent/tools/catalog.py, app/runtime/extensions/module/adapter.py, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 25b97cf90..d6dfca1e5 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1250,21 +1250,10 @@ "arg-type": 3, "assignment": 1, "empty-body": 1, - "no-any-return": 3, + "no-any-return": 1, "no-untyped-def": 7, "type-arg": 2 }, - "app/modules/ugreen/ugreen.py": { - "arg-type": 4, - "no-any-return": 1, - "no-untyped-call": 7, - "no-untyped-def": 9, - "operator": 2, - "type-arg": 12, - "type-var": 1, - "union-attr": 7, - "var-annotated": 2 - }, "app/modules/vocechat/__init__.py": { "assignment": 6, "attr-defined": 1, diff --git a/tests/test_ugreen_typed_boundaries.py b/tests/test_ugreen_typed_boundaries.py new file mode 100644 index 000000000..ceaa150f7 --- /dev/null +++ b/tests/test_ugreen_typed_boundaries.py @@ -0,0 +1,41 @@ +""" +验证绿联客户端类型收窄前后的播放进度及遍历边界。 +""" + +from unittest.mock import Mock + +import pytest + +from app.modules.ugreen.ugreen import Ugreen + + +@pytest.mark.parametrize("progress,expected", [(None, 0), ("0.5", 0), (0.5, 50), (2, 100)]) +def test_play_wrapper_preserves_progress_fallback(progress: object, expected: int) -> None: + """ + 缺失或非数值进度仍按零显示,合法数值继续限制在百分比范围内。 + """ + client = Ugreen.__new__(Ugreen) + item = client._Ugreen__build_play_item_from_wrapper({ + "video_info": {"ug_video_info_id": "movie", "type": 1, "name": "电影"}, + "play_status": {"progress": progress}, + }) + assert item is not None + assert item.percent == expected + + +@pytest.mark.parametrize("limit,expected", [(None, 3), (-1, 3), (0, 1), (1, 1), (2, 2)]) +def test_internal_item_limit_preserves_existing_zero_behavior( + monkeypatch: pytest.MonkeyPatch, limit: object, expected: int, +) -> None: + """ + 内部模式的不限量和零上限继续遵循历史行为,不借类型治理改变分页结果。 + """ + client = Ugreen.__new__(Ugreen) + client._api = Mock() + client._library_paths = {"library": "/media"} + monkeypatch.setattr(client, "is_authenticated", lambda: True) + monkeypatch.setattr(client, "_iter_library_videos", lambda **_kwargs: iter([ + {"ug_video_info_id": str(index), "type": 1, "name": "电影"} + for index in range(3) + ])) + assert len(list(client.get_items("library", limit=limit))) == expected