From a03e620c270d149a78e353eb91131460c12c5c1a Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 20:11:06 +0800 Subject: [PATCH] fix(types): make Emby response contracts explicit --- app/modules/emby/emby.py | 91 ++++++++++++------- docs/refactor/optimization-checklist.md | 2 +- mypy.ini | 1 + .../fixtures/architecture/mypy-baseline.json | 11 --- tests/test_emby_typed_boundaries.py | 76 ++++++++++++++++ 5 files changed, 137 insertions(+), 44 deletions(-) create mode 100644 tests/test_emby_typed_boundaries.py diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index e346f4ec4..4dfd37667 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -25,6 +25,24 @@ from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo from app.schemas.types import MediaSource, MediaType +def _optional_text(value: object) -> Optional[str]: + """ + 读取服务端可空文本字段,拒绝非文本值而不隐式转换标识或路径。 + """ + if value is None or isinstance(value, str): + return value + raise TypeError("Emby 文本字段格式错误") + + +def _user_id(value: object) -> Optional[Union[str, int]]: + """ + 读取兼容字符串或整数的服务端用户标识,保留空值。 + """ + if value is None or isinstance(value, (str, int)): + return value + raise TypeError("Emby 用户标识格式错误") + + class Emby: """ Emby媒体服务器客户端。 @@ -38,7 +56,7 @@ class Emby: _username: Optional[str] = None def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None, - username: Optional[str] = None, sync_libraries: list = None, **kwargs): + username: Optional[str] = None, sync_libraries: Optional[list[str]] = None, **kwargs: object) -> None: """ 保存服务配置,并查询用户、服务器身份及必要的媒体库路径。 @@ -75,14 +93,14 @@ class Emby: return False return True if not self.user else False - def reconnect(self): + def reconnect(self) -> None: """ 重新查询用户并更新媒体库路径缓存。 """ self.user = self.get_user() self.folders = self.get_emby_folders() - def get_emby_folders(self) -> List[dict]: + def get_emby_folders(self) -> List[dict[str, Any]]: """ 读取服务端可选媒体库及其目录信息。 @@ -97,7 +115,10 @@ class Emby: try: res = RequestUtils().get_res(url, params) if res: - return res.json() + folders: object = res.json() + if not isinstance(folders, list) or not all(isinstance(folder, dict) for folder in folders): + raise TypeError("Emby 媒体库目录响应格式错误") + return folders else: logger.error("Library/SelectableMediaFolders 未获取到返回数据") return [] @@ -105,7 +126,7 @@ class Emby: logger.error("连接Library/SelectableMediaFolders 出错:" + str(e)) return [] - def get_emby_virtual_folders(self) -> List[dict]: + def get_emby_virtual_folders(self) -> List[dict[str, Any]]: """ 读取虚拟媒体库及其全部目录,优先使用共享路径。 @@ -147,7 +168,7 @@ class Emby: logger.error("连接Library/VirtualFolders/Query 出错:" + str(e)) return [] - def __get_emby_librarys(self, username: Optional[str] = None) -> Optional[List[dict]]: + def __get_emby_librarys(self, username: Optional[str] = None) -> Optional[List[dict[str, Any]]]: """ 获取Emby媒体库列表。 """ @@ -256,10 +277,10 @@ class Emby: for candidate_username in candidate_usernames: for emby_user in users: if emby_user.get("Name") == candidate_username: - return emby_user.get("Id") + return _user_id(emby_user.get("Id")) for emby_user in users: if emby_user.get("Policy", {}).get("IsAdministrator"): - return emby_user.get("Id") + return _user_id(emby_user.get("Id")) else: logger.error("Users 未获取到返回数据") except Exception as e: @@ -297,7 +318,7 @@ class Emby: auth_token = res.json().get("AccessToken") if auth_token: logger.info(f"用户 {username} Emby认证成功") - return auth_token + return _optional_text(auth_token) else: logger.error("Users/AuthenticateByName 未获取到返回数据") except Exception as e: @@ -323,14 +344,14 @@ class Emby: result = res.json() or {} server_id = result.get("Id") or result.get("ServerId") if server_id: - return server_id + return _optional_text(server_id) else: logger.error(f"{path} 未获取到返回数据") except Exception as e: logger.error(f"连接{path}出错:" + str(e)) return None - def get_user_count(self) -> int: + def get_user_count(self) -> Optional[int]: """ 查询媒体服务器的用户数量。 @@ -345,7 +366,10 @@ class Emby: try: res = RequestUtils().get_res(url, params) if res: - return res.json().get("TotalRecordCount") + count: object = res.json().get("TotalRecordCount") + if count is None or isinstance(count, int): + return count + raise TypeError("Emby 用户数量格式错误") else: logger.error("Users/Query 未获取到返回数据") return 0 @@ -383,7 +407,7 @@ class Emby: logger.error("连接Items/Counts出错:" + str(e)) return _SchemaStatistic() - def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]: + def __get_emby_series_id_by_name(self, name: Optional[str], year: Optional[Union[str, int]]) -> Optional[str]: """ 根据名称查询Emby中剧集的SeriesId。 @@ -412,7 +436,7 @@ class Emby: for res_item in res_items: if res_item.get('Name') == name and ( not year or str(res_item.get('ProductionYear')) == str(year)): - return res_item.get('Id') + return _optional_text(res_item.get('Id')) except Exception as e: logger.error("连接Items出错:" + str(e)) return None @@ -420,7 +444,7 @@ class Emby: def get_movies(self, title: str, - year: Optional[str] = None, + year: Optional[Union[str, int]] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None) -> Optional[List[_SchemaMediaServerItem]]: """ @@ -510,7 +534,7 @@ class Emby: def get_tv_episodes(self, item_id: Optional[str] = None, title: Optional[str] = None, - year: Optional[str] = None, + year: Optional[Union[str, int]] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, season: Optional[int] = None @@ -566,7 +590,7 @@ class Emby: if res_json: tv_item = res_json.json() res_items = tv_item.get("Items") - season_episodes = {} + season_episodes: dict[int, list[int]] = {} for res_item in res_items: season_index = res_item.get("ParentIndexNumber") if season_index is None: @@ -640,7 +664,7 @@ class Emby: if images: for image in images: if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type: - return image.get("Url") + return _optional_text(image.get("Url")) logger.info("Items/RemoteImages 未获取到返回数据,采用本地图片") return self.generate_external_image_link(item_id, image_type) except Exception as e: @@ -664,7 +688,7 @@ class Emby: res = RequestUtils().get_res(url) if res and res.status_code != 404: logger.info(f"影片图片链接:{res.url}") - return res.url + return _optional_text(res.url) else: logger.info("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type)) return None @@ -784,7 +808,7 @@ class Emby: return "/" @staticmethod - def __format_item_info(item) -> Optional[_SchemaMediaServerItem]: + def __format_item_info(item: dict[str, Any]) -> Optional[_SchemaMediaServerItem]: """ 通过统一 Emby 系转换契约生成条目,并保留 Emby 的服务器 ID。 """ @@ -840,7 +864,7 @@ class Emby: logger.error(f"获取 Emby 剧集身份出错:{str(e)}") return None, None - def get_items_count(self, parent: Union[str, int], include_item_types: str = "Movie,Series") -> Optional[int]: + def get_items_count(self, parent: Optional[Union[str, int]], include_item_types: str = "Movie,Series") -> Optional[int]: """ 递归统计指定媒体库内的可同步条目。 @@ -869,7 +893,7 @@ class Emby: return None def get_items(self, parent: Union[str, int], start_index: Optional[int] = 0, - limit: Optional[int] = -1) -> Generator[MediaServerItem | None | Any, Any, None]: + limit: Optional[int] = -1) -> Generator[Optional[MediaServerItem], None, None]: """ 读取媒体库条目并递归展开目录。 @@ -888,7 +912,7 @@ class Emby: if not self._host or not self._apikey: raise ConnectionError("Emby 服务器未连接") url = f"{self._host}emby/Users/{self.user}/Items" - params = { + params: dict[str, Union[str, int, None]] = { "ParentId": parent, "api_key": self._apikey, "Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount," @@ -919,7 +943,7 @@ class Emby: logger.error(f"同步读取 Emby 媒体库 {parent} 出错:{str(e)}") raise - def get_webhook_message(self, form: Any, args: dict) -> Optional[_SchemaWebhookEventInfo]: + def get_webhook_message(self, form: Any, args: dict[str, object]) -> Optional[_SchemaWebhookEventInfo]: """ 解析 Emby Webhook,并补全统一事件中的媒体身份与图片。 @@ -1034,14 +1058,14 @@ class Emby: return None url = url.replace("[HOST]", self._host or '') \ .replace("[APIKEY]", self._apikey or '') \ - .replace("[USER]", self.user or '') + .replace("[USER]", _optional_text(self.user or '') or '') try: return RequestUtils(content_type="application/json").get_res(url=url) except Exception as e: logger.error("连接Emby出错:" + str(e)) return None - def post_data(self, url: str, data: Optional[str] = None, headers: dict = None) -> Optional[Response]: + def post_data(self, url: str, data: Optional[str] = None, headers: Optional[dict[str, str]] = None) -> Optional[Response]: """ 替换服务配置占位符后发送自定义 POST 请求。 @@ -1054,7 +1078,7 @@ class Emby: return None url = url.replace("[HOST]", self._host or '') \ .replace("[APIKEY]", self._apikey or '') \ - .replace("[USER]", self.user or '') + .replace("[USER]", _optional_text(self.user or '') or '') try: return RequestUtils( headers=headers, @@ -1096,7 +1120,7 @@ class Emby: return f"{host_url}Items/{item_id}/" \ f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}" - def __get_local_image_by_id(self, item_id: str, image_tag: Optional[str] = None) -> str: + def __get_local_image_by_id(self, item_id: Optional[Union[str, int]], image_tag: Optional[str] = None) -> str: """ 构造服务内网的主封面图片地址。 @@ -1137,7 +1161,7 @@ class Emby: res = RequestUtils().get_res(url, params) if res: result = res.json().get("Items") or [] - ret_resume = [] + ret_resume: list[_SchemaMediaServerPlayItem] = [] # 用户媒体库文件夹列表(排除黑名单) library_folders = self.get_user_library_folders() for item in result: @@ -1222,7 +1246,7 @@ class Emby: res = RequestUtils().get_res(url, params) if res: result = res.json() or [] - ret_latest = [] + ret_latest: list[_SchemaMediaServerPlayItem] = [] # 用户媒体库文件夹列表(排除黑名单) library_folders = self.get_user_library_folders() for item in result: @@ -1260,7 +1284,7 @@ class Emby: logger.error("连接Users/Items/Latest出错:" + str(e)) return None - def get_user_library_folders(self): + def get_user_library_folders(self) -> list[str]: """ 收集允许同步的媒体库目录路径。 @@ -1272,5 +1296,8 @@ class Emby: for library in self.get_emby_virtual_folders() or []: if self._sync_libraries and library.get("Id") not in self._sync_libraries: continue - library_folders += [folder for folder in library.get("Path")] + paths = library.get("Path") + if paths is None: + raise TypeError("Emby 媒体库路径列表为空") + library_folders += [folder for folder in paths] return library_folders diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 4bd16f03d..ca5425d79 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,257 / 248 文件 | 本分支为 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,220 / 247 文件 | 本分支为 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 091a56a22..46ae0fda2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,6 +7,7 @@ follow_imports = skip show_error_codes = True pretty = True files = + app/modules/emby/emby.py, app/modules/discord/discord.py, app/modules/zspace/zspace.py, app/modules/jellyfin/jellyfin.py, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 94b85c0ed..c0403c802 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -821,17 +821,6 @@ "no-untyped-def": 1, "type-arg": 1 }, - "app/modules/emby/emby.py": { - "arg-type": 9, - "assignment": 2, - "dict-item": 1, - "no-any-return": 9, - "no-untyped-call": 2, - "no-untyped-def": 4, - "type-arg": 6, - "union-attr": 1, - "var-annotated": 3 - }, "app/modules/fanart/__init__.py": { "misc": 2, "no-any-return": 1, diff --git a/tests/test_emby_typed_boundaries.py b/tests/test_emby_typed_boundaries.py new file mode 100644 index 000000000..405605593 --- /dev/null +++ b/tests/test_emby_typed_boundaries.py @@ -0,0 +1,76 @@ +""" +验证 Emby JSON 边界保留用户标识、路径和错误语义。 +""" + +from unittest.mock import Mock + +import pytest + +from app.modules.emby.emby import Emby + + +def _client(monkeypatch: pytest.MonkeyPatch, payload: object) -> Emby: + """ + 构造注入离线响应的 Emby 客户端。 + """ + client = Emby.__new__(Emby) + client._host = "http://emby.test/" + client._apikey = "test-key" + client.user = "user" + client._username = None + client._sync_libraries = [] + response = Mock() + response.json.return_value = payload + request = Mock(get_res=Mock(return_value=response)) + monkeypatch.setattr("app.modules.emby.emby.RequestUtils", Mock(return_value=request)) + return client + + +@pytest.mark.parametrize("identifier", [None, "user-id", 123]) +def test_named_user_preserves_identifier_type(monkeypatch: pytest.MonkeyPatch, identifier: object) -> None: + """ + 按名称命中的用户标识保持字符串、整数或空值原样。 + """ + client = _client(monkeypatch, [{"Name": "admin", "Id": identifier}]) + assert client.get_user("admin") == identifier + + +@pytest.mark.parametrize("value", [None, "", "/media/movie.mkv"]) +def test_server_id_preserves_nullable_text(monkeypatch: pytest.MonkeyPatch, value: object) -> None: + """ + 服务器标识的空值及文本值不被强制转换。 + """ + client = _client(monkeypatch, {"Id": value}) + assert client.get_server_id() == (value or None) + + +def test_get_user_count_rejects_non_integer_payload(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 用户数量响应为非整数时进入现有异常处理并返回空值。 + """ + client = _client(monkeypatch, {"TotalRecordCount": "many"}) + assert client.get_user_count() == 0 + + +def test_custom_request_integer_user_preserves_type_error(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 非零整数用户标识仍在 URL 替换阶段抛出 TypeError。 + """ + client = _client(monkeypatch, {}) + client.user = 123 + with pytest.raises(TypeError): + client.get_data("[HOST]Users/[USER]?api_key=[APIKEY]") + # URL 替换在构造请求前失败,因此不会建立请求对象。 + + + +def test_library_folders_missing_and_empty_paths(monkeypatch: pytest.MonkeyPatch) -> None: + """ + 缺少路径列表保留 TypeError,合法空路径列表返回空结果。 + """ + client = _client(monkeypatch, {}) + monkeypatch.setattr(client, "get_emby_virtual_folders", Mock(return_value=[{"Id": "library"}])) + with pytest.raises(TypeError): + client.get_user_library_folders() + monkeypatch.setattr(client, "get_emby_virtual_folders", Mock(return_value=[{"Id": "library", "Path": []}])) + assert client.get_user_library_folders() == []