From 8ad8b5eaad5367030e75cb3b5a95b972286a6308 Mon Sep 17 00:00:00 2001 From: ch3njun Date: Mon, 15 Jun 2026 22:05:17 +0800 Subject: [PATCH] fix(zspace): sync complete media library metadata (#5953) --- app/modules/zspace/zspace.py | 72 +++++++++----- tests/test_zspace_sync_items.py | 167 ++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 tests/test_zspace_sync_items.py diff --git a/app/modules/zspace/zspace.py b/app/modules/zspace/zspace.py index 3a875ba3..ce8f9649 100644 --- a/app/modules/zspace/zspace.py +++ b/app/modules/zspace/zspace.py @@ -15,6 +15,9 @@ from app.utils.http import RequestUtils from app.utils.url import UrlUtils +DEFAULT_ITEMS_PAGE_SIZE = 100 + + class ZSpace: _host: Optional[str] = None _playhost: Optional[str] = None @@ -826,30 +829,53 @@ class ZSpace: if not parent or not self._host or not self._apikey or not self.user: return None url = f"{self._host}emby/Users/{self.user}/Items" - params = { - "ParentId": parent, - "Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId" - } - if limit is not None and limit != -1: - params.update({ - "StartIndex": start_index, - "Limit": limit - }) - try: - res = self.__request_utils().get_res(url, params=params) - if not res or res.status_code != 200: - return None - items = res.json().get("Items") or [] - for item in items: - if not item: - continue - if "Folder" in item.get("Type"): - for sub_item in self.get_items(parent=item.get('Id')) or []: - yield sub_item - elif item.get("Type") in ["Movie", "Series"]: + fetch_all = limit is None or limit == -1 + page_size = DEFAULT_ITEMS_PAGE_SIZE if fetch_all else limit + current_start_index = max(start_index or 0, 0) + while True: + params = { + "ParentId": parent, + "Recursive": "true", + "StartIndex": current_start_index, + "Limit": page_size, + "Fields": "ProviderIds,OriginalTitle,ProductionYear,Path," + "UserDataPlayCount,UserDataLastPlayedDate,ParentId" + } + try: + res = self.__request_utils().get_res(url, params=params) + if not res or res.status_code != 200: + return None + result = res.json() or {} + items = result.get("Items") or [] + for item in items: + if not item or item.get("Type") not in ["Movie", "Series"]: + continue + provider_ids = item.get("ProviderIds") or {} + needs_detail = ( + not provider_ids.get("Tmdb") + or not item.get("ProductionYear") + or not item.get("Path") + ) + if needs_detail and item.get("Id"): + detail_item = self.get_iteminfo(item.get("Id")) + if detail_item: + yield detail_item + continue yield self.__format_item_info(item) - except Exception as e: - logger.error(f"连接Users/Items出错:{e}") + except Exception as e: + logger.error(f"连接Users/Items出错:{e}") + return None + + if not fetch_all: + break + current_start_index += len(items) + total_count = result.get("TotalRecordCount") + if not items or ( + total_count is not None and current_start_index >= total_count + ) or ( + total_count is None and len(items) < page_size + ): + break return None def get_webhook_message(self, form: Any, args: dict) -> Optional[schemas.WebhookEventInfo]: diff --git a/tests/test_zspace_sync_items.py b/tests/test_zspace_sync_items.py new file mode 100644 index 00000000..f72baabd --- /dev/null +++ b/tests/test_zspace_sync_items.py @@ -0,0 +1,167 @@ +from unittest.mock import patch + +from app.modules.zspace.zspace import ZSpace + + +class _FakeResponse: + """模拟极影视 HTTP 响应。""" + + def __init__(self, payload: dict, status_code: int = 200): + self._payload = payload + self.status_code = status_code + + def json(self) -> dict: + """返回模拟的 JSON 数据。""" + return self._payload + + +def _build_client() -> ZSpace: + """构造不触发登录流程的极影视客户端。""" + client = ZSpace.__new__(ZSpace) + client._host = "http://zspace.local/" + client._apikey = "zspace-token" + client.user = "user-id" + return client + + +def test_get_items_fetches_all_recursive_pages() -> None: + """全量同步应递归查询并持续翻页,且忽略合集外壳。""" + client = _build_client() + responses = [ + _FakeResponse({ + "Items": [ + { + "Id": "movie-1", + "Type": "Movie", + "Name": "电影一", + "ProductionYear": 2025, + "ProviderIds": {"Tmdb": "101"}, + "Path": "/media/movie-1.mkv", + }, + { + "Id": "collection-1", + "Type": "BoxSet", + "Name": "电影合集", + }, + ], + "TotalRecordCount": 3, + }), + _FakeResponse({ + "Items": [ + { + "Id": "series-1", + "Type": "Series", + "Name": "剧集一", + "ProductionYear": 2024, + "ProviderIds": {"Tmdb": "202"}, + "Path": "/media/series-1", + }, + ], + "TotalRecordCount": 3, + }), + ] + + with patch("app.modules.zspace.zspace.DEFAULT_ITEMS_PAGE_SIZE", 2), patch( + "app.modules.zspace.zspace.RequestUtils" + ) as request_utils_cls: + request_utils_cls.return_value.get_res.side_effect = responses + items = list(client.get_items(parent="library-id")) + + assert [item.item_id for item in items] == ["movie-1", "series-1"] + calls = request_utils_cls.return_value.get_res.call_args_list + assert calls[0].kwargs["params"]["Recursive"] == "true" + assert calls[0].kwargs["params"]["StartIndex"] == 0 + assert calls[0].kwargs["params"]["Limit"] == 2 + assert calls[1].kwargs["params"]["StartIndex"] == 2 + assert calls[1].kwargs["params"]["Limit"] == 2 + + +def test_get_items_loads_detail_when_list_metadata_is_incomplete() -> None: + """列表项缺少关键元数据时应使用详情接口补全。""" + client = _build_client() + responses = [ + _FakeResponse({ + "Items": [ + { + "Id": "movie-1", + "Type": "Movie", + "Name": "疯狂动物城", + "ProductionYear": None, + "ProviderIds": {}, + "Path": "", + } + ], + "TotalRecordCount": 1, + }), + _FakeResponse({ + "Id": "movie-1", + "ParentId": "library-id", + "Type": "Movie", + "Name": "疯狂动物城", + "OriginalTitle": "Zootopia", + "ProductionYear": 2016, + "ProviderIds": { + "Tmdb": "269149", + "Imdb": "tt2948356", + }, + "Path": "/media/疯狂动物城 (2016)/疯狂动物城.mkv", + }), + ] + + with patch("app.modules.zspace.zspace.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.side_effect = responses + items = list(client.get_items(parent="library-id")) + + assert len(items) == 1 + assert items[0].item_id == "movie-1" + assert items[0].tmdbid == 269149 + assert items[0].imdbid == "tt2948356" + assert items[0].year == 2016 + assert items[0].path.endswith("疯狂动物城.mkv") + assert request_utils_cls.return_value.get_res.call_args_list[1].args[0] == ( + "http://zspace.local/emby/Users/user-id/Items/movie-1" + ) + + +def test_get_items_uses_total_count_when_server_returns_short_pages() -> None: + """服务端单页少于请求数量时应以总记录数决定是否继续翻页。""" + client = _build_client() + responses = [ + _FakeResponse({ + "Items": [ + { + "Id": "movie-1", + "Type": "Movie", + "Name": "电影一", + "ProductionYear": 2025, + "ProviderIds": {"Tmdb": "101"}, + "Path": "/media/movie-1.mkv", + } + ], + "TotalRecordCount": 2, + }), + _FakeResponse({ + "Items": [ + { + "Id": "movie-2", + "Type": "Movie", + "Name": "电影二", + "ProductionYear": 2024, + "ProviderIds": {"Tmdb": "102"}, + "Path": "/media/movie-2.mkv", + } + ], + "TotalRecordCount": 2, + }), + ] + + with patch("app.modules.zspace.zspace.DEFAULT_ITEMS_PAGE_SIZE", 100), patch( + "app.modules.zspace.zspace.RequestUtils" + ) as request_utils_cls: + request_utils_cls.return_value.get_res.side_effect = responses + items = list(client.get_items(parent="library-id")) + + assert [item.item_id for item in items] == ["movie-1", "movie-2"] + calls = request_utils_cls.return_value.get_res.call_args_list + assert calls[0].kwargs["params"]["StartIndex"] == 0 + assert calls[1].kwargs["params"]["StartIndex"] == 1