diff --git a/app/modules/plex/plex.py b/app/modules/plex/plex.py index 8d1827c5e..2802436b4 100644 --- a/app/modules/plex/plex.py +++ b/app/modules/plex/plex.py @@ -1005,6 +1005,8 @@ class Plex: """ 获取同步范围内可以继续观看的媒体。 + 只使用列表已有字段,缺失的年份、图片或时长不触发逐条详情请求。 + :param num: 最多返回条目数量,默认 12 :return: 包含观看进度百分比的媒体列表;未连接或无法读取媒体库范围时返回 None,SDK 请求异常向上传播 """ @@ -1023,6 +1025,9 @@ class Plex: params=params) ret_resume: List[_SchemaMediaServerPlayItem] = [] for item in items: + if isinstance(item, PlexPartialObject): + # SDK 会为缺失属性同步重载详情,继续观看展示不能等待额外的媒体探测。 + item._autoReload = False item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value if item_type == MediaType.MOVIE.value: title = item.title diff --git a/tests/test_plex_resume.py b/tests/test_plex_resume.py new file mode 100644 index 000000000..b1fd04ac1 --- /dev/null +++ b/tests/test_plex_resume.py @@ -0,0 +1,81 @@ +""" +继续观看直接使用列表字段,缺失的可选信息不能触发逐条详情请求。 +""" + +from types import SimpleNamespace +from typing import Optional +from unittest.mock import Mock +from urllib.parse import urlsplit + +import pytest +from plexapi.server import PlexServer + +from app.modules.plex.plex import Plex + + +@pytest.mark.parametrize( + ("attributes", "expected_subtitle", "expected_image", "expected_percent"), + [ + ('type="movie" viewOffset="1000"', None, None, 0), + ('type="movie" year="2026" duration="10000" art="/art.jpg"', "2026", "/art.jpg", 0), + ('type="movie" year="2026" viewOffset="1000" art="/art.jpg"', "2026", "/art.jpg", 0), + ('type="movie" year="2026" art="/art.jpg" viewOffset="4000" duration="10000"', + "2026", "/art.jpg", 40), + ('type="episode" grandparentTitle="剧集" parentIndex="1" index="2" ' + 'grandparentArt="/show-art.jpg" viewOffset="1000"', + "S1:E2 - 测试条目", "/show-art.jpg", 0), + ('type="episode" grandparentTitle="剧集" parentIndex="1" index="2" ' + 'duration="10000" viewOffset="2500"', "S1:E2 - 测试条目", None, 25), + ], + ids=["missing-year-duration-art", "missing-offset", "missing-duration", "complete-movie", + "episode-fallback-art-missing-duration", "episode-missing-art"], +) +def test_resume_uses_hub_fields_without_detail_requests( + attributes: str, expected_subtitle: Optional[str], expected_image: Optional[str], expected_percent: int, +) -> None: + """ + 使用真实 PlexAPI 解析列表,禁止缺字段时请求详情,同时保留图片回退与观看进度。 + """ + session = Mock() + + def respond(url: str, **_kwargs: object) -> SimpleNamespace: + path = urlsplit(url).path + if path == "/": + text = '' + elif path == "/hubs/continueWatching/items": + text = ( + '' + f'' + ) + else: + raise AssertionError(f"列表展示不应额外请求 {path}") + return SimpleNamespace(status_code=200, text=text) + + session.get.side_effect = respond + session.get.__name__ = "get" + server = PlexServer("http://plex.test:32400", "test-token", session=session) + session.get.reset_mock() + client = Plex.__new__(Plex) + client._plex = server + client._host = "http://plex.test:32400/" + client._playhost = None + client._token = "test-token" + client.get_librarys = Mock(return_value=[SimpleNamespace(id="2")]) + + result = client.get_resume(8) + + assert len(result) == 1 + assert result[0].id == "/library/metadata/1" + assert result[0].subtitle == expected_subtitle + assert result[0].percent == expected_percent + assert result[0].image == ( + f"http://plex.test:32400{expected_image}?X-Plex-Token=test-token" if expected_image else None + ) + assert "/server/test-server/details?key=/library/metadata/1" in result[0].link + client.get_librarys.assert_called_once_with(hidden=True) + session.get.assert_called_once() + call = session.get.call_args + assert urlsplit(call.args[0]).path == "/hubs/continueWatching/items" + assert call.kwargs["params"] == {"contentDirectoryID": "2"} + assert call.kwargs["headers"]["X-Plex-Container-Size"] == "8"