From 4264aef48059867cff2557c45a6d90209d628111 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 26 Sep 2026 15:15:21 +0800 Subject: [PATCH] fix(emby): use series identity in TV webhooks --- app/modules/emby/emby.py | 31 +++++++++-- tests/test_webhook_media_identity.py | 78 +++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index dd03f2587..092cca968 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -756,6 +756,26 @@ class Emby: logger.error(f"连接/Users/{self.user}/Items/{itemid}出错:" + str(e)) return None + def _get_series_identity( + self, + series_id: Optional[str], + ) -> Tuple[Optional[MediaSource], Optional[str]]: + """查询 Emby Series 条目的规范身份,查询失败时返回空身份。""" + if not series_id or not self._host or not self._apikey or not self.user: + return None, None + url = f"{self._host}emby/Users/{self.user}/Items/{series_id}" + params = { + "api_key": self._apikey, + "Fields": "ProviderIds", + } + try: + res = RequestUtils().get_res(url, params) + if res and res.status_code == 200: + return MediaServerIdentityHelper.from_provider_ids(res.json().get("ProviderIds")) + except Exception as e: + logger.error(f"获取 Emby 剧集身份出错:{str(e)}") + return None, None + def get_items_count(self, parent: Union[str, int], include_item_types: str = "Movie,Series") -> Optional[int]: """ 获取指定媒体库可同步的电影和剧集总数 @@ -1063,6 +1083,8 @@ class Emby: "PlaylistLength": 40 } } + + Episode 和 Season 的 ProviderIds 可能标识单集或季;统一媒体身份应从 SeriesId 对应的剧集条目读取。 """ if not form and not args: return None @@ -1117,9 +1139,12 @@ class Emby: eventItem.item_id = message.get('Item', {}).get('Id') eventItem.item_path = message.get('Item', {}).get('Path') - eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids( - message.get('Item', {}).get('ProviderIds') - ) + if message.get('Item', {}).get('Type') in {"Episode", "Season"}: + eventItem.media_source, eventItem.media_id = self._get_series_identity(eventItem.item_id) + else: + eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids( + message.get('Item', {}).get('ProviderIds') + ) if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100: eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..." else: diff --git a/tests/test_webhook_media_identity.py b/tests/test_webhook_media_identity.py index 5525a9b8b..7e53d2c5c 100644 --- a/tests/test_webhook_media_identity.py +++ b/tests/test_webhook_media_identity.py @@ -1,11 +1,12 @@ import json +from unittest.mock import Mock import pytest from app.modules.emby.emby import Emby from app.modules.jellyfin.jellyfin import Jellyfin from app.modules.zspace.zspace import ZSpace -from app.schemas import WebhookEventInfo +from app.schemas.mediaserver import WebhookEventInfo from app.schemas.types import MediaSource @@ -79,3 +80,78 @@ def test_emby_family_webhook_uses_provider_identity_pair(client_class: type) -> assert event is not None assert event.media_source == MediaSource.Douban assert event.media_id == "2002" + + +@pytest.mark.parametrize("item_type", ["Episode", "Season"]) +def test_emby_tv_webhook_uses_series_provider_identity(monkeypatch, item_type: str) -> None: + """Episode 和 Season webhook 应使用 Series 条目的媒体主身份。""" + client = Emby.__new__(Emby) + get_series_identity = Mock(return_value=(MediaSource.TMDB, "286686")) + monkeypatch.setattr(client, "_get_series_identity", get_series_identity, raising=False) + monkeypatch.setattr(client, "get_remote_image_by_id", lambda **kwargs: None) + + event = client.get_webhook_message({ + "data": json.dumps({ + "Event": "playback.start", + "Item": { + "Type": item_type, + "Id": "child-item-1", + "Name": "第 24 集", + "SeriesName": "生逢其时", + "SeriesId": "series-item-1", + "ProviderIds": {"Tmdb": "7750138"}, + }, + }) + }, {}) + + assert event is not None + get_series_identity.assert_called_once_with("series-item-1") + assert event.item_id == "series-item-1" + assert event.media_source == MediaSource.TMDB + assert event.media_id == "286686" + + +def test_emby_tv_webhook_keeps_identity_empty_when_series_lookup_fails(monkeypatch) -> None: + """Series 身份查询失败时不得回退为 Episode 或 Season 的 ProviderIds。""" + client = Emby.__new__(Emby) + monkeypatch.setattr(client, "_get_series_identity", Mock(return_value=(None, None)), raising=False) + monkeypatch.setattr(client, "get_remote_image_by_id", lambda **kwargs: None) + + event = client.get_webhook_message({ + "data": json.dumps({ + "Event": "playback.start", + "Item": { + "Type": "Episode", + "Id": "episode-item-1", + "Name": "第 24 集", + "SeriesId": "series-item-1", + "ProviderIds": {"Tmdb": "7750138"}, + }, + }) + }, {}) + + assert event is not None + assert event.item_id == "series-item-1" + assert event.media_source is None + assert event.media_id is None + + +def test_emby_series_identity_requests_provider_ids(monkeypatch) -> None: + """系列身份查询应只请求 Series 的 ProviderIds 并选择规范来源。""" + client = Emby.__new__(Emby) + client._host = "http://emby.local/" + client._apikey = "test-key" + client.user = "user-1" + request = Mock() + response = Mock(status_code=200) + response.json.return_value = {"ProviderIds": {"Tmdb": "286686", "Tvdb": "468626"}} + request.get_res.return_value = response + monkeypatch.setattr("app.modules.emby.emby.RequestUtils", lambda: request) + + identity = client._get_series_identity("series-item-1") + + assert identity == (MediaSource.TMDB, "286686") + request.get_res.assert_called_once_with( + "http://emby.local/emby/Users/user-1/Items/series-item-1", + {"api_key": "test-key", "Fields": "ProviderIds"}, + )