fix(types): preserve Plex SDK return contracts

This commit is contained in:
jxxghp
2026-10-17 20:12:48 +08:00
parent 16723f03a4
commit f1e09b40d0
5 changed files with 121 additions and 41 deletions

View File

@@ -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 客户端。
"""

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

View File

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

View File

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

View File

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