diff --git a/app/modules/_base/mediaserver.py b/app/modules/_base/mediaserver.py index 061c993f5..205d3bacf 100644 --- a/app/modules/_base/mediaserver.py +++ b/app/modules/_base/mediaserver.py @@ -23,7 +23,8 @@ def emby_query_params(query: Optional[MediaServerQueryOptions]) -> dict[str, str """ 把显式投影翻译为 Emby/Jellyfin 已公开的列表查询控制参数。 - 名称、类型、年份等基础字段由服务端默认返回;Fields 只追加当前投影所需的可选字段。 + 名称、类型由服务端默认返回;年份必须通过 Fields 显式请求,避免精简查询丢失匹配和同步依据。 + Fields 只追加当前投影所需的字段。 音乐完整性匹配需要 ChildCount,即使未请求额外统计也不能删除它。 :param query: 调用方投影;None 或 fields=None 时不改变旧请求 @@ -34,6 +35,7 @@ def emby_query_params(query: Optional[MediaServerQueryOptions]) -> dict[str, str fields = { MediaServerField.LIBRARY: "ParentId", MediaServerField.ORIGINAL_TITLE: "OriginalTitle", + MediaServerField.YEAR: "ProductionYear", MediaServerField.IDENTITY: "ProviderIds", MediaServerField.PATH: "Path", MediaServerField.MUSIC: "ChildCount", diff --git a/tests/test_mediaserver_query.py b/tests/test_mediaserver_query.py index 54ae9927b..4f318bc43 100644 --- a/tests/test_mediaserver_query.py +++ b/tests/test_mediaserver_query.py @@ -10,6 +10,9 @@ import pytest from plexapi.library import MovieSection, MusicSection from pydantic import ValidationError +from app.application.mediaserver import MEDIA_SERVER_EXISTENCE_QUERY, MediaServerSyncItem +from app.chain.mediaserver import _SYNC_QUERY +from app.modules._base.mediaserver import emby_query_params from app.modules.emby import EmbyModule from app.modules.emby.emby import Emby from app.modules.jellyfin import JellyfinModule @@ -117,6 +120,114 @@ def _movie(): } +@pytest.mark.parametrize( + "query,expected_fields", + [ + (MediaServerQueryOptions(fields={MediaServerField.YEAR}), {"ProductionYear"}), + (MEDIA_SERVER_EXISTENCE_QUERY, {"ProviderIds", "ProductionYear"}), + (_SYNC_QUERY, {"ParentId", "OriginalTitle", "ProductionYear", "ProviderIds", "Path", "ChildCount"}), + ], + ids=["year-only", "existence", "sync"], +) +def test_emby_projection_explicitly_requests_year(query, expected_fields): + """ + 年份投影必须显式追加 ProductionYear,不能依赖服务端默认返回。 + + :param query: 年份单字段、实际查重或同步使用的投影 + :param expected_fields: 当前投影必须发送的字段集合 + """ + params = emby_query_params(query) + + assert set(params["Fields"].split(",")) == expected_fields + assert params["EnableImages"] == "false" + assert params["EnableUserData"] == "false" + + +@pytest.fixture +def year_projected_client(emby_client): + """ + 模拟只有请求 ProductionYear 才返回年份的 Emby/Jellyfin 服务器。 + + :param emby_client: 无网络客户端及传输替身 + :return: 客户端及按请求字段裁剪年份的传输替身 + """ + client, transport = emby_client + + def respond(_url, params): + """ + 按字段请求生成媒体列表响应,涵盖电影和电视剧同步。 + + :param _url: 客户端请求地址 + :param params: 实际发往服务端的查询参数 + :return: 根据 Fields 决定是否带有年份的成功响应 + """ + movie = _movie() + if "ProductionYear" not in params.get("Fields", "").split(","): + movie.pop("ProductionYear") + series = {**movie, "Id": "series", "Type": "Series"} + items = [movie] if params.get("Ids") or params.get("SearchTerm") or params.get("searchTerm") else [movie, series] + return _response({"Items": items}) + + transport.get_res.side_effect = respond + return client, transport + + +@pytest.mark.parametrize("year,media_id,expected", [(2026, "123", True), (2025, "123", False), (2026, "999", False)]) +def test_existence_projection_preserves_movie_year_matching(year_projected_client, year, media_id, expected): + """ + 实际查重投影能命中同年份同身份电影,并继续排除年份或身份冲突。 + + :param year_projected_client: 按请求字段返回年份的客户端及传输替身 + :param year: 查重要求的年份 + :param media_id: 查重要求的 TMDB 标识 + :param expected: 是否应匹配库内电影 + """ + client, _ = year_projected_client + + items = client.get_movies( + title="示例电影", year=str(year), media_source=MediaSource.TMDB, media_id=media_id, + query=MEDIA_SERVER_EXISTENCE_QUERY, + ) + + assert bool(items) is expected + if expected: + assert items[0].item_id == "movie" + assert str(items[0].year) == "2026" + + +def test_existence_projection_preserves_detail_year(year_projected_client): + """ + 缓存条目按 ID 实时复核时也必须取回年份。 + + :param year_projected_client: 按请求字段返回年份的客户端及传输替身 + """ + client, transport = year_projected_client + + item = client.get_iteminfo("movie", query=MEDIA_SERVER_EXISTENCE_QUERY) + + assert item.media_id == "123" + assert str(item.year) == "2026" + assert transport.get_res.call_args.args[1]["Ids"] == "movie" + + +def test_sync_projection_preserves_movie_and_series_year(year_projected_client): + """ + 实际同步投影把电影和电视剧年份保留到持久化写端快照。 + + :param year_projected_client: 按请求字段返回年份的客户端及传输替身 + """ + client, _ = year_projected_client + + items = list(client.get_items("library", query=_SYNC_QUERY)) + snapshots = [ + MediaServerSyncItem.from_item(item, item_type=item.item_type, seasoninfo={}, sync_time="2026-10-20 12:00:00") + for item in items + ] + + assert {item.item_type for item in snapshots} == {"Movie", "Series"} + assert all(str(item.year) == "2026" for item in snapshots) + + @pytest.mark.parametrize("query", [None, MediaServerQueryOptions()]) def test_default_query_preserves_list_request_and_playback_state(emby_client, query): """