fix(dashboard): distinguish empty media results (#6187)

This commit is contained in:
InfinityPacer
2026-07-25 14:24:25 +08:00
committed by GitHub
parent 8d099b9581
commit 197a09b2a4
27 changed files with 466 additions and 161 deletions
+25 -10
View File
@@ -1,6 +1,6 @@
from typing import Any, List, Dict, Optional 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 sqlalchemy.ext.asyncio import AsyncSession
from app import schemas from app import schemas
@@ -21,6 +21,18 @@ from app.utils.media import build_media_key, resolve_media_identity
router = APIRouter() 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="在线播放") @router.get("/play/{itemid:path}", summary="在线播放")
def play_item( def play_item(
itemid: str, _: schemas.TokenPayload = Depends(verify_token) itemid: str, _: schemas.TokenPayload = Depends(verify_token)
@@ -153,11 +165,12 @@ def latest(
""" """
获取媒体服务器最新入库条目 获取媒体服务器最新入库条目
""" """
return ( return _require_mediaserver_result(
MediaServerChain().latest( 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( 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( MediaServerChain().librarys(
server=server, username=userinfo.username, hidden=hidden server=server,
username=userinfo.username,
hidden=hidden,
) )
or []
) )
+25 -5
View File
@@ -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 import schemas
from app.chain.recommend import RecommendChain from app.chain.recommend import RecommendChain
from app.core.event import eventmanager from app.core.event import eventmanager
from app.core.security import verify_token from app.core.security import verify_token
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbException
from app.schemas import RecommendSourceEventData from app.schemas import RecommendSourceEventData
from app.schemas.types import ChainEventType from app.schemas.types import ChainEventType
router = APIRouter() 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( @router.get(
"/source", "/source",
summary="获取推荐数据源", summary="获取推荐数据源",
@@ -204,7 +216,8 @@ async def tmdb_movies(
""" """
浏览TMDB电影信息 浏览TMDB电影信息
""" """
return await RecommendChain().async_tmdb_movies( return await _require_tmdb_result(
RecommendChain().async_tmdb_movies(
sort_by=sort_by, sort_by=sort_by,
with_genres=with_genres, with_genres=with_genres,
with_original_language=with_original_language, with_original_language=with_original_language,
@@ -214,6 +227,8 @@ async def tmdb_movies(
vote_count=vote_count, vote_count=vote_count,
release_date=release_date, release_date=release_date,
page=page, page=page,
raise_exception=True,
)
) )
@@ -233,7 +248,8 @@ async def tmdb_tvs(
""" """
浏览TMDB剧集信息 浏览TMDB剧集信息
""" """
return await RecommendChain().async_tmdb_tvs( return await _require_tmdb_result(
RecommendChain().async_tmdb_tvs(
sort_by=sort_by, sort_by=sort_by,
with_genres=with_genres, with_genres=with_genres,
with_original_language=with_original_language, with_original_language=with_original_language,
@@ -243,6 +259,8 @@ async def tmdb_tvs(
vote_count=vote_count, vote_count=vote_count,
release_date=release_date, release_date=release_date,
page=page, page=page,
raise_exception=True,
)
) )
@@ -255,4 +273,6 @@ async def tmdb_trending(
""" """
TMDB流行趋势 TMDB流行趋势
""" """
return await RecommendChain().async_tmdb_trending(page=page) return await _require_tmdb_result(
RecommendChain().async_tmdb_trending(page=page, raise_exception=True)
)
+15 -11
View File
@@ -27,11 +27,13 @@ class MediaServerChain(ChainBase):
def _sign_library_images( def _sign_library_images(
self, libraries: Optional[List[MediaServerLibrary]] 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: if library.image:
library.image = self._sign_image_url(library.image) library.image = self._sign_image_url(library.image)
if library.image_list: if library.image_list:
@@ -40,21 +42,23 @@ class MediaServerChain(ChainBase):
for image in library.image_list for image in library.image_list
if image if image
] ]
return libraries or [] return libraries
def _sign_play_item_images( def _sign_play_item_images(
self, items: Optional[List[MediaServerPlayItem]] 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: if item.image:
item.image = self._sign_image_url(item.image) item.image = self._sign_image_url(item.image)
return items or [] return items
def librarys(self, server: str, username: Optional[str] = None, 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) return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
def playing(self, server: str, count: Optional[int] = 20, 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, def latest(self, server: str, count: Optional[int] = 20,
username: Optional[str] = None) -> List[MediaServerPlayItem]: username: Optional[str] = None) -> Optional[List[MediaServerPlayItem]]:
""" """
获取媒体服务器最新入库条目 获取媒体服务器最新入库条目
""" """
+16 -6
View File
@@ -313,7 +313,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average: Optional[float] = 0.0, vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0, vote_count: Optional[int] = 0,
release_date: Optional[str] = "", release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]: page: Optional[int] = 1,
raise_exception: bool = False) -> List[dict]:
""" """
异步TMDB热门电影 异步TMDB热门电影
""" """
@@ -326,7 +327,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average=vote_average, vote_average=vote_average,
vote_count=vote_count, vote_count=vote_count,
release_date=release_date, release_date=release_date,
page=page) page=page,
raise_exception=raise_exception)
return [movie.to_dict() for movie in movies] if movies else [] return [movie.to_dict() for movie in movies] if movies else []
@log_execution_time(logger=logger) @log_execution_time(logger=logger)
@@ -339,7 +341,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average: Optional[float] = 0.0, vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0, vote_count: Optional[int] = 0,
release_date: Optional[str] = "", release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]: page: Optional[int] = 1,
raise_exception: bool = False) -> List[dict]:
""" """
异步TMDB热门电视剧 异步TMDB热门电视剧
""" """
@@ -352,16 +355,23 @@ class RecommendChain(ChainBase, metaclass=Singleton):
vote_average=vote_average, vote_average=vote_average,
vote_count=vote_count, vote_count=vote_count,
release_date=release_date, release_date=release_date,
page=page) page=page,
raise_exception=raise_exception)
return [tv.to_dict() for tv in tvs] if tvs else [] return [tv.to_dict() for tv in tvs] if tvs else []
@log_execution_time(logger=logger) @log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @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流行趋势 异步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 [] return [info.to_dict() for info in infos] if infos else []
@log_execution_time(logger=logger) @log_execution_time(logger=logger)
+2
View File
@@ -99,6 +99,8 @@
"messages": { "messages": {
"模块不支持测试": "Module does not support testing", "模块不支持测试": "Module does not support testing",
"网络请求失败": "Network request failed", "网络请求失败": "Network request failed",
"TMDB请求失败": "TMDB request failed",
"媒体服务器请求失败": "Media server request failed",
"附件保存失败": "Failed to save attachment", "附件保存失败": "Failed to save attachment",
"该选择已失效,请重新发起选择": "This selection has expired. Please start the selection again", "该选择已失效,请重新发起选择": "This selection has expired. Please start the selection again",
"会话不存在或无权访问": "The conversation does not exist or you do not have access", "会话不存在或无权访问": "The conversation does not exist or you do not have access",
+2
View File
@@ -99,6 +99,8 @@
"messages": { "messages": {
"模块不支持测试": "模块不支持测试", "模块不支持测试": "模块不支持测试",
"网络请求失败": "网络请求失败", "网络请求失败": "网络请求失败",
"TMDB请求失败": "TMDB请求失败",
"媒体服务器请求失败": "媒体服务器请求失败",
"豆瓣网络连接失败": "豆瓣网络连接失败", "豆瓣网络连接失败": "豆瓣网络连接失败",
"Bangumi网络连接失败": "Bangumi网络连接失败", "Bangumi网络连接失败": "Bangumi网络连接失败",
"fanart网络连接失败": "fanart网络连接失败", "fanart网络连接失败": "fanart网络连接失败",
+2
View File
@@ -99,6 +99,8 @@
"messages": { "messages": {
"模块不支持测试": "模組不支援測試", "模块不支持测试": "模組不支援測試",
"网络请求失败": "網路請求失敗", "网络请求失败": "網路請求失敗",
"TMDB请求失败": "TMDB 請求失敗",
"媒体服务器请求失败": "媒體伺服器請求失敗",
"附件保存失败": "附件儲存失敗", "附件保存失败": "附件儲存失敗",
"该选择已失效,请重新发起选择": "此選擇已失效,請重新發起選擇", "该选择已失效,请重新发起选择": "此選擇已失效,請重新發起選擇",
"会话不存在或无权访问": "會話不存在或無權存取", "会话不存在或无权访问": "會話不存在或無權存取",
+5 -6
View File
@@ -282,13 +282,13 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
) for season, episodes in seasoninfo.items()] ) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, server: str, count: Optional[int] = 20, 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) server_obj: Emby = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_resume(num=count, username=username) return server_obj.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: 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) return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, 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) server_obj: Emby = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_latest(num=count, username=username) return server_obj.get_latest(num=count, username=username)
def mediaserver_latest_images(self, def mediaserver_latest_images(self,
@@ -345,8 +345,7 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
return [] return []
links = [] links = []
items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, items = self.mediaserver_latest(server=server, count=count, username=username) or []
username=username)
for item in items: for item in items:
if item.BackdropImageTags: if item.BackdropImageTags:
image_url = server_obj.get_backdrop_url(item_id=item.id, image_url = server_obj.get_backdrop_url(item_id=item.id,
+24 -11
View File
@@ -118,38 +118,47 @@ class Emby:
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e)) logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
return [] 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媒体库列表 获取Emby媒体库列表
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return [] return None
if username: if username:
user = self.get_user(username) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user:
return None
url = f"{self._host}emby/Users/{user}/Views" url = f"{self._host}emby/Users/{user}/Views"
params = {"api_key": self._apikey} params = {"api_key": self._apikey}
try: try:
res = RequestUtils().get_res(url, params) res = RequestUtils().get_res(url, params)
if res: if res:
return res.json().get("Items") items = res.json().get("Items")
return items if isinstance(items, list) else None
else: else:
logger.error(f"User/Views 未获取到返回数据") logger.error(f"User/Views 未获取到返回数据")
return [] return None
except Exception as e: except Exception as e:
logger.error(f"连接User/Views 出错:" + str(e)) logger.error(f"连接User/Views 出错:" + str(e))
return [] return None
def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[ def get_librarys(
schemas.MediaServerLibrary]: self,
username: Optional[str] = None,
hidden: Optional[bool] = False,
) -> Optional[List[schemas.MediaServerLibrary]]:
""" """
获取媒体服务器所有媒体库列表 获取媒体服务器所有媒体库列表
""" """
if not self._host or not self._apikey: 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 = [] 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 \ if hidden and self._sync_libraries and "all" not in self._sync_libraries \
and library.get("Id") not in self._sync_libraries: and library.get("Id") not in self._sync_libraries:
continue continue
@@ -1206,6 +1215,8 @@ class Emby:
user = self.get_user(username) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Resume" url = f"{self._host}Users/{user}/Items/Resume"
params = { params = {
"Limit": 100, "Limit": 100,
@@ -1266,7 +1277,7 @@ class Emby:
logger.error(f"Users/Items/Resume 未获取到返回数据") logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items/Resume出错:" + str(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[ def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[
List[schemas.MediaServerPlayItem]]: List[schemas.MediaServerPlayItem]]:
@@ -1279,6 +1290,8 @@ class Emby:
user = self.get_user(username) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Latest" url = f"{self._host}Users/{user}/Items/Latest"
params = { params = {
"Limit": 100, "Limit": 100,
@@ -1323,7 +1336,7 @@ class Emby:
logger.error(f"Users/Items/Latest 未获取到返回数据") logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items/Latest出错:" + str(e)) logger.error(f"连接Users/Items/Latest出错:" + str(e))
return [] return None
def get_user_library_folders(self): def get_user_library_folders(self):
""" """
+6 -6
View File
@@ -281,13 +281,14 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
) for season, episodes in seasoninfo.items()] ) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, server: str, 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) server_obj: Jellyfin = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_resume(num=count, username=username) return server_obj.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: 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) return server_obj.get_season_episode_ids(str(item_id), season)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, 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) server_obj: Jellyfin = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_latest(num=count, username=username) return server_obj.get_latest(num=count, username=username)
def mediaserver_latest_images(self, def mediaserver_latest_images(self,
@@ -344,8 +345,7 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
return [] return []
links = [] links = []
items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, items = self.mediaserver_latest(server=server, count=count, username=username) or []
username=username)
for item in items: for item in items:
if item.BackdropImageTags: if item.BackdropImageTags:
image_url = server_obj.get_backdrop_url(item_id=item.id, image_url = server_obj.get_backdrop_url(item_id=item.id,
+24 -12
View File
@@ -114,42 +114,50 @@ class Jellyfin:
logger.error(f"连接Library/VirtualFolders 出错:" + str(e)) logger.error(f"连接Library/VirtualFolders 出错:" + str(e))
return [] 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媒体库的信息 获取Jellyfin媒体库的信息
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return [] return None
if username: if username:
user = self.get_user(username) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user: if not user:
return [] return None
# 使用标准库路径拼接结合统一 URL 规整,避免 host 尾部斜杠缺失导致的寻址偏移。 # 使用标准库路径拼接结合统一 URL 规整,避免 host 尾部斜杠缺失导致的寻址偏移。
url = UrlUtils.combine_url(self._host, posixpath.join("Users", str(user), "Views")) url = UrlUtils.combine_url(self._host, posixpath.join("Users", str(user), "Views"))
if not url: if not url:
return [] return None
params = {"api_key": self._apikey} params = {"api_key": self._apikey}
try: try:
res = RequestUtils().get_res(url, params) res = RequestUtils().get_res(url, params)
if res: if res:
return res.json().get("Items") items = res.json().get("Items")
return items if isinstance(items, list) else None
else: else:
logger.error(f"Users/Views 未获取到返回数据") logger.error(f"Users/Views 未获取到返回数据")
return [] return None
except Exception as e: except Exception as e:
logger.error(f"连接Users/Views 出错:" + str(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: 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 = [] 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 \ if hidden and self._sync_libraries and "all" not in self._sync_libraries \
and library.get("Id") not in self._sync_libraries: and library.get("Id") not in self._sync_libraries:
continue continue
@@ -1027,6 +1035,8 @@ class Jellyfin:
user = self.get_user(username) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Resume" url = f"{self._host}Users/{user}/Items/Resume"
params = { params = {
@@ -1083,7 +1093,7 @@ class Jellyfin:
logger.error(f"Users/Items/Resume 未获取到返回数据") logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items/Resume出错:" + str(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]]: 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) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Latest" url = f"{self._host}Users/{user}/Items/Latest"
params = { params = {
"Limit": 100, "Limit": 100,
@@ -1136,7 +1148,7 @@ class Jellyfin:
logger.error(f"Users/Items/Latest 未获取到返回数据") logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items/Latest出错:" + str(e)) logger.error(f"连接Users/Items/Latest出错:" + str(e))
return [] return None
def get_user_library_folders(self): def get_user_library_folders(self):
""" """
+5 -6
View File
@@ -293,23 +293,23 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
) for season, episodes in seasoninfo.items()] ) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, server: str, count: Optional[int] = 20, 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) server_obj: Plex = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_resume(num=count) return server_obj.get_resume(num=count)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, 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) server_obj: Plex = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_latest(num=count) return server_obj.get_latest(num=count)
def mediaserver_latest_images(self, def mediaserver_latest_images(self,
@@ -331,8 +331,7 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
return [] return []
links = [] links = []
items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, items = self.mediaserver_latest(server=server, count=count, username=username) or []
username=username)
for item in items: for item in items:
link = server_obj.get_remote_image_by_id(item_id=item.id, link = server_obj.get_remote_image_by_id(item_id=item.id,
image_type="Backdrop", image_type="Backdrop",
+13 -7
View File
@@ -122,17 +122,17 @@ class Plex:
return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in
list(poster_urls.keys())[:total_size]] 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: if not self._plex:
return [] return None
try: try:
self._libraries = self._plex.library.sections() self._libraries = self._plex.library.sections()
except Exception as err: except Exception as err:
logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}") logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}")
return [] return None
libraries = [] libraries = []
for library in self._libraries: for library in self._libraries:
if hidden and self._sync_libraries and "all" not in self._sync_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() sections = self._plex.library.sections()
movie_count = tv_count = episode_count = 0 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: for sec in sections:
if str(sec.key) not in allow_library: if str(sec.key) not in allow_library:
continue continue
@@ -832,9 +832,12 @@ class Plex:
获取继续观看的媒体 获取继续观看的媒体
""" """
if not self._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} params = {"contentDirectoryID": allow_library}
items = self._plex.fetchItems("/hubs/continueWatching/items", items = self._plex.fetchItems("/hubs/continueWatching/items",
container_start=0, container_start=0,
@@ -871,7 +874,10 @@ class Plex:
if not self._plex: if not self._plex:
return None 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 = { params = {
"contentDirectoryID": allow_library, "contentDirectoryID": allow_library,
"count": num, "count": num,
+11 -5
View File
@@ -1265,7 +1265,8 @@ class TheMovieDbModule(_ModuleBase):
vote_average: float, vote_average: float,
vote_count: int, vote_count: int,
release_date: str, release_date: str,
page: Optional[int] = 1) -> Optional[List[MediaInfo]]: page: Optional[int] = 1,
raise_exception: bool = False) -> Optional[List[MediaInfo]]:
""" """
TMDB发现功能(异步版本) TMDB发现功能(异步版本)
:param mtype: 媒体类型 :param mtype: 媒体类型
@@ -1291,7 +1292,7 @@ class TheMovieDbModule(_ModuleBase):
"vote_count.gte": vote_count, "vote_count.gte": vote_count,
"release_date.gte": release_date, "release_date.gte": release_date,
"page": page "page": page
}) }, raise_exception=raise_exception)
elif mtype == MediaType.TV: elif mtype == MediaType.TV:
infos = await self.tmdb.async_discover_tvs({ infos = await self.tmdb.async_discover_tvs({
"sort_by": sort_by, "sort_by": sort_by,
@@ -1303,20 +1304,25 @@ class TheMovieDbModule(_ModuleBase):
"vote_count.gte": vote_count, "vote_count.gte": vote_count,
"first_air_date.gte": release_date, "first_air_date.gte": release_date,
"page": page "page": page
}) }, raise_exception=raise_exception)
else: else:
return [] return []
if infos: if infos:
return [MediaInfo(tmdb_info=info) for info in infos] return [MediaInfo(tmdb_info=info) for info in infos]
return [] 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流行趋势(异步版本) TMDB流行趋势(异步版本)
:param page: 第几页 :param page: 第几页
:return: TMDB信息列表 :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: if trending:
return [MediaInfo(tmdb_info=info) for info in trending] return [MediaInfo(tmdb_info=info) for info in trending]
return [] return []
+15 -3
View File
@@ -1758,7 +1758,9 @@ class TmdbApi:
ret_infos.append(tv) ret_infos.append(tv)
return ret_infos 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 return items
except Exception as e: except Exception as e:
logger.error(f"获取电影发现失败:{str(e)}") logger.error(f"获取电影发现失败:{str(e)}")
if raise_exception:
raise
return [] 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 return items
except Exception as e: except Exception as e:
logger.error(f"获取电视剧发现失败:{str(e)}") logger.error(f"获取电视剧发现失败:{str(e)}")
if raise_exception:
raise
return [] return []
async def async_search_persons(self, name: str) -> List[dict]: async def async_search_persons(self, name: str) -> List[dict]:
@@ -2006,7 +2014,9 @@ class TmdbApi:
logger.error(str(e)) logger.error(str(e))
return {} 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) return self._normalize_trending_infos(tmdbinfo)
except Exception as e: except Exception as e:
logger.error(str(e)) logger.error(str(e))
if raise_exception:
raise
return [] return []
async def async_get_movie_images( async def async_get_movie_images(
+6 -6
View File
@@ -332,14 +332,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
def mediaserver_playing( def mediaserver_playing(
self, server: str, count: Optional[int] = 20, **kwargs self, server: str, count: Optional[int] = 20, **kwargs
) -> List[schemas.MediaServerPlayItem]: ) -> Optional[List[schemas.MediaServerPlayItem]]:
""" """
获取媒体服务器正在播放信息 获取媒体服务器正在播放信息
""" """
server_obj: Optional[TrimeMedia] = self.get_instance(server) server_obj: Optional[TrimeMedia] = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_resume(num=count) or [] return server_obj.get_resume(num=count)
def mediaserver_play_url( def mediaserver_play_url(
self, server: str, item_id: Union[str, int] self, server: str, item_id: Union[str, int]
@@ -359,14 +359,14 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]):
server: Optional[str] = None, server: Optional[str] = None,
count: Optional[int] = 20, count: Optional[int] = 20,
**kwargs, **kwargs,
) -> List[schemas.MediaServerPlayItem]: ) -> Optional[List[schemas.MediaServerPlayItem]]:
""" """
获取媒体服务器最新入库条目 获取媒体服务器最新入库条目
""" """
server_obj: Optional[TrimeMedia] = self.get_instance(server) server_obj: Optional[TrimeMedia] = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_latest(num=count) or [] return server_obj.get_latest(num=count)
def mediaserver_latest_images( def mediaserver_latest_images(
self, self,
+13 -9
View File
@@ -163,16 +163,18 @@ class TrimeMedia:
def get_librarys( def get_librarys(
self, hidden: Optional[bool] = False self, hidden: Optional[bool] = False
) -> List[schemas.MediaServerLibrary]: ) -> Optional[List[schemas.MediaServerLibrary]]:
""" """
获取媒体服务器所有媒体库列表 获取媒体服务器所有媒体库列表
""" """
if not self.is_authenticated(): if not self.is_authenticated():
return [] return None
if self._userinfo.is_admin == 1: if self._userinfo.is_admin == 1:
mdb_list = self._api.mdb_list() or [] mdb_list = self._api.mdb_list()
else: 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} self._libraries = {lib.guid: lib for lib in mdb_list}
libraries = [] libraries = []
for library in self._libraries.values(): for library in self._libraries.values():
@@ -584,8 +586,11 @@ class TrimeMedia:
""" """
if not self.is_authenticated(): if not self.is_authenticated():
return None return None
items = self._api.play_list()
if items is None:
return None
ret_resume = [] ret_resume = []
for item in self._api.play_list() or []: for item in items:
if len(ret_resume) == num: if len(ret_resume) == num:
break break
if self.__is_library_blocked(item.ancestor_guid): if self.__is_library_blocked(item.ancestor_guid):
@@ -599,14 +604,13 @@ class TrimeMedia:
""" """
if not self.is_authenticated(): if not self.is_authenticated():
return None return None
items = ( items = self._api.item_list(
self._api.item_list(
page=1, page=1,
page_size=max(100, num * 5), page_size=max(100, num * 5),
types=[fnapi.Type.MOVIE, fnapi.Type.TV], types=[fnapi.Type.MOVIE, fnapi.Type.TV],
) )
or [] if items is None:
) return None
latest = [] latest = []
for item in items: for item in items:
if len(latest) == num: if len(latest) == num:
+6 -6
View File
@@ -304,14 +304,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
def mediaserver_playing( def mediaserver_playing(
self, server: str, count: Optional[int] = 20, **kwargs self, server: str, count: Optional[int] = 20, **kwargs
) -> List[schemas.MediaServerPlayItem]: ) -> Optional[List[schemas.MediaServerPlayItem]]:
""" """
获取媒体服务器正在播放信息 获取媒体服务器正在播放信息
""" """
server_obj: Optional[Ugreen] = self.get_instance(server) server_obj: Optional[Ugreen] = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_resume(num=count) or [] return server_obj.get_resume(num=count)
def mediaserver_play_url( def mediaserver_play_url(
self, server: str, item_id: Union[str, int] self, server: str, item_id: Union[str, int]
@@ -331,14 +331,14 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]):
server: Optional[str] = None, server: Optional[str] = None,
count: Optional[int] = 20, count: Optional[int] = 20,
**kwargs, **kwargs,
) -> List[schemas.MediaServerPlayItem]: ) -> Optional[List[schemas.MediaServerPlayItem]]:
""" """
获取媒体服务器最新入库条目 获取媒体服务器最新入库条目
""" """
server_obj: Optional[Ugreen] = self.get_instance(server) server_obj: Optional[Ugreen] = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_latest(num=count) or [] return server_obj.get_latest(num=count)
def mediaserver_latest_images( def mediaserver_latest_images(
self, self,
+3 -3
View File
@@ -489,15 +489,15 @@ class Api:
return None return None
return dict(result.data) return dict(result.data)
def media_list(self) -> list[dict]: def media_list(self) -> Optional[list[dict]]:
""" """
获取首页媒体库列表`media_lib_info_list` 获取首页媒体库列表`media_lib_info_list`
""" """
result = self.request("v1/video/homepage/media_list") result = self.request("v1/video/homepage/media_list")
if not result.success or not isinstance(result.data, Mapping): if not result.success or not isinstance(result.data, Mapping):
return [] return None
items = result.data.get("media_lib_info_list") 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]: def media_lib_users(self) -> list[dict]:
""" """
+8 -6
View File
@@ -520,7 +520,7 @@ class Ugreen:
return paths 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: 媒体库列表 :return: 媒体库列表
""" """
if not self.is_authenticated() or not self._api: if not self.is_authenticated() or not self._api:
return [] return None
media_libs = self._api.media_list() media_libs = self._api.media_list()
if media_libs is None:
return None
self._library_paths = self.__load_library_paths() self._library_paths = self.__load_library_paths()
libraries = [] libraries = []
self._libraries = {} self._libraries = {}
@@ -957,8 +959,8 @@ class Ugreen:
page_size = max(1, num or 12) page_size = max(1, num or 12)
data = self._api.recently_played(page=1, page_size=page_size) data = self._api.recently_played(page=1, page_size=page_size)
if not data: if data is None:
return [] return None
ret_resume = [] ret_resume = []
for item in data.get("video_arr") or []: for item in data.get("video_arr") or []:
@@ -982,8 +984,8 @@ class Ugreen:
page_size = max(1, num) page_size = max(1, num)
data = self._api.recently_updated(page=1, page_size=page_size) data = self._api.recently_updated(page=1, page_size=page_size)
if not data: if data is None:
return [] return None
latest = [] latest = []
for item in data.get("video_arr") or []: for item in data.get("video_arr") or []:
+5 -6
View File
@@ -276,13 +276,13 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
) for season, episodes in seasoninfo.items()] ) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, server: str, count: Optional[int] = 20, 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) server_obj: ZSpace = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_resume(num=count, username=username) return server_obj.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]: 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) return server_obj.get_play_url(item_id)
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20, 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) server_obj: ZSpace = self.get_instance(server)
if not server_obj: if not server_obj:
return [] return None
return server_obj.get_latest(num=count, username=username) return server_obj.get_latest(num=count, username=username)
def mediaserver_latest_images(self, def mediaserver_latest_images(self,
@@ -324,8 +324,7 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]):
return [] return []
links = [] links = []
items: List[schemas.MediaServerPlayItem] = self.mediaserver_latest(server=server, count=count, items = self.mediaserver_latest(server=server, count=count, username=username) or []
username=username)
for item in items: for item in items:
if item.BackdropImageTags: if item.BackdropImageTags:
image_url = server_obj.get_backdrop_url(item_id=item.id, image_url = server_obj.get_backdrop_url(item_id=item.id,
+21 -14
View File
@@ -198,39 +198,46 @@ class ZSpace:
}) })
return libraries 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: if not self._host or not self._apikey:
return [] return None
if username: if username:
user = self.get_user(username) user = self.get_user(username)
else: else:
user = self.user user = self.user
if not user: if not user:
return [] return None
url = f"{self._host}emby/Users/{user}/Views" url = f"{self._host}emby/Users/{user}/Views"
try: try:
res = self.__request_utils().get_res(url) res = self.__request_utils().get_res(url)
if res: if res:
return res.json().get("Items") items = res.json().get("Items")
return items if isinstance(items, list) else None
else: else:
logger.error("Users/Views 未获取到返回数据") logger.error("Users/Views 未获取到返回数据")
return [] return None
except Exception as e: except Exception as e:
logger.error(f"连接Users/Views 出错:{e}") logger.error(f"连接Users/Views 出错:{e}")
return [] return None
def get_librarys(self, username: Optional[str] = None, hidden: Optional[bool] = False) -> List[ def get_librarys(
schemas.MediaServerLibrary]: self,
username: Optional[str] = None,
hidden: Optional[bool] = False,
) -> Optional[List[schemas.MediaServerLibrary]]:
""" """
获取媒体服务器所有媒体库列表 获取媒体服务器所有媒体库列表
""" """
if not self._host or not self._apikey: 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 = [] 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 \ if hidden and self._sync_libraries and "all" not in self._sync_libraries \
and library.get("Id") not in self._sync_libraries: and library.get("Id") not in self._sync_libraries:
continue continue
@@ -1085,7 +1092,7 @@ class ZSpace:
else: else:
user = self.user user = self.user
if not user: if not user:
return [] return None
url = f"{self._host}emby/Users/{user}/Items/Resume" url = f"{self._host}emby/Users/{user}/Items/Resume"
params = { params = {
"Limit": 100, "Limit": 100,
@@ -1141,7 +1148,7 @@ class ZSpace:
logger.error("Users/Items/Resume 未获取到返回数据") logger.error("Users/Items/Resume 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items/Resume出错:{e}") logger.error(f"连接Users/Items/Resume出错:{e}")
return [] return None
def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[ def get_latest(self, num: Optional[int] = 20, username: Optional[str] = None) -> Optional[
List[schemas.MediaServerPlayItem]]: List[schemas.MediaServerPlayItem]]:
@@ -1160,7 +1167,7 @@ class ZSpace:
else: else:
user = self.user user = self.user
if not user: if not user:
return [] return None
url = f"{self._host}emby/Users/{user}/Items" url = f"{self._host}emby/Users/{user}/Items"
params = { params = {
"Recursive": "true", "Recursive": "true",
@@ -1208,7 +1215,7 @@ class ZSpace:
logger.debug("Users/Items?SortBy=DateCreated 未获取到返回数据") logger.debug("Users/Items?SortBy=DateCreated 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接 Users/ItemsDateCreated 排序)出错:{e}") logger.error(f"连接 Users/ItemsDateCreated 排序)出错:{e}")
return [] return None
def get_user_library_folders(self): def get_user_library_folders(self):
""" """
+2
View File
@@ -3,6 +3,8 @@ testpaths =
tests tests
timeout = 120 timeout = 120
timeout_method = thread timeout_method = thread
asyncio_mode = strict
asyncio_default_fixture_loop_scope = function
# 仅对「无法在本仓修复根因」的已知上游/三方弃用告警做精确忽略,保持测试输出干净、 # 仅对「无法在本仓修复根因」的已知上游/三方弃用告警做精确忽略,保持测试输出干净、
# 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。 # 让本仓自身的新告警更醒目。本仓代码引发的告警一律不在此忽略,应在源码/用例处修复。
filterwarnings = filterwarnings =
+1
View File
@@ -3,6 +3,7 @@
Cython~=3.2.5 Cython~=3.2.5
pylint~=4.0.6 pylint~=4.0.6
pytest~=9.0.3 pytest~=9.0.3
pytest-asyncio~=1.4.0
pytest-cov~=7.1.0 pytest-cov~=7.1.0
pytest-timeout~=2.4.0 pytest-timeout~=2.4.0
uv~=0.11.23 uv~=0.11.23
+2 -2
View File
@@ -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 = self._build_client()
client.user = None client.user = None
with patch.object(jellyfin_module, "RequestUtils") as request_utils_cls: with patch.object(jellyfin_module, "RequestUtils") as request_utils_cls:
libraries = client._Jellyfin__get_jellyfin_librarys() libraries = client._Jellyfin__get_jellyfin_librarys()
self.assertEqual(libraries, []) self.assertIsNone(libraries)
request_utils_cls.assert_not_called() request_utils_cls.assert_not_called()
def test_get_jellyfin_librarys_uses_normalized_views_url(self): def test_get_jellyfin_librarys_uses_normalized_views_url(self):
@@ -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
+110
View File
@@ -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)