diff --git a/app/api/endpoints/mediaserver.py b/app/api/endpoints/mediaserver.py index f46a25a5..c0e61265 100644 --- a/app/api/endpoints/mediaserver.py +++ b/app/api/endpoints/mediaserver.py @@ -1,6 +1,6 @@ from typing import Any, List, Dict, Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app import schemas @@ -21,6 +21,18 @@ from app.utils.media import build_media_key, resolve_media_identity router = APIRouter() +def _require_mediaserver_result(result: Optional[List[Any]]) -> List[Any]: + """ + 保留媒体服务器成功空列表,并把提供方失败转换为明确的网关错误。 + """ + if result is None: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="媒体服务器请求失败", + ) + return result + + @router.get("/play/{itemid:path}", summary="在线播放") def play_item( itemid: str, _: schemas.TokenPayload = Depends(verify_token) @@ -153,11 +165,12 @@ def latest( """ 获取媒体服务器最新入库条目 """ - return ( + return _require_mediaserver_result( MediaServerChain().latest( - server=server, count=count, username=userinfo.username + server=server, + count=count, + username=userinfo.username, ) - or [] ) @@ -172,11 +185,12 @@ def playing( """ 获取媒体服务器正在播放条目 """ - return ( + return _require_mediaserver_result( MediaServerChain().playing( - server=server, count=count, username=userinfo.username + server=server, + count=count, + username=userinfo.username, ) - or [] ) @@ -191,11 +205,12 @@ def library( """ 获取媒体服务器媒体库列表 """ - return ( + return _require_mediaserver_result( MediaServerChain().librarys( - server=server, username=userinfo.username, hidden=hidden + server=server, + username=userinfo.username, + hidden=hidden, ) - or [] ) diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index b22caf18..dfe3ece6 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -1,17 +1,29 @@ -from typing import Any, List, Optional +from typing import Any, Awaitable, List, Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from app import schemas from app.chain.recommend import RecommendChain from app.core.event import eventmanager from app.core.security import verify_token +from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException from app.schemas import RecommendSourceEventData from app.schemas.types import ChainEventType router = APIRouter() +async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]: + """保留 TMDB 成功空列表,并把上游请求异常转换为明确的网关错误。""" + try: + return await operation + except TMDbException as error: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="TMDB请求失败", + ) from error + + @router.get( "/source", summary="获取推荐数据源", @@ -204,16 +216,19 @@ async def tmdb_movies( """ 浏览TMDB电影信息 """ - return await RecommendChain().async_tmdb_movies( - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page, + return await _require_tmdb_result( + RecommendChain().async_tmdb_movies( + sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + with_keywords=with_keywords, + with_watch_providers=with_watch_providers, + vote_average=vote_average, + vote_count=vote_count, + release_date=release_date, + page=page, + raise_exception=True, + ) ) @@ -233,16 +248,19 @@ async def tmdb_tvs( """ 浏览TMDB剧集信息 """ - return await RecommendChain().async_tmdb_tvs( - sort_by=sort_by, - with_genres=with_genres, - with_original_language=with_original_language, - with_keywords=with_keywords, - with_watch_providers=with_watch_providers, - vote_average=vote_average, - vote_count=vote_count, - release_date=release_date, - page=page, + return await _require_tmdb_result( + RecommendChain().async_tmdb_tvs( + sort_by=sort_by, + with_genres=with_genres, + with_original_language=with_original_language, + with_keywords=with_keywords, + with_watch_providers=with_watch_providers, + vote_average=vote_average, + vote_count=vote_count, + release_date=release_date, + page=page, + raise_exception=True, + ) ) @@ -255,4 +273,6 @@ async def tmdb_trending( """ TMDB流行趋势 """ - return await RecommendChain().async_tmdb_trending(page=page) + return await _require_tmdb_result( + RecommendChain().async_tmdb_trending(page=page, raise_exception=True) + ) diff --git a/app/chain/mediaserver.py b/app/chain/mediaserver.py index 4585be16..7143e9a4 100644 --- a/app/chain/mediaserver.py +++ b/app/chain/mediaserver.py @@ -27,11 +27,13 @@ class MediaServerChain(ChainBase): def _sign_library_images( self, libraries: Optional[List[MediaServerLibrary]] - ) -> List[MediaServerLibrary]: + ) -> Optional[List[MediaServerLibrary]]: """ - 给媒体库列表中的封面和封面组添加代理签名。 + 给媒体库列表中的封面和封面组添加代理签名,并保留提供方失败状态。 """ - for library in libraries or []: + if libraries is None: + return None + for library in libraries: if library.image: library.image = self._sign_image_url(library.image) if library.image_list: @@ -40,21 +42,23 @@ class MediaServerChain(ChainBase): for image in library.image_list if image ] - return libraries or [] + return libraries def _sign_play_item_images( self, items: Optional[List[MediaServerPlayItem]] - ) -> List[MediaServerPlayItem]: + ) -> Optional[List[MediaServerPlayItem]]: """ - 给媒体服务器播放条目中的图片 URL 添加代理签名。 + 给媒体服务器播放条目中的图片 URL 添加代理签名,并保留提供方失败状态。 """ - for item in items or []: + if items is None: + return None + for item in items: if item.image: item.image = self._sign_image_url(item.image) - return items or [] + return items def librarys(self, server: str, username: Optional[str] = None, - hidden: bool = False) -> List[MediaServerLibrary]: + hidden: bool = False) -> Optional[List[MediaServerLibrary]]: """ 获取媒体服务器所有媒体库 """ @@ -151,7 +155,7 @@ class MediaServerChain(ChainBase): return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id) def playing(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> List[MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ @@ -165,7 +169,7 @@ class MediaServerChain(ChainBase): ) def latest(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> List[MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 4b69ba2f..579e4b70 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -313,7 +313,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", - page: Optional[int] = 1) -> List[dict]: + page: Optional[int] = 1, + raise_exception: bool = False) -> List[dict]: """ 异步TMDB热门电影 """ @@ -326,7 +327,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): vote_average=vote_average, vote_count=vote_count, release_date=release_date, - page=page) + page=page, + raise_exception=raise_exception) return [movie.to_dict() for movie in movies] if movies else [] @log_execution_time(logger=logger) @@ -339,7 +341,8 @@ class RecommendChain(ChainBase, metaclass=Singleton): vote_average: Optional[float] = 0.0, vote_count: Optional[int] = 0, release_date: Optional[str] = "", - page: Optional[int] = 1) -> List[dict]: + page: Optional[int] = 1, + raise_exception: bool = False) -> List[dict]: """ 异步TMDB热门电视剧 """ @@ -352,16 +355,23 @@ class RecommendChain(ChainBase, metaclass=Singleton): vote_average=vote_average, vote_count=vote_count, release_date=release_date, - page=page) + page=page, + raise_exception=raise_exception) return [tv.to_dict() for tv in tvs] if tvs else [] @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) - async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]: + async def async_tmdb_trending( + self, page: Optional[int] = 1, raise_exception: bool = False + ) -> List[dict]: """ 异步TMDB流行趋势 """ - infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page) + infos = await TmdbChain().async_run_module( + "async_tmdb_trending", + page=page, + raise_exception=raise_exception, + ) return [info.to_dict() for info in infos] if infos else [] @log_execution_time(logger=logger) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 0e4fe4f6..a4f5eb82 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -99,6 +99,8 @@ "messages": { "模块不支持测试": "Module does not support testing", "网络请求失败": "Network request failed", + "TMDB请求失败": "TMDB request failed", + "媒体服务器请求失败": "Media server request failed", "附件保存失败": "Failed to save attachment", "该选择已失效,请重新发起选择": "This selection has expired. Please start the selection again", "会话不存在或无权访问": "The conversation does not exist or you do not have access", diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json index d4615c19..04a65428 100644 --- a/app/locales/zh-CN.json +++ b/app/locales/zh-CN.json @@ -99,6 +99,8 @@ "messages": { "模块不支持测试": "模块不支持测试", "网络请求失败": "网络请求失败", + "TMDB请求失败": "TMDB请求失败", + "媒体服务器请求失败": "媒体服务器请求失败", "豆瓣网络连接失败": "豆瓣网络连接失败", "Bangumi网络连接失败": "Bangumi网络连接失败", "fanart网络连接失败": "fanart网络连接失败", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 88b92034..5e612204 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -99,6 +99,8 @@ "messages": { "模块不支持测试": "模組不支援測試", "网络请求失败": "網路請求失敗", + "TMDB请求失败": "TMDB 請求失敗", + "媒体服务器请求失败": "媒體伺服器請求失敗", "附件保存失败": "附件儲存失敗", "该选择已失效,请重新发起选择": "此選擇已失效,請重新發起選擇", "会话不存在或无权访问": "會話不存在或無權存取", diff --git a/app/modules/emby/__init__.py b/app/modules/emby/__init__.py index 1aa36104..f25e9faa 100644 --- a/app/modules/emby/__init__.py +++ b/app/modules/emby/__init__.py @@ -282,13 +282,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ server_obj: Emby = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_resume(num=count, username=username) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: @@ -316,13 +316,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): return server_obj.get_season_episode_ids(str(item_id), season) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ server_obj: Emby = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_latest(num=count, username=username) def mediaserver_latest_images(self, @@ -345,8 +345,7 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): return [] links = [] - items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, - username=username) + items = self.mediaserver_latest(server=server, count=count, username=username) or [] for item in items: if item.BackdropImageTags: image_url = server_obj.get_backdrop_url(item_id=item.id, diff --git a/app/modules/emby/emby.py b/app/modules/emby/emby.py index 5b25f4a7..3c7f0564 100644 --- a/app/modules/emby/emby.py +++ b/app/modules/emby/emby.py @@ -118,38 +118,47 @@ class Emby: logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e)) return [] - def __get_emby_librarys(self, username: Optional[str] = None) -> List[dict]: + def __get_emby_librarys(self, username: Optional[str] = None) -> Optional[List[dict]]: """ 获取Emby媒体库列表 """ if not self._host or not self._apikey: - return [] + return None if username: user = self.get_user(username) else: user = self.user + if not user: + return None url = f"{self._host}emby/Users/{user}/Views" params = {"api_key": self._apikey} try: res = RequestUtils().get_res(url, params) if res: - return res.json().get("Items") + items = res.json().get("Items") + return items if isinstance(items, list) else None else: logger.error(f"User/Views 未获取到返回数据") - return [] + return None except Exception as e: logger.error(f"连接User/Views 出错:" + str(e)) - return [] + return None - def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[ - schemas.MediaServerLibrary]: + def get_librarys( + self, + username: Optional[str] = None, + hidden: Optional[bool] = False, + ) -> Optional[List[schemas.MediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ if not self._host or not self._apikey: - return [] + return None + source_libraries = self.__get_emby_librarys(username) + if source_libraries is None: + return None libraries = [] - for library in self.__get_emby_librarys(username) or []: + for library in source_libraries: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ and library.get("Id") not in self._sync_libraries: continue @@ -1206,6 +1215,8 @@ class Emby: user = self.get_user(username) else: user = self.user + if not user: + return None url = f"{self._host}Users/{user}/Items/Resume" params = { "Limit": 100, @@ -1266,7 +1277,7 @@ class Emby: logger.error(f"Users/Items/Resume 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Resume出错:" + str(e)) - return [] + return None def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[ List[schemas.MediaServerPlayItem]]: @@ -1279,6 +1290,8 @@ class Emby: user = self.get_user(username) else: user = self.user + if not user: + return None url = f"{self._host}Users/{user}/Items/Latest" params = { "Limit": 100, @@ -1323,7 +1336,7 @@ class Emby: logger.error(f"Users/Items/Latest 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Latest出错:" + str(e)) - return [] + return None def get_user_library_folders(self): """ diff --git a/app/modules/jellyfin/__init__.py b/app/modules/jellyfin/__init__.py index b0da28b3..ba738d80 100644 --- a/app/modules/jellyfin/__init__.py +++ b/app/modules/jellyfin/__init__.py @@ -281,13 +281,14 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, - count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: + count: Optional[int] = 20, + username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ server_obj: Jellyfin = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_resume(num=count, username=username) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: @@ -315,13 +316,13 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): return server_obj.get_season_episode_ids(str(item_id), season) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ server_obj: Jellyfin = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_latest(num=count, username=username) def mediaserver_latest_images(self, @@ -344,8 +345,7 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): return [] links = [] - items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, - username=username) + items = self.mediaserver_latest(server=server, count=count, username=username) or [] for item in items: if item.BackdropImageTags: image_url = server_obj.get_backdrop_url(item_id=item.id, diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index 005e0244..6f4ae196 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -114,42 +114,50 @@ class Jellyfin: logger.error(f"连接Library/VirtualFolders 出错:" + str(e)) return [] - def __get_jellyfin_librarys(self, username: Optional[str] = None) -> List[dict]: + def __get_jellyfin_librarys(self, username: Optional[str] = None) -> Optional[List[dict]]: """ 获取Jellyfin媒体库的信息 """ if not self._host or not self._apikey: - return [] + return None if username: user = self.get_user(username) else: user = self.user if not user: - return [] + return None # 使用标准库路径拼接结合统一 URL 规整,避免 host 尾部斜杠缺失导致的寻址偏移。 url = UrlUtils.combine_url(self._host, posixpath.join("Users", str(user), "Views")) if not url: - return [] + return None params = {"api_key": self._apikey} try: res = RequestUtils().get_res(url, params) if res: - return res.json().get("Items") + items = res.json().get("Items") + return items if isinstance(items, list) else None else: logger.error(f"Users/Views 未获取到返回数据") - return [] + return None except Exception as e: logger.error(f"连接Users/Views 出错:" + str(e)) - return [] + return None - def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]: + def get_librarys( + self, + username: Optional[str] = None, + hidden: Optional[bool] = False, + ) -> Optional[List[schemas.MediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ if not self._host or not self._apikey: - return [] + return None + source_libraries = self.__get_jellyfin_librarys(username) + if source_libraries is None: + return None libraries = [] - for library in self.__get_jellyfin_librarys(username) or []: + for library in source_libraries: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ and library.get("Id") not in self._sync_libraries: continue @@ -1027,6 +1035,8 @@ class Jellyfin: user = self.get_user(username) else: user = self.user + if not user: + return None url = f"{self._host}Users/{user}/Items/Resume" params = { @@ -1083,7 +1093,7 @@ class Jellyfin: logger.error(f"Users/Items/Resume 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Resume出错:" + str(e)) - return [] + return None def get_latest(self, num=20, username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ @@ -1095,6 +1105,8 @@ class Jellyfin: user = self.get_user(username) else: user = self.user + if not user: + return None url = f"{self._host}Users/{user}/Items/Latest" params = { "Limit": 100, @@ -1136,7 +1148,7 @@ class Jellyfin: logger.error(f"Users/Items/Latest 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Latest出错:" + str(e)) - return [] + return None def get_user_library_folders(self): """ diff --git a/app/modules/plex/__init__.py b/app/modules/plex/__init__.py index 46cf2ebe..9d683911 100644 --- a/app/modules/plex/__init__.py +++ b/app/modules/plex/__init__.py @@ -293,23 +293,23 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]): ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - **kwargs) -> List[schemas.MediaServerPlayItem]: + **kwargs) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ server_obj: Plex = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_resume(num=count) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - **kwargs) -> List[schemas.MediaServerPlayItem]: + **kwargs) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ server_obj: Plex = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_latest(num=count) def mediaserver_latest_images(self, @@ -331,8 +331,7 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]): return [] links = [] - items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, - username=username) + items = self.mediaserver_latest(server=server, count=count, username=username) or [] for item in items: link = server_obj.get_remote_image_by_id(item_id=item.id, image_type="Backdrop", diff --git a/app/modules/plex/plex.py b/app/modules/plex/plex.py index f0a38cc5..f507ceb2 100644 --- a/app/modules/plex/plex.py +++ b/app/modules/plex/plex.py @@ -122,17 +122,17 @@ class Plex: return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in list(poster_urls.keys())[:total_size]] - def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]: + def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ if not self._plex: - return [] + return None try: self._libraries = self._plex.library.sections() except Exception as err: logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}") - return [] + return None libraries = [] for library in self._libraries: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ @@ -171,7 +171,7 @@ class Plex: sections = self._plex.library.sections() movie_count = tv_count = episode_count = 0 # 媒体库白名单 - allow_library = [str(lib.id) for lib in self.get_librarys(hidden=True)] + allow_library = [str(lib.id) for lib in self.get_librarys(hidden=True) or []] for sec in sections: if str(sec.key) not in allow_library: continue @@ -832,9 +832,12 @@ class Plex: 获取继续观看的媒体 """ if not self._plex: - return [] + return None # 媒体库白名单 - allow_library = ",".join(map(str, (lib.id for lib in self.get_librarys(hidden=True)))) + libraries = self.get_librarys(hidden=True) + if libraries is None: + return None + allow_library = ",".join(map(str, (lib.id for lib in libraries))) params = {"contentDirectoryID": allow_library} items = self._plex.fetchItems("/hubs/continueWatching/items", container_start=0, @@ -871,7 +874,10 @@ class Plex: if not self._plex: return None # 请求参数(除黑名单) - allow_library = ",".join(map(str, (lib.id for lib in self.get_librarys(hidden=True)))) + libraries = self.get_librarys(hidden=True) + if libraries is None: + return None + allow_library = ",".join(map(str, (lib.id for lib in libraries))) params = { "contentDirectoryID": allow_library, "count": num, diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 609041f1..3dcd3042 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -1265,7 +1265,8 @@ class TheMovieDbModule(_ModuleBase): vote_average: float, vote_count: int, release_date: str, - page: Optional[int] = 1) -> Optional[List[MediaInfo]]: + page: Optional[int] = 1, + raise_exception: bool = False) -> Optional[List[MediaInfo]]: """ TMDB发现功能(异步版本) :param mtype: 媒体类型 @@ -1291,7 +1292,7 @@ class TheMovieDbModule(_ModuleBase): "vote_count.gte": vote_count, "release_date.gte": release_date, "page": page - }) + }, raise_exception=raise_exception) elif mtype == MediaType.TV: infos = await self.tmdb.async_discover_tvs({ "sort_by": sort_by, @@ -1303,20 +1304,25 @@ class TheMovieDbModule(_ModuleBase): "vote_count.gte": vote_count, "first_air_date.gte": release_date, "page": page - }) + }, raise_exception=raise_exception) else: return [] if infos: return [MediaInfo(tmdb_info=info) for info in infos] return [] - async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]: + async def async_tmdb_trending( + self, page: Optional[int] = 1, raise_exception: bool = False + ) -> List[MediaInfo]: """ TMDB流行趋势(异步版本) :param page: 第几页 :return: TMDB信息列表 """ - trending = await self.tmdb.async_discover_trending(page=page) + trending = await self.tmdb.async_discover_trending( + page=page, + raise_exception=raise_exception, + ) if trending: return [MediaInfo(tmdb_info=info) for info in trending] return [] diff --git a/app/modules/themoviedb/tmdbapi.py b/app/modules/themoviedb/tmdbapi.py index 101a0e94..1be2f4fc 100644 --- a/app/modules/themoviedb/tmdbapi.py +++ b/app/modules/themoviedb/tmdbapi.py @@ -1758,7 +1758,9 @@ class TmdbApi: ret_infos.append(tv) return ret_infos - async def async_discover_movies(self, params: dict) -> List[dict]: + async def async_discover_movies( + self, params: dict, raise_exception: bool = False + ) -> List[dict]: """ 发现电影(异步版本) """ @@ -1771,9 +1773,13 @@ class TmdbApi: return items except Exception as e: logger.error(f"获取电影发现失败:{str(e)}") + if raise_exception: + raise return [] - async def async_discover_tvs(self, params: dict) -> List[dict]: + async def async_discover_tvs( + self, params: dict, raise_exception: bool = False + ) -> List[dict]: """ 发现电视剧(异步版本) """ @@ -1786,6 +1792,8 @@ class TmdbApi: return items except Exception as e: logger.error(f"获取电视剧发现失败:{str(e)}") + if raise_exception: + raise return [] async def async_search_persons(self, name: str) -> List[dict]: @@ -2006,7 +2014,9 @@ class TmdbApi: logger.error(str(e)) return {} - async def async_discover_trending(self, page: Optional[int] = 1) -> List[dict]: + async def async_discover_trending( + self, page: Optional[int] = 1, raise_exception: bool = False + ) -> List[dict]: """ 流行趋势(异步版本) """ @@ -2018,6 +2028,8 @@ class TmdbApi: return self._normalize_trending_infos(tmdbinfo) except Exception as e: logger.error(str(e)) + if raise_exception: + raise return [] async def async_get_movie_images( diff --git a/app/modules/trimemedia/__init__.py b/app/modules/trimemedia/__init__.py index 616ee02c..058ba95a 100644 --- a/app/modules/trimemedia/__init__.py +++ b/app/modules/trimemedia/__init__.py @@ -332,14 +332,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): def mediaserver_playing( self, server: str, count: Optional[int] = 20, **kwargs - ) -> List[schemas.MediaServerPlayItem]: + ) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: - return [] - return server_obj.get_resume(num=count) or [] + return None + return server_obj.get_resume(num=count) def mediaserver_play_url( self, server: str, item_id: Union[str, int] @@ -359,14 +359,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): server: Optional[str] = None, count: Optional[int] = 20, **kwargs, - ) -> List[schemas.MediaServerPlayItem]: + ) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ server_obj: Optional[TrimeMedia] = self.get_instance(server) if not server_obj: - return [] - return server_obj.get_latest(num=count) or [] + return None + return server_obj.get_latest(num=count) def mediaserver_latest_images( self, diff --git a/app/modules/trimemedia/trimemedia.py b/app/modules/trimemedia/trimemedia.py index ea6dc4f1..e446a403 100644 --- a/app/modules/trimemedia/trimemedia.py +++ b/app/modules/trimemedia/trimemedia.py @@ -163,16 +163,18 @@ class TrimeMedia: def get_librarys( self, hidden: Optional[bool] = False - ) -> List[schemas.MediaServerLibrary]: + ) -> Optional[List[schemas.MediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ if not self.is_authenticated(): - return [] + return None if self._userinfo.is_admin == 1: - mdb_list = self._api.mdb_list() or [] + mdb_list = self._api.mdb_list() else: - mdb_list = self._api.mediadb_list() or [] + mdb_list = self._api.mediadb_list() + if mdb_list is None: + return None self._libraries = {lib.guid: lib for lib in mdb_list} libraries = [] for library in self._libraries.values(): @@ -584,8 +586,11 @@ class TrimeMedia: """ if not self.is_authenticated(): return None + items = self._api.play_list() + if items is None: + return None ret_resume = [] - for item in self._api.play_list() or []: + for item in items: if len(ret_resume) == num: break if self.__is_library_blocked(item.ancestor_guid): @@ -599,14 +604,13 @@ class TrimeMedia: """ if not self.is_authenticated(): return None - items = ( - self._api.item_list( - page=1, - page_size=max(100, num * 5), - types=[fnapi.Type.MOVIE, fnapi.Type.TV], - ) - or [] + items = self._api.item_list( + page=1, + page_size=max(100, num * 5), + types=[fnapi.Type.MOVIE, fnapi.Type.TV], ) + if items is None: + return None latest = [] for item in items: if len(latest) == num: diff --git a/app/modules/ugreen/__init__.py b/app/modules/ugreen/__init__.py index 3fc342fb..a791c038 100644 --- a/app/modules/ugreen/__init__.py +++ b/app/modules/ugreen/__init__.py @@ -304,14 +304,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): def mediaserver_playing( self, server: str, count: Optional[int] = 20, **kwargs - ) -> List[schemas.MediaServerPlayItem]: + ) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: - return [] - return server_obj.get_resume(num=count) or [] + return None + return server_obj.get_resume(num=count) def mediaserver_play_url( self, server: str, item_id: Union[str, int] @@ -331,14 +331,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): server: Optional[str] = None, count: Optional[int] = 20, **kwargs, - ) -> List[schemas.MediaServerPlayItem]: + ) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ server_obj: Optional[Ugreen] = self.get_instance(server) if not server_obj: - return [] - return server_obj.get_latest(num=count) or [] + return None + return server_obj.get_latest(num=count) def mediaserver_latest_images( self, diff --git a/app/modules/ugreen/api.py b/app/modules/ugreen/api.py index e33baa1c..1d1893f0 100644 --- a/app/modules/ugreen/api.py +++ b/app/modules/ugreen/api.py @@ -489,15 +489,15 @@ class Api: return None return dict(result.data) - def media_list(self) -> list[dict]: + def media_list(self) -> Optional[list[dict]]: """ 获取首页媒体库列表(`media_lib_info_list`)。 """ result = self.request("v1/video/homepage/media_list") if not result.success or not isinstance(result.data, Mapping): - return [] + return None items = result.data.get("media_lib_info_list") - return items if isinstance(items, list) else [] + return items if isinstance(items, list) else None def media_lib_users(self) -> list[dict]: """ diff --git a/app/modules/ugreen/ugreen.py b/app/modules/ugreen/ugreen.py index 3aa71e5c..cfbca672 100644 --- a/app/modules/ugreen/ugreen.py +++ b/app/modules/ugreen/ugreen.py @@ -520,7 +520,7 @@ class Ugreen: return paths - def get_librarys(self, hidden: Optional[bool] = False) -> List[schemas.MediaServerLibrary]: + def get_librarys(self, hidden: Optional[bool] = False) -> Optional[List[schemas.MediaServerLibrary]]: """ 获取绿联影视媒体库列表 @@ -528,9 +528,11 @@ class Ugreen: :return: 媒体库列表 """ if not self.is_authenticated() or not self._api: - return [] + return None media_libs = self._api.media_list() + if media_libs is None: + return None self._library_paths = self.__load_library_paths() libraries = [] self._libraries = {} @@ -957,8 +959,8 @@ class Ugreen: page_size = max(1, num or 12) data = self._api.recently_played(page=1, page_size=page_size) - if not data: - return [] + if data is None: + return None ret_resume = [] for item in data.get("video_arr") or []: @@ -982,8 +984,8 @@ class Ugreen: page_size = max(1, num) data = self._api.recently_updated(page=1, page_size=page_size) - if not data: - return [] + if data is None: + return None latest = [] for item in data.get("video_arr") or []: diff --git a/app/modules/zspace/__init__.py b/app/modules/zspace/__init__.py index c3edb1c6..3e03524e 100644 --- a/app/modules/zspace/__init__.py +++ b/app/modules/zspace/__init__.py @@ -276,13 +276,13 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]): ) for season, episodes in seasoninfo.items()] def mediaserver_playing(self, server: str, count: Optional[int] = 20, - username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器正在播放信息 """ server_obj: ZSpace = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_resume(num=count, username=username) def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: @@ -295,13 +295,13 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]): return server_obj.get_play_url(item_id) def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, - username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]: + username: Optional[str] = None) -> Optional[List[schemas.MediaServerPlayItem]]: """ 获取媒体服务器最新入库条目 """ server_obj: ZSpace = self.get_instance(server) if not server_obj: - return [] + return None return server_obj.get_latest(num=count, username=username) def mediaserver_latest_images(self, @@ -324,8 +324,7 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]): return [] links = [] - items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, - username=username) + items = self.mediaserver_latest(server=server, count=count, username=username) or [] for item in items: if item.BackdropImageTags: image_url = server_obj.get_backdrop_url(item_id=item.id, diff --git a/app/modules/zspace/zspace.py b/app/modules/zspace/zspace.py index 6f8a5790..cfbeeaec 100644 --- a/app/modules/zspace/zspace.py +++ b/app/modules/zspace/zspace.py @@ -198,39 +198,46 @@ class ZSpace: }) return libraries - def __get_library_views(self, username: Optional[str] = None) -> List[dict]: + def __get_library_views(self, username: Optional[str] = None) -> Optional[List[dict]]: """ 获取极影视媒体库列表 """ if not self._host or not self._apikey: - return [] + return None if username: user = self.get_user(username) else: user = self.user if not user: - return [] + return None url = f"{self._host}emby/Users/{user}/Views" try: res = self.__request_utils().get_res(url) if res: - return res.json().get("Items") + items = res.json().get("Items") + return items if isinstance(items, list) else None else: logger.error("Users/Views 未获取到返回数据") - return [] + return None except Exception as e: logger.error(f"连接Users/Views 出错:{e}") - return [] + return None - def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[ - schemas.MediaServerLibrary]: + def get_librarys( + self, + username: Optional[str] = None, + hidden: Optional[bool] = False, + ) -> Optional[List[schemas.MediaServerLibrary]]: """ 获取媒体服务器所有媒体库列表 """ if not self._host or not self._apikey: - return [] + return None + source_libraries = self.__get_library_views(username) + if source_libraries is None: + return None libraries = [] - for library in self.__get_library_views(username) or []: + for library in source_libraries: if hidden and self._sync_libraries and "all" not in self._sync_libraries \ and library.get("Id") not in self._sync_libraries: continue @@ -1085,7 +1092,7 @@ class ZSpace: else: user = self.user if not user: - return [] + return None url = f"{self._host}emby/Users/{user}/Items/Resume" params = { "Limit": 100, @@ -1141,7 +1148,7 @@ class ZSpace: logger.error("Users/Items/Resume 未获取到返回数据") except Exception as e: logger.error(f"连接Users/Items/Resume出错:{e}") - return [] + return None def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[ List[schemas.MediaServerPlayItem]]: @@ -1160,7 +1167,7 @@ class ZSpace: else: user = self.user if not user: - return [] + return None url = f"{self._host}emby/Users/{user}/Items" params = { "Recursive": "true", @@ -1208,7 +1215,7 @@ class ZSpace: logger.debug("Users/Items?SortBy=DateCreated 未获取到返回数据") except Exception as e: logger.error(f"连接 Users/Items(DateCreated 排序)出错:{e}") - return [] + return None def get_user_library_folders(self): """ diff --git a/pytest.ini b/pytest.ini index 4ec1fb1b..33cfa3ed 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,8 @@ testpaths = tests timeout = 120 timeout_method = thread +asyncio_mode = strict +asyncio_default_fixture_loop_scope = function # 仅对「无法在本仓修复根因」的已知上游/三方弃用告警做精确忽略,保持测试输出干净、 # 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。 filterwarnings = diff --git a/requirements-dev.in b/requirements-dev.in index b091eeff..5027aa97 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -3,6 +3,7 @@ Cython~=3.2.5 pylint~=4.0.6 pytest~=9.0.3 +pytest-asyncio~=1.4.0 pytest-cov~=7.1.0 pytest-timeout~=2.4.0 uv~=0.11.23 diff --git a/tests/test_jellyfin.py b/tests/test_jellyfin.py index edd3f4f8..9577770f 100644 --- a/tests/test_jellyfin.py +++ b/tests/test_jellyfin.py @@ -131,14 +131,14 @@ class JellyfinUserResolutionTest(unittest.TestCase): ) ) - def test_get_jellyfin_librarys_returns_empty_when_user_missing(self): + def test_get_jellyfin_librarys_reports_failure_when_user_missing(self): client = self._build_client() client.user = None with patch.object(jellyfin_module, "RequestUtils") as request_utils_cls: libraries = client._Jellyfin__get_jellyfin_librarys() - self.assertEqual(libraries, []) + self.assertIsNone(libraries) request_utils_cls.assert_not_called() def test_get_jellyfin_librarys_uses_normalized_views_url(self): diff --git a/tests/test_mediaserver_dashboard_contract.py b/tests/test_mediaserver_dashboard_contract.py new file mode 100644 index 00000000..7f25cdb1 --- /dev/null +++ b/tests/test_mediaserver_dashboard_contract.py @@ -0,0 +1,78 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from app.api.endpoints.mediaserver import latest, library, playing +from app.chain.mediaserver import MediaServerChain + + +@pytest.mark.parametrize( + ("endpoint", "chain_method", "kwargs"), + [ + (latest, "latest", {"server": "home", "count": 20}), + (playing, "playing", {"server": "home", "count": 12}), + (library, "librarys", {"server": "home", "hidden": True}), + ], +) +def test_dashboard_media_endpoints_preserve_successful_empty_results( + endpoint, + chain_method, + kwargs, +): + """媒体服务器成功返回空列表时,Dashboard 接口应保留真实空结果。""" + with patch("app.api.endpoints.mediaserver.MediaServerChain") as chain_cls: + getattr(chain_cls.return_value, chain_method).return_value = [] + + result = endpoint( + **kwargs, + userinfo=SimpleNamespace(username="alice"), + ) + + assert result == [] + + +@pytest.mark.parametrize( + ("endpoint", "chain_method", "kwargs"), + [ + (latest, "latest", {"server": "home", "count": 20}), + (playing, "playing", {"server": "home", "count": 12}), + (library, "librarys", {"server": "home", "hidden": True}), + ], +) +def test_dashboard_media_endpoints_report_upstream_failures( + endpoint, + chain_method, + kwargs, +): + """媒体服务器请求失败时,Dashboard 接口不得把 None 折叠为空列表。""" + with patch("app.api.endpoints.mediaserver.MediaServerChain") as chain_cls: + getattr(chain_cls.return_value, chain_method).return_value = None + + with pytest.raises(HTTPException) as exc_info: + endpoint( + **kwargs, + userinfo=SimpleNamespace(username="alice"), + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == "媒体服务器请求失败" + + +@pytest.mark.parametrize( + ("method_name", "run_method"), + [ + ("latest", "mediaserver_latest"), + ("playing", "mediaserver_playing"), + ("librarys", "mediaserver_librarys"), + ], +) +def test_media_server_chain_preserves_none_from_provider(method_name, run_method): + """媒体服务器处理链应保留提供方失败状态,交由接口层转换为明确错误。""" + chain = MediaServerChain.__new__(MediaServerChain) + chain.run_module = lambda method, **kwargs: None + + result = getattr(chain, method_name)(server="home") + + assert result is None diff --git a/tests/test_recommend_dashboard_contract.py b/tests/test_recommend_dashboard_contract.py new file mode 100644 index 00000000..c63c635f --- /dev/null +++ b/tests/test_recommend_dashboard_contract.py @@ -0,0 +1,110 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from app.api.endpoints.recommend import tmdb_movies, tmdb_trending, tmdb_tvs +from app.modules.themoviedb.tmdbapi import TmdbApi +from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("endpoint", "chain_method"), + [ + (tmdb_movies, "async_tmdb_movies"), + (tmdb_tvs, "async_tmdb_tvs"), + (tmdb_trending, "async_tmdb_trending"), + ], +) +async def test_dashboard_recommend_endpoints_preserve_successful_empty_results( + endpoint, + chain_method, +): + """TMDB 成功返回空列表时,推荐卡片接口应保留真实空结果。""" + with patch("app.api.endpoints.recommend.RecommendChain") as chain_cls: + chain_mock = AsyncMock(return_value=[]) + setattr(chain_cls.return_value, chain_method, chain_mock) + + result = await endpoint( + page=1, + _=SimpleNamespace(username="alice"), + ) + + assert result == [] + assert chain_mock.await_args.kwargs["raise_exception"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("endpoint", "chain_method"), + [ + (tmdb_movies, "async_tmdb_movies"), + (tmdb_tvs, "async_tmdb_tvs"), + (tmdb_trending, "async_tmdb_trending"), + ], +) +async def test_dashboard_recommend_endpoints_report_upstream_failures( + endpoint, + chain_method, +): + """TMDB 请求异常时,推荐卡片接口应返回明确的网关错误。""" + with patch("app.api.endpoints.recommend.RecommendChain") as chain_cls: + setattr( + chain_cls.return_value, + chain_method, + AsyncMock(side_effect=TMDbException("remote unavailable")), + ) + + with pytest.raises(HTTPException) as exc_info: + await endpoint( + page=1, + _=SimpleNamespace(username="alice"), + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == "TMDB请求失败" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "dependency_name", "dependency_method", "kwargs"), + [ + ( + "async_discover_movies", + "discover", + "async_discover_movies", + {"params": {"page": 1}}, + ), + ( + "async_discover_tvs", + "discover", + "async_discover_tv_shows", + {"params": {"page": 1}}, + ), + ( + "async_discover_trending", + "trending", + "async_all_week", + {"page": 1}, + ), + ], +) +async def test_tmdb_recommend_queries_only_propagate_failures_in_strict_mode( + method_name, + dependency_name, + dependency_method, + kwargs, +): + """推荐 endpoint 的严格模式应保留异常,其他调用方继续沿用空列表降级。""" + api = TmdbApi.__new__(TmdbApi) + dependency = SimpleNamespace( + **{dependency_method: AsyncMock(side_effect=TMDbException("remote unavailable"))} + ) + setattr(api, dependency_name, dependency) + + assert await getattr(api, method_name)(**kwargs) == [] + + with pytest.raises(TMDbException, match="remote unavailable"): + await getattr(api, method_name)(**kwargs, raise_exception=True)