From 35199dbba0c2f5cd5bf4075f0cdcce5e6feb7975 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 17 Oct 2026 09:36:25 +0800 Subject: [PATCH] fix(emby): include image tags in primary image URLs --- app/modules/emby/emby.py | 34 +++++++---- tests/test_emby_image_urls.py | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 tests/test_emby_image_urls.py diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 35c4ce32e..9fb64dd85 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -22,6 +22,8 @@ from app.schemas.types import MediaSource, MediaType class Emby: + """Emby媒体服务器客户端。""" + _host: Optional[str] = None _playhost: Optional[str] = None _apikey: Optional[str] = None @@ -175,7 +177,10 @@ class Emby: library_type = MediaType.TV.value else: library_type = MediaType.UNKNOWN.value - image = self.__get_local_image_by_id(library.get("Id")) + image = self.__get_local_image_by_id( + library.get("Id"), + image_tag=(library.get("ImageTags") or {}).get("Primary"), + ) server_id = library.get("ServerId") or self.serverid server_query = f"serverId={server_id}&" if server_id else "" libraries.append( @@ -1251,16 +1256,16 @@ class Emby: return f"{host_url}Items/{item_id}/" \ f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}" - def __get_local_image_by_id(self, item_id: str) -> str: + def __get_local_image_by_id(self, item_id: str, image_tag: Optional[str] = None) -> str: """ 根据ItemId从媒体服务器查询本地图片地址 :param: item_id: 在Emby中的ID - :param: remote 是否远程使用,TG微信等客户端调用应为True - :param: inner 是否NT内部调用,为True是会使用NT中转 + :param: image_tag: Emby图片版本标签,用于使图片缓存随封面更新失效 """ if not self._host or not self._apikey: return "" - return "%sItems/%s/Images/Primary" % (self._host, item_id) + image_url = f"{self._host}Items/{item_id}/Images/Primary" + return f"{image_url}?tag={image_tag}" if image_tag else image_url def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[ List[_SchemaMediaServerPlayItem]]: @@ -1279,7 +1284,7 @@ class Emby: params = { "Limit": 100, "MediaTypes": "Video", - "Fields": "ProductionYear,Path", + "Fields": "ProductionYear,Path,ImageTags", "api_key": self._apikey, } try: @@ -1312,12 +1317,18 @@ class Emby: image = self.get_backdrop_url(item_id=item.get("Id"), image_tag=item.get("BackdropImageTags")[0]) else: - image = self.__get_local_image_by_id(item.get("Id")) + image = self.__get_local_image_by_id( + item.get("Id"), + image_tag=(item.get("ImageTags") or {}).get("Primary"), + ) else: image = self.get_backdrop_url(item_id=item.get("SeriesId"), image_tag=item.get("SeriesPrimaryImageTag")) if not image: - image = self.__get_local_image_by_id(item.get("SeriesId")) + image = self.__get_local_image_by_id( + item.get("SeriesId"), + image_tag=item.get("SeriesPrimaryImageTag"), + ) ret_resume.append(_SchemaMediaServerPlayItem( id=item.get("Id"), item_id=item.get("Id"), @@ -1354,7 +1365,7 @@ class Emby: params = { "Limit": 100, "MediaTypes": "Video", - "Fields": "ProductionYear,Path,BackdropImageTags", + "Fields": "ProductionYear,Path,BackdropImageTags,ImageTags", "api_key": self._apikey } try: @@ -1376,7 +1387,10 @@ class Emby: item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value server_id = item.get("ServerId") or self.serverid link = self.get_play_url(item.get("Id"), server_id=server_id) - image = self.__get_local_image_by_id(item_id=item.get("Id")) + image = self.__get_local_image_by_id( + item_id=item.get("Id"), + image_tag=(item.get("ImageTags") or {}).get("Primary"), + ) ret_latest.append(_SchemaMediaServerPlayItem( id=item.get("Id"), item_id=item.get("Id"), diff --git a/tests/test_emby_image_urls.py b/tests/test_emby_image_urls.py new file mode 100644 index 000000000..481f5d8c2 --- /dev/null +++ b/tests/test_emby_image_urls.py @@ -0,0 +1,106 @@ +"""验证 Emby 图片版本标签参与图片 URL 构造。""" + +from unittest.mock import Mock, patch + +from app.modules.emby.emby import Emby + + +class _FakeResponse: + """提供 Emby 接口响应的最小 JSON 封装。""" + + def __init__(self, payload): + """保存测试响应体。""" + self._payload = payload + + def json(self): + """返回测试响应体。""" + return self._payload + + +def _client() -> Emby: + """构造跳过远端初始化的 Emby 客户端。""" + client = Emby.__new__(Emby) + client._host = "http://emby.local/" + client._playhost = None + client._apikey = "api-key" + client._sync_libraries = [] + client.user = "user-id" + client.serverid = "server-id" + return client + + +def test_local_primary_image_url_includes_image_tag() -> None: + """封面标签应成为 URL 的版本参数,使缓存键随原地换图变化。""" + client = _client() + + assert client._Emby__get_local_image_by_id("item-id", "primary-tag") == ( + "http://emby.local/Items/item-id/Images/Primary?tag=primary-tag" + ) + assert client._Emby__get_local_image_by_id("item-id") == ( + "http://emby.local/Items/item-id/Images/Primary" + ) + + +def test_get_librarys_uses_primary_image_tag() -> None: + """媒体库封面应使用 Views 返回的 Primary 标签。""" + client = _client() + client.get_items_count = Mock(return_value=0) + + with patch.object(client, "_Emby__get_emby_librarys") as librarys: + librarys.return_value = [{ + "Id": "library-id", + "Name": "电影库", + "CollectionType": "movies", + "ImageTags": {"Primary": "library-tag"}, + }] + + result = client.get_librarys() + + assert result[0].image == ( + "http://emby.local/Items/library-id/Images/Primary?tag=library-tag" + ) + + +def test_get_resume_uses_primary_image_tag_for_movie_fallback() -> None: + """继续观看电影没有背景图时应使用电影主图标签。""" + client = _client() + client.get_user_library_folders = Mock(return_value=[]) + + with patch("app.modules.emby.emby.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.return_value = _FakeResponse({ + "Items": [{ + "Id": "movie-id", + "Type": "Movie", + "Name": "测试电影", + "ImageTags": {"Primary": "movie-tag"}, + "BackdropImageTags": [], + }] + }) + + result = client.get_resume() + + assert result[0].image == "http://emby.local/Items/movie-id/Images/Primary?tag=movie-tag" + assert request_utils_cls.return_value.get_res.call_args.args[1]["Fields"] == ( + "ProductionYear,Path,ImageTags" + ) + + +def test_get_latest_uses_primary_image_tag() -> None: + """最近入库条目的主图 URL 应携带图片标签。""" + client = _client() + client.get_user_library_folders = Mock(return_value=[]) + + with patch("app.modules.emby.emby.RequestUtils") as request_utils_cls: + request_utils_cls.return_value.get_res.return_value = _FakeResponse([{ + "Id": "movie-id", + "Type": "Movie", + "Name": "测试电影", + "ImageTags": {"Primary": "movie-tag"}, + }]) + + result = client.get_latest() + + assert result[0].image == "http://emby.local/Items/movie-id/Images/Primary?tag=movie-tag" + assert request_utils_cls.return_value.get_res.call_args.args[1]["Fields"] == ( + "ProductionYear,Path,BackdropImageTags,ImageTags" + )