mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-13 07:26:45 +00:00
feat: refactor ZSpace media server request handling and improve authorization headers
This commit is contained in:
@@ -45,6 +45,46 @@ class ZSpace:
|
||||
if not self.reconnect():
|
||||
logger.error(f"请检查极影视服务端地址 {host}")
|
||||
|
||||
@staticmethod
|
||||
def __get_client_authorization() -> str:
|
||||
"""
|
||||
构造客户端标识头。
|
||||
|
||||
极影视兼容 Emby 登录接口时,需要携带客户端、设备和版本信息,
|
||||
这里统一复用一份固定头,避免各接口散落重复字符串。
|
||||
"""
|
||||
return 'MediaBrowser Client="MoviePilot", Device="requests", DeviceId="1", Version="1.0.0"'
|
||||
|
||||
@staticmethod
|
||||
def __get_user_authorization(user_id: Union[str, int]) -> str:
|
||||
"""
|
||||
构造用户态授权头。
|
||||
|
||||
保留这个组装函数,便于后续需要显式传递用户态 Authorization 头时复用。
|
||||
当前极影视兼容 Emby 实测主要依赖 `X-Emby-Token`,这里不默认附带该头,
|
||||
避免部分实现把非 GUID 的用户 ID 校验为非法格式。
|
||||
"""
|
||||
return f'Emby UserId="{user_id}", Client="MoviePilot", Device="requests", DeviceId="1", Version="1.0.0"'
|
||||
|
||||
def __request_utils(self,
|
||||
timeout: Optional[int] = None,
|
||||
include_token: bool = True,
|
||||
headers: Optional[dict] = None) -> RequestUtils:
|
||||
"""
|
||||
统一构造极影视请求客户端。
|
||||
|
||||
极影视这里使用用户名密码登录获取 AccessToken,后续 API 请求优先通过
|
||||
`X-Emby-Token` 请求头传递 token,而不是把登录 token 当成静态 API Key。
|
||||
"""
|
||||
request_headers = {
|
||||
"X-Emby-Authorization": self.__get_client_authorization()
|
||||
}
|
||||
if include_token and self._apikey:
|
||||
request_headers["X-Emby-Token"] = self._apikey
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
return RequestUtils(headers=request_headers, timeout=timeout)
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
"""
|
||||
判断是否需要重连
|
||||
@@ -88,11 +128,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
url = f"{self._host}emby/Library/SelectableMediaFolders"
|
||||
params = {
|
||||
'api_key': self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
return res.json()
|
||||
else:
|
||||
@@ -109,11 +146,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
url = f"{self._host}emby/Library/VirtualFolders/Query"
|
||||
params = {
|
||||
'api_key': self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
library_items = res.json().get("Items")
|
||||
libraries = []
|
||||
@@ -155,9 +189,8 @@ class ZSpace:
|
||||
if not user:
|
||||
return []
|
||||
url = f"{self._host}emby/Users/{user}/Views"
|
||||
params = {"api_key": self._apikey}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
return res.json().get("Items")
|
||||
else:
|
||||
@@ -207,12 +240,9 @@ class ZSpace:
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = f"{self._host}Users"
|
||||
params = {
|
||||
"api_key": self._apikey
|
||||
}
|
||||
url = f"{self._host}emby/Users"
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
users = res.json()
|
||||
if isinstance(users, list):
|
||||
@@ -252,12 +282,9 @@ class ZSpace:
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = f"{self._host}System/Info"
|
||||
params = {
|
||||
'api_key': self._apikey
|
||||
}
|
||||
url = f"{self._host}emby/System/Info"
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
return res.json().get("Id")
|
||||
else:
|
||||
@@ -273,11 +300,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey:
|
||||
return 0
|
||||
url = f"{self._host}emby/Users/Query"
|
||||
params = {
|
||||
'api_key': self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
count = res.json().get("TotalRecordCount")
|
||||
if count:
|
||||
@@ -296,11 +320,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey:
|
||||
return schemas.Statistic()
|
||||
url = f"{self._host}emby/Items/Counts"
|
||||
params = {
|
||||
'api_key': self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
@@ -332,11 +353,10 @@ class ZSpace:
|
||||
"Recursive": "true",
|
||||
"SearchTerm": name,
|
||||
"Limit": 10,
|
||||
"IncludeSearchTypes": "false",
|
||||
"api_key": self._apikey
|
||||
"IncludeSearchTypes": "false"
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url, params=params)
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
@@ -370,11 +390,10 @@ class ZSpace:
|
||||
"Recursive": "true",
|
||||
"SearchTerm": title,
|
||||
"Limit": 10,
|
||||
"IncludeSearchTypes": "false",
|
||||
"api_key": self._apikey
|
||||
"IncludeSearchTypes": "false"
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url, params=params)
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
@@ -439,10 +458,9 @@ class ZSpace:
|
||||
url = f"{self._host}emby/Shows/{item_id}/Episodes"
|
||||
params = {
|
||||
"Season": season,
|
||||
"IsMissing": "false",
|
||||
"api_key": self._apikey
|
||||
"IsMissing": "false"
|
||||
}
|
||||
res_json = RequestUtils().get_res(url, params)
|
||||
res_json = self.__request_utils().get_res(url, params=params)
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
@@ -475,11 +493,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = f"{self._host}emby/Items/{item_id}/RemoteImages"
|
||||
params = {
|
||||
"api_key": self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils(timeout=10).get_res(url, params)
|
||||
res = self.__request_utils(timeout=10).get_res(url)
|
||||
if res:
|
||||
images = res.json().get("Images")
|
||||
if images:
|
||||
@@ -503,9 +518,9 @@ class ZSpace:
|
||||
logger.error("极影视外网播放地址未能获取或为空")
|
||||
return None
|
||||
|
||||
url = f"{self._playhost}Items/{item_id}/Images/{image_type}"
|
||||
url = f"{self._playhost}emby/Items/{item_id}/Images/{image_type}"
|
||||
try:
|
||||
res = RequestUtils().get_res(url)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res and res.status_code != 404:
|
||||
logger.info(f"影片图片链接:{res.url}")
|
||||
return res.url
|
||||
@@ -524,11 +539,10 @@ class ZSpace:
|
||||
return False
|
||||
url = f"{self._host}emby/Items/{item_id}/Refresh"
|
||||
params = {
|
||||
"Recursive": "true",
|
||||
"api_key": self._apikey
|
||||
"Recursive": "true"
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().post_res(url, params=params)
|
||||
res = self.__request_utils().post_res(url, params=params)
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
@@ -545,11 +559,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey:
|
||||
return False
|
||||
url = f"{self._host}emby/Library/Refresh"
|
||||
params = {
|
||||
"api_key": self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().post_res(url, params=params)
|
||||
res = self.__request_utils().post_res(url)
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
@@ -662,11 +673,8 @@ class ZSpace:
|
||||
if not self._host or not self._apikey or not self.user:
|
||||
return None
|
||||
url = f"{self._host}emby/Users/{self.user}/Items/{itemid}"
|
||||
params = {
|
||||
"api_key": self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res and res.status_code == 200:
|
||||
iteminfo = self.__format_item_info(res.json())
|
||||
return iteminfo
|
||||
@@ -690,7 +698,6 @@ class ZSpace:
|
||||
url = f"{self._host}emby/Users/{self.user}/Items"
|
||||
params = {
|
||||
"ParentId": parent,
|
||||
"api_key": self._apikey,
|
||||
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId"
|
||||
}
|
||||
if limit is not None and limit != -1:
|
||||
@@ -699,7 +706,7 @@ class ZSpace:
|
||||
"Limit": limit
|
||||
})
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url, params=params)
|
||||
if not res or res.status_code != 200:
|
||||
return None
|
||||
items = res.json().get("Items") or []
|
||||
@@ -807,7 +814,8 @@ class ZSpace:
|
||||
|
||||
def get_data(self, url: str) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值。
|
||||
极影视这里的 [APIKEY] 实际替换为登录返回的 AccessToken,以兼容现有占位符。
|
||||
:param url: 请求地址
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
@@ -816,14 +824,15 @@ class ZSpace:
|
||||
.replace("[APIKEY]", self._apikey or '') \
|
||||
.replace("[USER]", self.user or '')
|
||||
try:
|
||||
return RequestUtils(content_type="application/json").get_res(url=url)
|
||||
return self.__request_utils(headers={"Content-Type": "application/json"}).get_res(url=url)
|
||||
except Exception as e:
|
||||
logger.error(f"连接极影视出错:{e}")
|
||||
return None
|
||||
|
||||
def post_data(self, url: str, data: Optional[str] = None, headers: dict = None) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值。
|
||||
极影视这里的 [APIKEY] 实际替换为登录返回的 AccessToken,以兼容现有占位符。
|
||||
:param url: 请求地址
|
||||
:param data: 请求数据
|
||||
:param headers: 请求头
|
||||
@@ -834,9 +843,7 @@ class ZSpace:
|
||||
.replace("[APIKEY]", self._apikey or '') \
|
||||
.replace("[USER]", self.user or '')
|
||||
try:
|
||||
return RequestUtils(
|
||||
headers=headers,
|
||||
).post_res(url=url, data=data)
|
||||
return self.__request_utils(headers=headers).post_res(url=url, data=data)
|
||||
except Exception as e:
|
||||
logger.error(f"连接极影视出错:{e}")
|
||||
return None
|
||||
@@ -864,7 +871,7 @@ class ZSpace:
|
||||
host_url = self._playhost or self._host
|
||||
else:
|
||||
host_url = self._host
|
||||
return f"{host_url}Items/{item_id}/" \
|
||||
return f"{host_url}emby/Items/{item_id}/" \
|
||||
f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}"
|
||||
|
||||
def __get_local_image_by_id(self, item_id: str) -> str:
|
||||
@@ -874,7 +881,7 @@ class ZSpace:
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return ""
|
||||
return f"{self._host}Items/{item_id}/Images/Primary"
|
||||
return f"{self._host}emby/Items/{item_id}/Images/Primary?api_key={self._apikey}"
|
||||
|
||||
def get_resume(self, num: Optional[int] = 12, username: Optional[str] = None) -> Optional[
|
||||
List[schemas.MediaServerPlayItem]]:
|
||||
@@ -889,15 +896,14 @@ class ZSpace:
|
||||
user = self.user
|
||||
if not user:
|
||||
return []
|
||||
url = f"{self._host}Users/{user}/Items/Resume"
|
||||
url = f"{self._host}emby/Users/{user}/Items/Resume"
|
||||
params = {
|
||||
"Limit": 100,
|
||||
"MediaTypes": "Video",
|
||||
"Fields": "ProductionYear,Path",
|
||||
"api_key": self._apikey,
|
||||
"Fields": "ProductionYear,Path"
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url, params=params)
|
||||
if res:
|
||||
result = res.json().get("Items") or []
|
||||
ret_resume = []
|
||||
@@ -960,15 +966,14 @@ class ZSpace:
|
||||
user = self.user
|
||||
if not user:
|
||||
return []
|
||||
url = f"{self._host}Users/{user}/Items/Latest"
|
||||
url = f"{self._host}emby/Users/{user}/Items/Latest"
|
||||
params = {
|
||||
"Limit": 100,
|
||||
"MediaTypes": "Video",
|
||||
"Fields": "ProductionYear,Path,BackdropImageTags",
|
||||
"api_key": self._apikey
|
||||
"Fields": "ProductionYear,Path,BackdropImageTags"
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
res = self.__request_utils().get_res(url, params=params)
|
||||
if res:
|
||||
result = res.json() or []
|
||||
ret_latest = []
|
||||
@@ -1023,14 +1028,13 @@ class ZSpace:
|
||||
return None, None
|
||||
url = f"{self._host}emby/Users/AuthenticateByName"
|
||||
try:
|
||||
res = RequestUtils(headers={
|
||||
'X-Emby-Authorization': 'MediaBrowser Client="MoviePilot", '
|
||||
'Device="requests", '
|
||||
'DeviceId="1", '
|
||||
'Version="1.0.0"',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}).post_res(
|
||||
res = self.__request_utils(
|
||||
include_token=False,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
).post_res(
|
||||
url=url,
|
||||
data=json.dumps({
|
||||
"Username": username,
|
||||
@@ -1055,20 +1059,19 @@ class ZSpace:
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = f"{self._host}emby/Users/Me"
|
||||
params = {
|
||||
"api_key": self._apikey
|
||||
}
|
||||
try:
|
||||
res = RequestUtils().get_res(url, params)
|
||||
if res:
|
||||
result = res.json()
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
else:
|
||||
logger.error("Users/Me 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Me出错:{e}")
|
||||
urls = []
|
||||
if self.user:
|
||||
urls.append(f"{self._host}emby/Users/{self.user}")
|
||||
urls.append(f"{self._host}emby/Users/Me")
|
||||
for url in urls:
|
||||
try:
|
||||
res = self.__request_utils().get_res(url)
|
||||
if res:
|
||||
result = res.json()
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"连接 {url} 出错:{e}")
|
||||
return None
|
||||
|
||||
def __get_current_user_id(self) -> Optional[str]:
|
||||
|
||||
@@ -14,17 +14,17 @@ class _FakeResponse:
|
||||
|
||||
class ZSpaceMediaServerTest(unittest.TestCase):
|
||||
def test_reconnect_uses_username_password_login(self):
|
||||
request_utils = Mock()
|
||||
request_utils.post_res.return_value = _FakeResponse({
|
||||
"AccessToken": "zspace-token",
|
||||
"User": {"Id": "user-id"},
|
||||
})
|
||||
request_utils.get_res.side_effect = [
|
||||
_FakeResponse([]),
|
||||
_FakeResponse({"Id": "server-id"}),
|
||||
]
|
||||
with patch("app.modules.zspace.zspace.RequestUtils") as request_utils_cls:
|
||||
request_utils = request_utils_cls.return_value
|
||||
request_utils.post_res.return_value = _FakeResponse({
|
||||
"AccessToken": "zspace-token",
|
||||
"User": {"Id": "user-id"},
|
||||
})
|
||||
request_utils.get_res.side_effect = [
|
||||
_FakeResponse([]),
|
||||
_FakeResponse({"Id": "server-id"}),
|
||||
]
|
||||
|
||||
with patch("app.modules.zspace.zspace.RequestUtils", return_value=request_utils):
|
||||
client = ZSpace(
|
||||
host="http://zspace.local",
|
||||
username="admin",
|
||||
@@ -34,6 +34,21 @@ class ZSpaceMediaServerTest(unittest.TestCase):
|
||||
self.assertEqual(client._apikey, "zspace-token")
|
||||
self.assertEqual(client.user, "user-id")
|
||||
self.assertEqual(client.serverid, "server-id")
|
||||
self.assertEqual(
|
||||
request_utils_cls.call_args_list[0].kwargs["headers"]["X-Emby-Authorization"],
|
||||
'MediaBrowser Client="MoviePilot", Device="requests", DeviceId="1", Version="1.0.0"',
|
||||
)
|
||||
self.assertEqual(
|
||||
request_utils_cls.call_args_list[1].kwargs["headers"]["X-Emby-Token"],
|
||||
"zspace-token",
|
||||
)
|
||||
self.assertIsNone(
|
||||
request_utils_cls.call_args_list[1].kwargs["headers"].get("Authorization")
|
||||
)
|
||||
self.assertEqual(
|
||||
request_utils.get_res.call_args_list[1].args[0],
|
||||
"http://zspace.local/emby/System/Info",
|
||||
)
|
||||
|
||||
def test_get_user_falls_back_to_current_login_user(self):
|
||||
client = ZSpace.__new__(ZSpace)
|
||||
@@ -48,6 +63,14 @@ class ZSpaceMediaServerTest(unittest.TestCase):
|
||||
user_id = client.get_user("admin")
|
||||
|
||||
self.assertEqual(user_id, "current-user-id")
|
||||
self.assertEqual(
|
||||
request_utils_cls.return_value.get_res.call_args.args[0],
|
||||
"http://zspace.local/emby/Users",
|
||||
)
|
||||
self.assertEqual(
|
||||
request_utils_cls.call_args.kwargs["headers"]["X-Emby-Token"],
|
||||
"zspace-token",
|
||||
)
|
||||
|
||||
def test_authenticate_does_not_require_existing_api_key(self):
|
||||
with patch("app.modules.zspace.zspace.RequestUtils") as request_utils_cls:
|
||||
@@ -68,6 +91,57 @@ class ZSpaceMediaServerTest(unittest.TestCase):
|
||||
headers.get("X-Emby-Authorization"),
|
||||
'MediaBrowser Client="MoviePilot", Device="requests", DeviceId="1", Version="1.0.0"',
|
||||
)
|
||||
self.assertIsNone(headers.get("X-Emby-Token"))
|
||||
|
||||
def test_get_resume_uses_emby_path_and_login_token_headers(self):
|
||||
client = ZSpace.__new__(ZSpace)
|
||||
client._host = "http://zspace.local/"
|
||||
client._apikey = "zspace-token"
|
||||
client.user = "user-id"
|
||||
client._sync_libraries = []
|
||||
client.get_user_library_folders = Mock(return_value=[])
|
||||
|
||||
with patch("app.modules.zspace.zspace.RequestUtils") as request_utils_cls:
|
||||
request_utils_cls.return_value.get_res.return_value = _FakeResponse({"Items": []})
|
||||
|
||||
items = client.get_resume()
|
||||
|
||||
self.assertEqual(items, [])
|
||||
self.assertEqual(
|
||||
request_utils_cls.return_value.get_res.call_args.args[0],
|
||||
"http://zspace.local/emby/Users/user-id/Items/Resume",
|
||||
)
|
||||
self.assertEqual(
|
||||
request_utils_cls.return_value.get_res.call_args.kwargs["params"],
|
||||
{
|
||||
"Limit": 100,
|
||||
"MediaTypes": "Video",
|
||||
"Fields": "ProductionYear,Path",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
request_utils_cls.call_args.kwargs["headers"]["X-Emby-Token"],
|
||||
"zspace-token",
|
||||
)
|
||||
|
||||
def test_image_urls_use_emby_compatible_paths(self):
|
||||
client = ZSpace.__new__(ZSpace)
|
||||
client._host = "http://zspace.local/"
|
||||
client._playhost = "http://play.zspace.local/"
|
||||
client._apikey = "zspace-token"
|
||||
|
||||
self.assertEqual(
|
||||
client.get_backdrop_url("item-id", "tag-id"),
|
||||
"http://zspace.local/emby/Items/item-id/Images/Backdrop?tag=tag-id&api_key=zspace-token",
|
||||
)
|
||||
self.assertEqual(
|
||||
client.get_backdrop_url("item-id", "tag-id", remote=True),
|
||||
"http://play.zspace.local/emby/Items/item-id/Images/Backdrop?tag=tag-id&api_key=zspace-token",
|
||||
)
|
||||
self.assertEqual(
|
||||
client._ZSpace__get_local_image_by_id("item-id"),
|
||||
"http://zspace.local/emby/Items/item-id/Images/Primary?api_key=zspace-token",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user