diff --git a/app/modules/plex/plex.py b/app/modules/plex/plex.py index d1de04967..8d1827c5e 100644 --- a/app/modules/plex/plex.py +++ b/app/modules/plex/plex.py @@ -8,10 +8,13 @@ from pathlib import Path from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union from urllib.parse import quote_plus +from plexapi.audio import Album, Artist, Track from plexapi.base import PlexPartialObject from plexapi.exceptions import NotFound +from plexapi.library import Library from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer +from plexapi.video import Episode, Movie, Season, Show from requests import RequestException from app.adapters.network.http import RequestUtils @@ -103,7 +106,7 @@ class Plex: return False return True if not self._plex else False - def reconnect(self): + def reconnect(self) -> None: """ 重建 Plex SDK 客户端并刷新媒体库目录缓存;失败时清空 SDK 客户端。 """ @@ -136,7 +139,11 @@ class Plex: logger.error(f"Authentication failed: {e}") return None - @cached(maxsize=32, ttl=86400) + _image_cache: Callable[ + [Callable[..., Optional[List[str]]]], Callable[..., Optional[List[str]]] + ] = cached(maxsize=32, ttl=86400) + + @_image_cache def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]: """ 分页读取最近新增媒体,选取最多 4 张不同的库封面。 @@ -150,7 +157,7 @@ class Plex: if not self._plex: return None # 返回结果 - poster_urls = {} + poster_urls: dict[str, None] = {} # 页码计数 container_start = 0 # 需要的总条数/每页的条数 @@ -370,7 +377,7 @@ class Plex: year: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]: + season: Optional[int] = None) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]: """ 查询电视剧及其各季已有的分集编号。 @@ -413,7 +420,7 @@ class Plex: ): return None, {} episodes = videos.episodes() - season_episodes = {} + season_episodes: Dict[int, List[int]] = {} for episode in episodes: if season is not None and episode.seasonNumber != int(season): continue @@ -463,6 +470,8 @@ class Plex: :param year: 年份,可以为空,为空时不按年份过滤 :return: Plex搜索返回的电视剧条目列表 """ + if self._plex is None: + raise AttributeError("'NoneType' object has no attribute 'library'") # 兼容年份为空的场景 kwargs = {"year": year} if year else {} # 根据标题和年份搜索候选,后续仍需核对规范媒体来源身份。 @@ -491,7 +500,11 @@ class Plex: :param plex_url: 是否返回Plex的URL,默认为True(仅在配置了外网地址和Token时有效) :return: 图片对应在plex服务器或TMDB中的URL """ - if not self._plex or depth > 2 or not item_id: + if not self._plex: + return None + if depth is None: + raise TypeError("'>' not supported between instances of 'NoneType' and 'int'") + if depth > 2 or not item_id: return None try: image_url = None @@ -579,7 +592,7 @@ class Plex: return None return UrlUtils.combine_url(host=image_host, path=image_path, query=query) - def refresh_root_library(self) -> bool: + def refresh_root_library(self) -> Union[bool, Library]: """ 请求 Plex 刷新整个媒体库。 @@ -587,7 +600,8 @@ class Plex: """ if not self._plex: return False - return self._plex.library.update() + updated: Library = self._plex.library.update() + return updated def refresh_library_by_items(self, items: List[_SchemaRefreshMediaItem]) -> Optional[bool]: """ @@ -615,13 +629,13 @@ class Plex: return refreshed is not False else: # 否则一个一个刷新 - for path, lib_key in result_dict.items(): - logger.info(f"刷新媒体库:{lib_key} - {path}") - self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(Path(path).parent.as_posix())}') + for refresh_path, lib_key in result_dict.items(): + logger.info(f"刷新媒体库:{lib_key} - {refresh_path}") + self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(Path(refresh_path).parent.as_posix())}') return True @staticmethod - def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, Optional[Path]]: + def __find_librarie(path: Optional[Path], libraries: List[Any]) -> Tuple[str, Optional[Path]]: """ 按路径归属查找第一个匹配的媒体库。 @@ -670,14 +684,14 @@ class Plex: return None @staticmethod - def __get_ids(guids: List[Any]) -> dict: + def __get_ids(guids: List[Any]) -> dict[str, Union[str, int, None]]: """ 从 Plex Guid 提取 IMDb、TMDB 和 TVDB 来源标识。 :param guids: 包含 id 的字典或 SDK Guid 对象列表 :return: 含 imdb_id、tmdb_id、tvdb_id 的字典;缺失项为 None,TMDB 标识转换为整数,无法转换时忽略 """ - def parse_tmdb_id(value: str) -> tuple[bool, int]: + def parse_tmdb_id(value: str) -> tuple[bool, Optional[int]]: """ 尝试将TMDB ID字符串转换为整数。如果成功,返回(True, int),失败则返回(False, None)。 """ @@ -692,7 +706,7 @@ class Plex: "tmdb://": "tmdb_id", "tvdb://": "tvdb_id" } - ids = {varname: None for varname in guid_mapping.values()} + ids: dict[str, Union[str, int, None]] = {varname: None for varname in guid_mapping.values()} for guid in guids: guid_id = guid['id'] if isinstance(guid, dict) else guid.id for prefix, varname in guid_mapping.items(): @@ -709,7 +723,7 @@ class Plex: return ids - def __fetch_item(self, item_id: Union[int, str]): + def __fetch_item(self, item_id: Union[int, str]) -> Union[Movie, Show, Season, Episode, Artist, Album, Track]: """ 根据给定的item_id获取媒体项。 @@ -721,9 +735,12 @@ class Plex: elif not item_id.startswith("/library/metadata/"): # 其他媒体服务器的 UUID 不能作为 Plex metadata key,直接按失效缓存回退。 raise NotFound(f"invalid Plex metadata id: {item_id}") - return self._plex.fetchItem(item_id) + if self._plex is None: + raise AttributeError("'NoneType' object has no attribute 'fetchItem'") + item: Union[Movie, Show, Season, Episode, Artist, Album, Track] = self._plex.fetchItem(item_id) + return item - def __build_media_server_item(self, item) -> Optional[_SchemaMediaServerItem]: + def __build_media_server_item(self, item: Union[Movie, Show, Season, Episode, Artist, Album, Track, None]) -> Optional[_SchemaMediaServerItem]: """ 按 Plex 条目实际具备的字段构造媒体信息,兼容艺人、专辑和曲目。 @@ -965,7 +982,7 @@ class Plex: return eventItem - def get_plex(self): + def get_plex(self) -> Optional[PlexServer]: """ 提供当前 Plex SDK 客户端供扩展调用。 @@ -980,6 +997,8 @@ class Plex: :param item_id: Plex 条目标识,可以是数字标识或 /library/metadata/ 路径 :return: 以外网播放地址或服务地址为根的条目链接;调用前须已经建立 SDK 连接 """ + if self._plex is None: + raise AttributeError("'NoneType' object has no attribute 'machineIdentifier'") return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}&X-Plex-Token={self._token}' def get_resume(self, num: Optional[int] = 12) -> Optional[List[_SchemaMediaServerPlayItem]]: @@ -1002,7 +1021,7 @@ class Plex: container_size=num, maxresults=num, params=params) - ret_resume = [] + ret_resume: List[_SchemaMediaServerPlayItem] = [] for item in items: item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value if item_type == MediaType.MOVIE.value: @@ -1046,9 +1065,11 @@ class Plex: "count": num, "excludeContinueWatching": 1 } - ret_resume = [] + ret_resume: List[_SchemaMediaServerPlayItem] = [] sub_result = [] offset = 0 + if num is None: + raise TypeError("'>=' not supported between instances of 'int' and 'NoneType'") while True: if len(ret_resume) >= num: break @@ -1103,7 +1124,7 @@ class Plex: offset += num return ret_resume[:num] - def get_data(self, endpoint: str, **kwargs) -> Optional[Any]: + def get_data(self, endpoint: str, **kwargs: object) -> Optional[Any]: """ 使用复用的 HTTP 会话发送自定义 GET 请求。 @@ -1113,7 +1134,7 @@ class Plex: """ return self.__request(method="get", endpoint=endpoint, **kwargs) - def post_data(self, endpoint: str, **kwargs) -> Optional[Any]: + def post_data(self, endpoint: str, **kwargs: object) -> Optional[Any]: """ 使用复用的 HTTP 会话发送自定义 POST 请求。 @@ -1123,7 +1144,7 @@ class Plex: """ return self.__request(method="post", endpoint=endpoint, **kwargs) - def put_data(self, endpoint: str, **kwargs) -> Optional[Any]: + def put_data(self, endpoint: str, **kwargs: object) -> Optional[Any]: """ 使用复用的 HTTP 会话发送自定义 PUT 请求。 @@ -1133,7 +1154,7 @@ class Plex: """ return self.__request(method="put", endpoint=endpoint, **kwargs) - def __request(self, method: str, endpoint: str, **kwargs) -> Optional[Any]: + def __request(self, method: str, endpoint: str, **kwargs: object) -> Optional[Any]: """ 为自定义 Plex 请求补充令牌请求头,并记录请求异常。 @@ -1158,7 +1179,7 @@ class Plex: logger.error("连接Plex出错:" + str(e)) return None - def __get_request_headers(self) -> dict: + def __get_request_headers(self) -> dict[str, str]: """ 获取请求头。 """ @@ -1168,7 +1189,7 @@ class Plex: "Content-Type": "application/json" } - def close(self): + def close(self) -> None: """ 关闭自定义 HTTP 请求会话;不主动关闭 Plex SDK 客户端。 """ diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 468212205..beab5aa67 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,187 / 246 文件 | 本分支为 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,155 / 245 文件 | 本分支为 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 3899bedeb..e1177c8b2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,6 +7,7 @@ follow_imports = skip show_error_codes = True pretty = True files = + app/modules/plex/plex.py, app/modules/feishu/feishu.py, app/modules/emby/emby.py, app/modules/discord/discord.py, diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 3c9fc7971..73aa16a3e 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1073,22 +1073,9 @@ "arg-type": 7, "assignment": 16, "empty-body": 1, - "no-untyped-call": 1, "no-untyped-def": 5, "type-arg": 1 }, - "app/modules/plex/plex.py": { - "arg-type": 2, - "assignment": 2, - "misc": 1, - "no-any-return": 1, - "no-untyped-def": 9, - "operator": 5, - "return-value": 1, - "type-arg": 3, - "union-attr": 4, - "var-annotated": 3 - }, "app/modules/qqbot/gateway.py": { "no-untyped-def": 3, "type-arg": 2 diff --git a/tests/test_plex_type_contracts.py b/tests/test_plex_type_contracts.py new file mode 100644 index 000000000..58b2e4a75 --- /dev/null +++ b/tests/test_plex_type_contracts.py @@ -0,0 +1,71 @@ +""" +Plex SDK 返回值与类型收窄的异常边界回归。 +""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from app.modules.plex.plex import Plex + + +def test_root_refresh_preserves_sdk_return_identity(): + """ + 整库刷新原样返回 SDK 对象,不能转换为布尔值。 + """ + client = Plex.__new__(Plex) + result = object() + client._plex = SimpleNamespace(library=SimpleNamespace(update=Mock(return_value=result))) + assert client.refresh_root_library() is result + client._plex = None + assert client.refresh_root_library() is False + + +def test_missing_refresh_path_keeps_root_refresh_success_semantics(): + """ + 目标路径缺失时继续回退根库,SDK 空返回值仍算请求成功。 + """ + client = Plex.__new__(Plex) + update = Mock(return_value=None) + client._plex = SimpleNamespace(library=SimpleNamespace(update=update)) + client._libraries = [] + assert client.refresh_library_by_items([SimpleNamespace(target_path=None)]) is True + update.assert_called_once_with() + + +def test_optional_image_depth_preserves_short_circuit_and_error(): + """ + 未连接仍提前返回,连接后空递归深度仍在 SDK 查询前报错。 + """ + client = Plex.__new__(Plex) + client._plex = None + assert client.get_remote_image_by_id("1", "Poster", depth=None) is None + client._plex = Mock() + with pytest.raises(TypeError): + client.get_remote_image_by_id("1", "Poster", depth=None) + client._plex.fetchItem.assert_not_called() + + +def test_latest_none_count_keeps_library_query_before_error(): + """ + 空数量仍先读取库范围,只有库可用时才到比较数量的失败点。 + """ + client = Plex.__new__(Plex) + client._plex = Mock() + client.get_librarys = Mock(return_value=None) + assert client.get_latest(num=None) is None + client.get_librarys.return_value = [] + with pytest.raises(TypeError): + client.get_latest(num=None) + client._plex.fetchItems.assert_not_called() + + +def test_guid_parsing_retains_null_and_identifier_types(): + """ + 非法 TMDB 标识仍忽略,其他标识保持文本,合法 TMDB 转为整数。 + """ + assert Plex._Plex__get_ids([{"id": "tmdb://bad"}, {"id": "imdb://tt1"}]) == { + "tmdb_id": None, "imdb_id": "tt1", "tvdb_id": None, + } + assert Plex._Plex__get_ids([{"id": "tmdb://123"}])["tmdb_id"] == 123