mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix: avoid Plex sync detail reloads and preserve cache on read failure
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, call, patch
|
||||
from xml.etree.ElementTree import fromstring
|
||||
|
||||
import pytest
|
||||
from plexapi.library import MovieSection
|
||||
from plexapi.video import Movie # pylint: disable=unused-import
|
||||
|
||||
from app.modules.plex import plex as plex_module
|
||||
from app.modules.plex.plex import Plex
|
||||
@@ -11,13 +14,16 @@ class _ReadTimeout(Exception):
|
||||
"""模拟 PlexAPI 底层 HTTP 客户端的 ReadTimeout。"""
|
||||
|
||||
|
||||
def _make_client(section: Mock) -> Plex:
|
||||
def _make_client(section: Mock | MovieSection) -> Plex:
|
||||
"""构造不建立真实网络连接的 Plex 客户端。"""
|
||||
client = Plex.__new__(Plex)
|
||||
client._plex = SimpleNamespace(
|
||||
library=SimpleNamespace(sectionByID=Mock(return_value=section))
|
||||
)
|
||||
client._Plex__build_media_server_item = lambda item: item
|
||||
if isinstance(section, Mock):
|
||||
section.TYPE = "movie"
|
||||
section._buildSearchKey.return_value = "/library/sections/1/all?includeGuids=1&type=1"
|
||||
return client
|
||||
|
||||
|
||||
@@ -76,30 +82,42 @@ def test_plex_sync_reads_full_library_in_pages_and_retries_timeouts(monkeypatch)
|
||||
monkeypatch.setattr(plex_module.time, "sleep", lambda _seconds: None)
|
||||
items = [SimpleNamespace(key=f"item-{index}") for index in range(3)]
|
||||
section = Mock()
|
||||
section.all.side_effect = [_ReadTimeout("read timeout"), items[:2], items[2:]]
|
||||
section.fetchItems.side_effect = [_ReadTimeout("read timeout"), items[:2], items[2:]]
|
||||
client = _make_client(section)
|
||||
|
||||
result = list(client.get_items("1"))
|
||||
|
||||
assert result == items
|
||||
assert section.all.call_args_list == [
|
||||
call(container_start=0, container_size=2, maxresults=2),
|
||||
call(container_start=0, container_size=2, maxresults=2),
|
||||
call(container_start=2, container_size=2, maxresults=2),
|
||||
options = dict(params={
|
||||
"excludeElements": plex_module.PLEX_SYNC_EXCLUDE_ELEMENTS,
|
||||
"skipRefresh": 1,
|
||||
})
|
||||
assert section.fetchItems.call_args_list == [
|
||||
call(section._buildSearchKey.return_value, container_start=0, container_size=2,
|
||||
maxresults=2, **options),
|
||||
call(section._buildSearchKey.return_value, container_start=0, container_size=2,
|
||||
maxresults=2, **options),
|
||||
call(section._buildSearchKey.return_value, container_start=2, container_size=2,
|
||||
maxresults=2, **options),
|
||||
]
|
||||
section._buildSearchKey.assert_called_once_with(libtype="movie")
|
||||
|
||||
|
||||
def test_plex_sync_honors_explicit_page_start_and_limit():
|
||||
"""显式分页调用应只读取请求的起点和条目数。"""
|
||||
items = [SimpleNamespace(key=f"item-{index}") for index in range(2)]
|
||||
section = Mock()
|
||||
section.all.return_value = items
|
||||
section.fetchItems.return_value = items
|
||||
client = _make_client(section)
|
||||
|
||||
result = list(client.get_items("1", start_index=8, limit=2))
|
||||
|
||||
assert result == items
|
||||
section.all.assert_called_once_with(container_start=8, container_size=2, maxresults=2)
|
||||
section.fetchItems.assert_called_once_with(
|
||||
section._buildSearchKey.return_value,
|
||||
container_start=8, container_size=2, maxresults=2,
|
||||
params={"excludeElements": plex_module.PLEX_SYNC_EXCLUDE_ELEMENTS, "skipRefresh": 1},
|
||||
)
|
||||
|
||||
|
||||
def test_plex_sync_retries_item_conversion_timeout(monkeypatch):
|
||||
@@ -108,7 +126,7 @@ def test_plex_sync_retries_item_conversion_timeout(monkeypatch):
|
||||
monkeypatch.setattr(plex_module.time, "sleep", lambda _seconds: None)
|
||||
item = SimpleNamespace(key="item-1")
|
||||
section = Mock()
|
||||
section.all.return_value = [item]
|
||||
section.fetchItems.return_value = [item]
|
||||
client = _make_client(section)
|
||||
convert = Mock(side_effect=[_ReadTimeout("read timeout"), "converted-item"])
|
||||
monkeypatch.setattr(client, "_Plex__build_media_server_item", convert)
|
||||
@@ -123,10 +141,73 @@ def test_plex_sync_raises_after_all_page_timeout_retries(monkeypatch):
|
||||
"""分页超时重试耗尽时应中止整库同步,不能静默跳过页面。"""
|
||||
monkeypatch.setattr(plex_module.time, "sleep", lambda _seconds: None)
|
||||
section = Mock()
|
||||
section.all.side_effect = _ReadTimeout("read timeout")
|
||||
section.fetchItems.side_effect = _ReadTimeout("read timeout")
|
||||
client = _make_client(section)
|
||||
|
||||
with pytest.raises(_ReadTimeout, match="read timeout"):
|
||||
list(client.get_items("1"))
|
||||
|
||||
assert section.all.call_count == plex_module.PLEX_SYNC_TIMEOUT_MAX_ATTEMPTS
|
||||
assert section.fetchItems.call_count == plex_module.PLEX_SYNC_TIMEOUT_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def test_plex_sync_raises_on_connection_failure_after_partial_page(monkeypatch):
|
||||
"""连接拒绝后不得把已读部分视作完整媒体库。"""
|
||||
monkeypatch.setattr(plex_module, "PLEX_LIBRARY_SYNC_PAGE_SIZE", 1)
|
||||
section = Mock()
|
||||
section.fetchItems.side_effect = [[SimpleNamespace(key="first")], ConnectionError("refused")]
|
||||
client = _make_client(section)
|
||||
with pytest.raises(ConnectionError, match="refused"):
|
||||
list(client.get_items("1"))
|
||||
|
||||
|
||||
def test_plex_sync_raises_on_item_conversion_failure():
|
||||
"""条目转换失败时不能跳过记录后继续执行陈旧清理。"""
|
||||
section = Mock()
|
||||
section.fetchItems.return_value = [SimpleNamespace(key="first")]
|
||||
client = _make_client(section)
|
||||
client._Plex__build_media_server_item = Mock(side_effect=ConnectionError("refused"))
|
||||
with pytest.raises(ConnectionError, match="refused"):
|
||||
list(client.get_items("1"))
|
||||
|
||||
|
||||
def test_plex_sync_raises_when_server_or_library_is_unavailable():
|
||||
"""服务器失联或媒体库消失时不得返回空库并触发旧数据清理。"""
|
||||
section = Mock()
|
||||
client = _make_client(section)
|
||||
client._plex = None
|
||||
with pytest.raises(ConnectionError, match="未连接"):
|
||||
list(client.get_items("1"))
|
||||
|
||||
client = _make_client(section)
|
||||
client._plex.library.sectionByID.return_value = None
|
||||
with pytest.raises(ValueError, match="不存在"):
|
||||
list(client.get_items("1"))
|
||||
|
||||
|
||||
def test_plex_sync_uses_list_metadata_without_reloading_each_movie():
|
||||
"""列表中的文件路径和 Guid 足够生成缓存条目,不应发起逐条详情请求。"""
|
||||
server = Mock()
|
||||
server.query.return_value = fromstring(
|
||||
'<MediaContainer size="1" totalSize="1" librarySectionID="1">'
|
||||
'<Video type="movie" key="/library/metadata/1" ratingKey="1" '
|
||||
'title="Demo" year="2026">'
|
||||
'<Media><Part file="/cloud/Demo.mkv"/></Media>'
|
||||
'<Guid id="tmdb://123"/></Video></MediaContainer>'
|
||||
)
|
||||
section = MovieSection(
|
||||
server, fromstring('<Directory key="1" type="movie" title="Movies"/>'),
|
||||
"/library/sections",
|
||||
)
|
||||
client = _make_client(section)
|
||||
del client._Plex__build_media_server_item
|
||||
|
||||
result = list(client.get_items("1"))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == "/cloud/Demo.mkv"
|
||||
assert str(result[0].media_id) == "123"
|
||||
server.query.assert_called_once_with(
|
||||
"/library/sections/1/all?includeGuids=1&type=1",
|
||||
headers={"X-Plex-Container-Start": "0", "X-Plex-Container-Size": "50"},
|
||||
params={"excludeElements": plex_module.PLEX_SYNC_EXCLUDE_ELEMENTS, "skipRefresh": 1},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user