fix(types): clarify Ugreen session and media response boundaries

This commit is contained in:
jxxghp
2026-10-17 20:03:51 +08:00
parent e33ae01aad
commit bb7f7f7315
5 changed files with 92 additions and 49 deletions

View File

@@ -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 提供。

View File

@@ -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% |

View File

@@ -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,

View File

@@ -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,

View File

@@ -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