From 2a49495e278785778cfc2ff52f3528a2685caf1a Mon Sep 17 00:00:00 2001 From: freeman Date: Mon, 13 Jul 2026 11:51:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(jellyfin):=20=E5=AA=92=E4=BD=93=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=94=B9=E4=B8=BA=E6=8C=89=E7=94=A8=E6=88=B7=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E9=80=90=E5=BA=93=E7=B4=AF=E8=AE=A1=20(#5915)=20(#610?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/modules/jellyfin/jellyfin.py | 44 +++++++++- tests/test_jellyfin_medias_count.py | 126 ++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/test_jellyfin_medias_count.py diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index 3318f63e5..3a6f324f7 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -317,10 +317,18 @@ class Jellyfin: def get_medias_count(self) -> schemas.Statistic: """ 获得电影、电视剧、动漫媒体数量 - :return: MovieCount SeriesCount SongCount + + 优先遍历用户媒体库视图逐库统计:全局 `Items/Counts` 按数据库原始条目 + 计数,同一影片在库内有多个版本/多个文件夹拷贝时会重复累计(#5915), + 而用户级 `Users/{user}/Items` 查询会折叠版本,与 Jellyfin 页面显示一致。 + 仅在用户视图不可用时回退到 `Items/Counts`。 + :return: MovieCount SeriesCount EpisodeCount """ if not self._host or not self._apikey: return schemas.Statistic() + stat = self.__count_medias_by_librarys() + if stat is not None: + return stat url = f"{self._host}Items/Counts" params = { 'api_key': self._apikey @@ -341,6 +349,32 @@ class Jellyfin: logger.error(f"连接Items/Counts出错:" + str(e)) return schemas.Statistic() + def __count_medias_by_librarys(self) -> Optional[schemas.Statistic]: + """ + 遍历用户媒体库视图逐库统计媒体数量 + + `Users/{user}/Views` 每个媒体库仅返回一条记录(库包含多个文件夹时 + 也不会重复),按 `CollectionType` 分桶后用用户级条目查询累计。 + :return: 统计结果,用户或媒体库视图不可用时返回None(由调用方回退) + """ + if not self.user: + return None + librarys = self.__get_jellyfin_librarys() + if not librarys: + return None + stat = schemas.Statistic() + for library in librarys: + library_id = library.get("Id") + if not library_id: + continue + collection_type = library.get("CollectionType") + if collection_type == "movies": + stat.movie_count += self.get_items_count(library_id, include_item_types="Movie") or 0 + elif collection_type == "tvshows": + stat.tv_count += self.get_items_count(library_id, include_item_types="Series") or 0 + stat.episode_count += self.get_items_count(library_id, include_item_types="Episode") or 0 + return stat + def __get_jellyfin_series_id_by_name(self, name: str, year: str) -> Optional[str]: """ 根据名称查询Jellyfin中剧集的SeriesId @@ -809,11 +843,13 @@ class Jellyfin: logger.error(f"连接Users/{self.user}/Items/{itemid}:" + str(e)) return None - def get_items_count(self, parent: Union[str, int]) -> Optional[int]: + def get_items_count(self, parent: Union[str, int], + include_item_types: str = "Movie,Series") -> Optional[int]: """ - 获取指定媒体库可同步的电影和剧集总数 + 获取指定媒体库可同步的媒体条目总数 :param parent: 媒体库ID + :param include_item_types: 统计的条目类型,默认电影和剧集 :return: 媒体条目总数,查询失败时返回None """ if not parent or not self._host or not self._apikey or not self.user: @@ -822,7 +858,7 @@ class Jellyfin: params = { "ParentId": parent, "Recursive": "true", - "IncludeItemTypes": "Movie,Series", + "IncludeItemTypes": include_item_types, "Limit": 0, "api_key": self._apikey, } diff --git a/tests/test_jellyfin_medias_count.py b/tests/test_jellyfin_medias_count.py new file mode 100644 index 000000000..399a03e09 --- /dev/null +++ b/tests/test_jellyfin_medias_count.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +from unittest.mock import patch + +from app.modules.jellyfin.jellyfin import Jellyfin + + +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 _make_client(user: str = "user-id") -> Jellyfin: + """构造跳过初始化的 Jellyfin 客户端。""" + client = Jellyfin.__new__(Jellyfin) + client._host = "http://media.local/" + client._apikey = "token" + client.user = user + client._sync_libraries = [] + return client + + +def _routed_get_res(views: dict, counts: dict, global_counts: dict = None): + """按 URL 分发的 get_res 模拟:视图列表、单库统计与全局统计。""" + + def _get_res(url, params=None, **_kwargs): + if url.endswith("/Views"): + return _FakeResponse(views) + if url.endswith("/Items") and params: + key = (params.get("ParentId"), params.get("IncludeItemTypes")) + return _FakeResponse({"TotalRecordCount": counts.get(key, 0)}) + if url.endswith("Items/Counts"): + return _FakeResponse(global_counts or {}) + raise AssertionError(f"意外的请求地址:{url}") + + return _get_res + + +def test_medias_count_deduplicates_multi_folder_library(): + """多文件夹电影库应按用户视图统计,避免版本重复计数(#5915)。""" + client = _make_client() + views = {"Items": [{"Id": "lib-movie", "CollectionType": "movies"}]} + # 用户级查询会折叠同一影片的多个版本,返回 67 而非数据库原始行数 201 + counts = {("lib-movie", "Movie"): 67} + + with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.side_effect = _routed_get_res( + views, counts, global_counts={"MovieCount": 201} + ) + stat = client.get_medias_count() + + assert stat.movie_count == 67 + assert stat.tv_count == 0 + assert stat.episode_count == 0 + + +def test_medias_count_buckets_by_collection_type(): + """电影与剧集库应按视图类型分桶累计,未知类型库不参与统计。""" + client = _make_client() + views = { + "Items": [ + {"Id": "lib-movie", "CollectionType": "movies"}, + {"Id": "lib-tv", "CollectionType": "tvshows"}, + {"Id": "lib-music", "CollectionType": "music"}, + {"Id": None, "CollectionType": "movies"}, + ] + } + counts = { + ("lib-movie", "Movie"): 12, + ("lib-tv", "Series"): 3, + ("lib-tv", "Episode"): 45, + } + + with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.side_effect = _routed_get_res( + views, counts + ) + stat = client.get_medias_count() + + assert stat.movie_count == 12 + assert stat.tv_count == 3 + assert stat.episode_count == 45 + + +def test_medias_count_falls_back_without_user(): + """无可用用户时应回退到全局 Items/Counts 统计。""" + client = _make_client(user=None) + + with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.return_value = _FakeResponse( + {"MovieCount": 5, "SeriesCount": 2, "EpisodeCount": 30} + ) + stat = client.get_medias_count() + + assert stat.movie_count == 5 + assert stat.tv_count == 2 + assert stat.episode_count == 30 + args = request_utils_cls.return_value.get_res.call_args.args + assert args[0] == "http://media.local/Items/Counts" + + +def test_medias_count_falls_back_when_views_unavailable(): + """媒体库视图查询失败时应回退到全局 Items/Counts 统计。""" + client = _make_client() + + def _get_res(url, params=None, **_kwargs): + if url.endswith("/Views"): + return None + if url.endswith("Items/Counts"): + return _FakeResponse({"MovieCount": 7, "SeriesCount": 1, "EpisodeCount": 9}) + raise AssertionError(f"意外的请求地址:{url}") + + with patch("app.modules.jellyfin.jellyfin.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.side_effect = _get_res + stat = client.get_medias_count() + + assert stat.movie_count == 7 + assert stat.tv_count == 1 + assert stat.episode_count == 9