Merge remote-tracking branch 'origin/v2' into v2

This commit is contained in:
jxxghp
2025-11-20 08:19:52 +08:00
8 changed files with 330 additions and 305 deletions
+2
View File
@@ -30,6 +30,8 @@
API文档:https://api.movie-pilot.org API文档:https://api.movie-pilot.org
MCP工具API文档:详见 [docs/mcp-api.md](docs/mcp-api.md)
本地运行需要 `Python 3.12``Node JS v20.12.1` 本地运行需要 `Python 3.12``Node JS v20.12.1`
- 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot) - 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot)
+1 -1
View File
@@ -21,7 +21,7 @@ class QuerySitesInput(BaseModel):
class QuerySitesTool(MoviePilotTool): class QuerySitesTool(MoviePilotTool):
name: str = "query_sites" name: str = "query_sites"
description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration." description: str = "Query site status and list all configured sites. Shows site name, domain, status, priority, and basic configuration. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
args_schema: Type[BaseModel] = QuerySitesInput args_schema: Type[BaseModel] = QuerySitesInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
+2 -2
View File
@@ -20,7 +20,7 @@ class UpdateSiteInput(BaseModel):
site_id: int = Field(..., description="The ID of the site to update") site_id: int = Field(..., description="The ID of the site to update")
name: Optional[str] = Field(None, description="Site name (optional)") name: Optional[str] = Field(None, description="Site name (optional)")
url: Optional[str] = Field(None, description="Site URL (optional, will be automatically formatted)") url: Optional[str] = Field(None, description="Site URL (optional, will be automatically formatted)")
pri: Optional[int] = Field(None, description="Site priority (optional, higher number = higher priority)") pri: Optional[int] = Field(None, description="Site priority (optional, smaller value = higher priority, e.g., pri=1 has higher priority than pri=10)")
rss: Optional[str] = Field(None, description="RSS feed URL (optional)") rss: Optional[str] = Field(None, description="RSS feed URL (optional)")
cookie: Optional[str] = Field(None, description="Site cookie (optional)") cookie: Optional[str] = Field(None, description="Site cookie (optional)")
ua: Optional[str] = Field(None, description="User-Agent string (optional)") ua: Optional[str] = Field(None, description="User-Agent string (optional)")
@@ -39,7 +39,7 @@ class UpdateSiteInput(BaseModel):
class UpdateSiteTool(MoviePilotTool): class UpdateSiteTool(MoviePilotTool):
name: str = "update_site" name: str = "update_site"
description: str = "Update site configuration including URL, priority, authentication credentials (cookie, UA, API key), proxy settings, rate limits, and other site properties. Supports updating multiple site attributes at once." description: str = "Update site configuration including URL, priority, authentication credentials (cookie, UA, API key), proxy settings, rate limits, and other site properties. Supports updating multiple site attributes at once. Site priority (pri): smaller values have higher priority (e.g., pri=1 has higher priority than pri=10)."
args_schema: Type[BaseModel] = UpdateSiteInput args_schema: Type[BaseModel] = UpdateSiteInput
def get_tool_message(self, **kwargs) -> Optional[str]: def get_tool_message(self, **kwargs) -> Optional[str]:
+199 -188
View File
@@ -1,15 +1,16 @@
import base64 import base64
import hashlib
import secrets import secrets
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple, Union from threading import Lock
from typing import List, Optional, Tuple, Union, Dict
from hashlib import sha256
import oss2 import oss2
import requests import httpx
from oss2 import SizedFileAdapter, determine_part_size from oss2 import SizedFileAdapter, determine_part_size
from oss2.models import PartInfo from oss2.models import PartInfo
from cryptography.hazmat.primitives import hashes
from app import schemas from app import schemas
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
@@ -19,8 +20,10 @@ from app.modules.filemanager.storages import transfer_process
from app.schemas.types import StorageSchema from app.schemas.types import StorageSchema
from app.utils.singleton import WeakSingleton from app.utils.singleton import WeakSingleton
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.utils.limit import QpsRateLimiter
lock = threading.Lock()
lock = Lock()
class NoCheckInException(Exception): class NoCheckInException(Exception):
@@ -36,10 +39,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
schema = StorageSchema.U115 schema = StorageSchema.U115
# 支持的整理方式 # 支持的整理方式
transtype = { transtype = {"move": "移动", "copy": "复制"}
"move": "移动",
"copy": "复制"
}
# 基础url # 基础url
base_url = "https://proapi.115.com" base_url = "https://proapi.115.com"
@@ -52,18 +52,28 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._auth_state = {} self._auth_state = {}
self.session = requests.Session() self.session = httpx.Client(follow_redirects=True, timeout=20.0)
self._init_session() self._init_session()
self.qps_limiter: Dict[str, QpsRateLimiter] = {
"/open/ufile/files": QpsRateLimiter(4),
"/open/folder/get_info": QpsRateLimiter(3),
"/open/ufile/move": QpsRateLimiter(2),
"/open/ufile/copy": QpsRateLimiter(2),
"/open/ufile/update": QpsRateLimiter(2),
"/open/ufile/delete": QpsRateLimiter(2),
}
def _init_session(self): def _init_session(self):
""" """
初始化带速率限制的会话 初始化带速率限制的会话
""" """
self.session.headers.update({ self.session.headers.update(
"User-Agent": "W115Storage/2.0", {
"Accept-Encoding": "gzip, deflate", "User-Agent": "W115Storage/2.0",
"Content-Type": "application/x-www-form-urlencoded" "Accept-Encoding": "gzip, deflate",
}) "Content-Type": "application/x-www-form-urlencoded",
}
)
def _check_session(self): def _check_session(self):
""" """
@@ -87,10 +97,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
if expires_in and refresh_time + expires_in < int(time.time()): if expires_in and refresh_time + expires_in < int(time.time()):
tokens = self.__refresh_access_token(refresh_token) tokens = self.__refresh_access_token(refresh_token)
if tokens: if tokens:
self.set_config({ self.set_config({"refresh_time": int(time.time()), **tokens})
"refresh_time": int(time.time()),
**tokens
})
else: else:
return None return None
access_token = tokens.get("access_token") access_token = tokens.get("access_token")
@@ -105,7 +112,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
# 生成PKCE参数 # 生成PKCE参数
code_verifier = secrets.token_urlsafe(96)[:128] code_verifier = secrets.token_urlsafe(96)[:128]
code_challenge = base64.b64encode( code_challenge = base64.b64encode(
hashlib.sha256(code_verifier.encode("utf-8")).digest() sha256(code_verifier.encode("utf-8")).digest()
).decode("utf-8") ).decode("utf-8")
# 请求设备码 # 请求设备码
resp = self.session.post( resp = self.session.post(
@@ -113,8 +120,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
data={ data={
"client_id": settings.U115_APP_ID, "client_id": settings.U115_APP_ID,
"code_challenge": code_challenge, "code_challenge": code_challenge,
"code_challenge_method": "sha256" "code_challenge_method": "sha256",
} },
) )
if resp is None: if resp is None:
return {}, "网络错误" return {}, "网络错误"
@@ -126,13 +133,11 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"code_verifier": code_verifier, "code_verifier": code_verifier,
"uid": result["data"]["uid"], "uid": result["data"]["uid"],
"time": result["data"]["time"], "time": result["data"]["time"],
"sign": result["data"]["sign"] "sign": result["data"]["sign"],
} }
# 生成二维码内容 # 生成二维码内容
return { return {"codeContent": result["data"]["qrcode"]}, ""
"codeContent": result['data']['qrcode']
}, ""
def check_login(self) -> Optional[Tuple[dict, str]]: def check_login(self) -> Optional[Tuple[dict, str]]:
""" """
@@ -146,8 +151,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
params={ params={
"uid": self._auth_state["uid"], "uid": self._auth_state["uid"],
"time": self._auth_state["time"], "time": self._auth_state["time"],
"sign": self._auth_state["sign"] "sign": self._auth_state["sign"],
} },
) )
if resp is None: if resp is None:
return {}, "网络错误" return {}, "网络错误"
@@ -156,11 +161,11 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return {}, result.get("message") return {}, result.get("message")
if result["data"]["status"] == 2: if result["data"]["status"] == 2:
tokens = self.__get_access_token() tokens = self.__get_access_token()
self.set_config({ self.set_config({"refresh_time": int(time.time()), **tokens})
"refresh_time": int(time.time()), return {
**tokens "status": result["data"]["status"],
}) "tip": result["data"]["msg"],
return {"status": result["data"]["status"], "tip": result["data"]["msg"]}, "" }, ""
except Exception as e: except Exception as e:
return {}, str(e) return {}, str(e)
@@ -174,8 +179,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"https://passportapi.115.com/open/deviceCodeToToken", "https://passportapi.115.com/open/deviceCodeToToken",
data={ data={
"uid": self._auth_state["uid"], "uid": self._auth_state["uid"],
"code_verifier": self._auth_state["code_verifier"] "code_verifier": self._auth_state["code_verifier"],
} },
) )
if resp is None: if resp is None:
raise Exception("获取 access_token 失败") raise Exception("获取 access_token 失败")
@@ -190,21 +195,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
""" """
resp = self.session.post( resp = self.session.post(
"https://passportapi.115.com/open/refreshToken", "https://passportapi.115.com/open/refreshToken",
data={ data={"refresh_token": refresh_token},
"refresh_token": refresh_token
}
) )
if resp is None: if resp is None:
logger.error(f"【115】刷新 access_token 失败:refresh_token={refresh_token}") logger.error(
f"【115】刷新 access_token 失败:refresh_token={refresh_token}"
)
return None return None
result = resp.json() result = resp.json()
if result.get("code") != 0: if result.get("code") != 0:
logger.warn(f"【115】刷新 access_token 失败:{result.get('code')} - {result.get('message')}") logger.warn(
f"【115】刷新 access_token 失败:{result.get('code')} - {result.get('message')}"
)
return None return None
return result.get("data") return result.get("data")
def _request_api(self, method: str, endpoint: str, def _request_api(
result_key: Optional[str] = None, **kwargs) -> Optional[Union[dict, list]]: self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs
) -> Optional[Union[dict, list]]:
""" """
带错误处理和速率限制的API请求 带错误处理和速率限制的API请求
""" """
@@ -216,12 +224,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
# 重试次数 # 重试次数
retry_times = kwargs.pop("retry_limit", 5) retry_times = kwargs.pop("retry_limit", 5)
# qps 速率限制
if endpoint in self.qps_limiter:
self.qps_limiter[endpoint].acquire()
try: try:
resp = self.session.request( resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs)
method, f"{self.base_url}{endpoint}", except httpx.RequestError as e:
**kwargs
)
except requests.exceptions.RequestException as e:
logger.error(f"【115】{method} 请求 {endpoint} 网络错误: {str(e)}") logger.error(f"【115】{method} 请求 {endpoint} 网络错误: {str(e)}")
return None return None
@@ -241,7 +250,21 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return self._request_api(method, endpoint, result_key, **kwargs) return self._request_api(method, endpoint, result_key, **kwargs)
# 处理请求错误 # 处理请求错误
resp.raise_for_status() try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
if retry_times <= 0:
logger.error(
f"【115】{method} 请求 {endpoint} 错误 {e},重试次数用尽!"
)
return None
kwargs["retry_limit"] = retry_times - 1
sleep_duration = 2 ** (5 - retry_times + 1)
logger.info(
f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..."
)
time.sleep(sleep_duration)
return self._request_api(method, endpoint, result_key, **kwargs)
# 返回数据 # 返回数据
ret_data = resp.json() ret_data = resp.json()
@@ -251,10 +274,14 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}") logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}")
if "已达到当前访问上限" in error_msg: if "已达到当前访问上限" in error_msg:
if retry_times <= 0: if retry_times <= 0:
logger.error(f"【115】{method} 请求 {endpoint} 达到访问上限,重试次数用尽!") logger.error(
f"【115】{method} 请求 {endpoint} 达到访问上限,重试次数用尽!"
)
return None return None
kwargs["retry_limit"] = retry_times - 1 kwargs["retry_limit"] = retry_times - 1
logger.info(f"【115】{method} 请求 {endpoint} 达到访问上限,等待 {self.retry_delay} 秒后重试...") logger.info(
f"【115】{method} 请求 {endpoint} 达到访问上限,等待 {self.retry_delay} 秒后重试..."
)
time.sleep(self.retry_delay) time.sleep(self.retry_delay)
return self._request_api(method, endpoint, result_key, **kwargs) return self._request_api(method, endpoint, result_key, **kwargs)
return None return None
@@ -269,26 +296,15 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
计算文件SHA1(符合115规范) 计算文件SHA1(符合115规范)
size: 前多少字节 size: 前多少字节
""" """
sha1 = hashlib.sha1() sha1 = hashes.Hash(hashes.SHA1())
with open(filepath, 'rb') as f: with open(filepath, "rb") as f:
if size: if size:
chunk = f.read(size) chunk = f.read(size)
sha1.update(chunk) sha1.update(chunk)
else: else:
while chunk := f.read(8192): while chunk := f.read(8192):
sha1.update(chunk) sha1.update(chunk)
return sha1.hexdigest() return sha1.finalize().hex()
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
"""
自动延迟重试 get_item 模块
"""
for i in range(1, 4):
time.sleep(2 ** i)
fileitem = self.get_item(path)
if fileitem:
return fileitem
return None
def init_storage(self): def init_storage(self):
pass pass
@@ -304,7 +320,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return [item] return [item]
return [] return []
if fileitem.path == "/": if fileitem.path == "/":
cid = '0' cid = "0"
else: else:
cid = fileitem.fileid cid = fileitem.fileid
if not cid: if not cid:
@@ -322,29 +338,37 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"GET", "GET",
"/open/ufile/files", "/open/ufile/files",
"data", "data",
params={"cid": int(cid), "limit": 1000, "offset": offset, "cur": True, "show_dir": 1} params={
"cid": int(cid),
"limit": 1000,
"offset": offset,
"cur": True,
"show_dir": 1,
},
) )
if resp is None: if resp is None:
raise FileNotFoundError(f"【115】{fileitem.path} 检索出错!") raise FileNotFoundError(f"【115】{fileitem.path} 检索出错!")
if not resp: if not resp:
break break
for item in resp: for item in resp:
# 更新缓存 parent_path = Path(fileitem.path) # noqa
path = f"{fileitem.path}{item['fn']}" item_name = item["fn"]
file_path = path + ("/" if item["fc"] == "0" else "") full_path = parent_path / item_name
items.append(schemas.FileItem( items.append(
storage=self.schema.value, schemas.FileItem(
fileid=str(item["fid"]), storage=self.schema.value,
parent_fileid=cid, fileid=str(item["fid"]),
name=item["fn"], parent_fileid=cid,
basename=Path(item["fn"]).stem, name=item["fn"],
extension=item["ico"] if item["fc"] == "1" else None, basename=Path(item["fn"]).stem,
type="dir" if item["fc"] == "0" else "file", extension=item["ico"] if item["fc"] == "1" else None,
path=file_path, type="dir" if item["fc"] == "0" else "file",
size=item["fs"] if item["fc"] == "1" else None, path=full_path.as_posix() + ("/" if item["fc"] == "0" else ""),
modify_time=item["upt"], size=item["fs"] if item["fc"] == "1" else None,
pickcode=item["pc"] modify_time=item["upt"],
)) pickcode=item["pc"],
)
)
if len(resp) < 1000: if len(resp) < 1000:
break break
@@ -352,7 +376,9 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return items return items
def create_folder(self, parent_item: schemas.FileItem, name: str) -> Optional[schemas.FileItem]: def create_folder(
self, parent_item: schemas.FileItem, name: str
) -> Optional[schemas.FileItem]:
""" """
创建目录 创建目录
""" """
@@ -360,10 +386,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
resp = self._request_api( resp = self._request_api(
"POST", "POST",
"/open/folder/add", "/open/folder/add",
data={ data={"pid": int(parent_item.fileid or "0"), "file_name": name},
"pid": int(parent_item.fileid or "0"),
"file_name": name
}
) )
if not resp: if not resp:
return None return None
@@ -376,15 +399,19 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return schemas.FileItem( return schemas.FileItem(
storage=self.schema.value, storage=self.schema.value,
fileid=str(resp["data"]["file_id"]), fileid=str(resp["data"]["file_id"]),
path=str(new_path) + "/", path=new_path.as_posix() + "/",
name=name, name=name,
basename=name, basename=name,
type="dir", type="dir",
modify_time=int(time.time()) modify_time=int(time.time()),
) )
def upload(self, target_dir: schemas.FileItem, local_path: Path, def upload(
new_name: Optional[str] = None) -> Optional[schemas.FileItem]: self,
target_dir: schemas.FileItem,
local_path: Path,
new_name: Optional[str] = None,
) -> Optional[schemas.FileItem]:
""" """
实现带秒传、断点续传和二次认证的文件上传 实现带秒传、断点续传和二次认证的文件上传
""" """
@@ -409,13 +436,9 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"file_size": file_size, "file_size": file_size,
"target": target_param, "target": target_param,
"fileid": file_sha1, "fileid": file_sha1,
"preid": file_preid "preid": file_preid,
} }
init_resp = self._request_api( init_resp = self._request_api("POST", "/open/upload/init", data=init_data)
"POST",
"/open/upload/init",
data=init_data
)
if not init_resp: if not init_resp:
return None return None
if not init_resp.get("state"): if not init_resp.get("state"):
@@ -444,19 +467,15 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
# 取2392148-2392298之间的内容(包含2392148、2392298)的sha1 # 取2392148-2392298之间的内容(包含2392148、2392298)的sha1
f.seek(start) f.seek(start)
chunk = f.read(end - start + 1) chunk = f.read(end - start + 1)
sign_val = hashlib.sha1(chunk).hexdigest().upper() sha1 = hashes.Hash(hashes.SHA1())
sha1.update(chunk)
sign_val = sha1.finalize().hex().upper()
# 重新初始化请求 # 重新初始化请求
# sign_keysign_val(根据sign_check计算的值大写的sha1值) # sign_keysign_val(根据sign_check计算的值大写的sha1值)
init_data.update({ init_data.update(
"pick_code": pick_code, {"pick_code": pick_code, "sign_key": sign_key, "sign_val": sign_val}
"sign_key": sign_key,
"sign_val": sign_val
})
init_resp = self._request_api(
"POST",
"/open/upload/init",
data=init_data
) )
init_resp = self._request_api("POST", "/open/upload/init", data=init_data)
if not init_resp: if not init_resp:
return None return None
if not init_resp.get("state"): if not init_resp.get("state"):
@@ -485,32 +504,30 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"GET", "GET",
"/open/folder/get_info", "/open/folder/get_info",
"data", "data",
params={ params={"file_id": int(file_id)},
"file_id": int(file_id)
}
) )
if info_resp: if info_resp:
return schemas.FileItem( return schemas.FileItem(
storage=self.schema.value, storage=self.schema.value,
fileid=str(info_resp["file_id"]), fileid=str(info_resp["file_id"]),
path=str(target_path) + ("/" if info_resp["file_category"] == "0" else ""), path=target_path.as_posix()
+ ("/" if info_resp["file_category"] == "0" else ""),
type="file" if info_resp["file_category"] == "1" else "dir", type="file" if info_resp["file_category"] == "1" else "dir",
name=info_resp["file_name"], name=info_resp["file_name"],
basename=Path(info_resp["file_name"]).stem, basename=Path(info_resp["file_name"]).stem,
extension=Path(info_resp["file_name"]).suffix[1:] if info_resp[ extension=Path(info_resp["file_name"]).suffix[1:]
"file_category"] == "1" else None, if info_resp["file_category"] == "1"
else None,
pickcode=info_resp["pick_code"], pickcode=info_resp["pick_code"],
size=StringUtils.num_filesize(info_resp['size']) if info_resp["file_category"] == "1" else None, size=StringUtils.num_filesize(info_resp["size"])
modify_time=info_resp["utime"] if info_resp["file_category"] == "1"
else None,
modify_time=info_resp["utime"],
) )
return self._delay_get_item(target_path) return self.get_item(target_path)
# Step 4: 获取上传凭证 # Step 4: 获取上传凭证
token_resp = self._request_api( token_resp = self._request_api("GET", "/open/upload/get_token", "data")
"GET",
"/open/upload/get_token",
"data"
)
if not token_resp: if not token_resp:
logger.warn("【115】获取上传凭证失败") logger.warn("【115】获取上传凭证失败")
return None return None
@@ -530,8 +547,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"file_size": file_size, "file_size": file_size,
"target": target_param, "target": target_param,
"fileid": file_sha1, "fileid": file_sha1,
"pick_code": pick_code "pick_code": pick_code,
} },
) )
if resume_resp: if resume_resp:
logger.debug(f"【115】上传 Step 5 断点续传结果: {resume_resp}") logger.debug(f"【115】上传 Step 5 断点续传结果: {resume_resp}")
@@ -542,25 +559,25 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
auth = oss2.StsAuth( auth = oss2.StsAuth(
access_key_id=AccessKeyId, access_key_id=AccessKeyId,
access_key_secret=AccessKeySecret, access_key_secret=AccessKeySecret,
security_token=SecurityToken security_token=SecurityToken,
) )
bucket = oss2.Bucket(auth, endpoint, bucket_name) # noqa bucket = oss2.Bucket(auth, endpoint, bucket_name) # noqa
# determine_part_size方法用于确定分片大小,设置分片大小为 10M # determine_part_size方法用于确定分片大小,设置分片大小为 10M
part_size = determine_part_size(file_size, preferred_size=10 * 1024 * 1024) part_size = determine_part_size(file_size, preferred_size=10 * 1024 * 1024)
# 初始化进度条 # 初始化进度条
logger.info(f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}") logger.info(
f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}"
)
progress_callback = transfer_process(local_path.as_posix()) progress_callback = transfer_process(local_path.as_posix())
# 初始化分片 # 初始化分片
upload_id = bucket.init_multipart_upload(object_name, upload_id = bucket.init_multipart_upload(
params={ object_name, params={"encoding-type": "url", "sequential": ""}
"encoding-type": "url", ).upload_id
"sequential": ""
}).upload_id
parts = [] parts = []
# 逐个上传分片 # 逐个上传分片
with open(local_path, 'rb') as fileobj: with open(local_path, "rb") as fileobj:
part_number = 1 part_number = 1
offset = 0 offset = 0
while offset < file_size: while offset < file_size:
@@ -569,9 +586,15 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return None return None
num_to_upload = min(part_size, file_size - offset) num_to_upload = min(part_size, file_size - offset)
# 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。 # 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。
logger.info(f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}") logger.info(
result = bucket.upload_part(object_name, upload_id, part_number, f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}"
data=SizedFileAdapter(fileobj, num_to_upload)) )
result = bucket.upload_part(
object_name,
upload_id,
part_number,
data=SizedFileAdapter(fileobj, num_to_upload),
)
parts.append(PartInfo(part_number, result.etag)) parts.append(PartInfo(part_number, result.etag))
logger.info(f"【115】{target_name} 分片 {part_number} 上传完成") logger.info(f"【115】{target_name} 分片 {part_number} 上传完成")
offset += num_to_upload offset += num_to_upload
@@ -585,15 +608,18 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
# 请求头 # 请求头
headers = { headers = {
'X-oss-callback': encode_callback(callback["callback"]), "X-oss-callback": encode_callback(callback["callback"]),
'x-oss-callback-var': encode_callback(callback["callback_var"]), "x-oss-callback-var": encode_callback(callback["callback_var"]),
'x-oss-forbid-overwrite': 'false' "x-oss-forbid-overwrite": "false",
} }
try: try:
result = bucket.complete_multipart_upload(object_name, upload_id, parts, result = bucket.complete_multipart_upload(
headers=headers) object_name, upload_id, parts, headers=headers
)
if result.status == 200: if result.status == 200:
logger.debug(f"【115】上传 Step 6 回调结果:{result.resp.response.json()}") logger.debug(
f"【115】上传 Step 6 回调结果:{result.resp.response.json()}"
)
logger.info(f"【115】{target_name} 上传成功") logger.info(f"【115】{target_name} 上传成功")
else: else:
logger.warn(f"【115】{target_name} 上传失败,错误码: {result.status}") logger.warn(f"【115】{target_name} 上传失败,错误码: {result.status}")
@@ -602,10 +628,12 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
if e.code == "FileAlreadyExists": if e.code == "FileAlreadyExists":
logger.warn(f"【115】{target_name} 已存在") logger.warn(f"【115】{target_name} 已存在")
else: else:
logger.error(f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}") logger.error(
f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}"
)
return None return None
# 返回结果 # 返回结果
return self._delay_get_item(target_path) return self.get_item(target_path)
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
""" """
@@ -617,12 +645,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
return None return None
download_info = self._request_api( download_info = self._request_api(
"POST", "POST", "/open/ufile/downurl", "data", data={"pick_code": detail.pickcode}
"/open/ufile/downurl",
"data",
data={
"pick_code": detail.pickcode
}
) )
if not download_info: if not download_info:
logger.error(f"【115】获取下载链接失败: {fileitem.name}") logger.error(f"【115】获取下载链接失败: {fileitem.name}")
@@ -643,28 +666,26 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
progress_callback = transfer_process(Path(fileitem.path).as_posix()) progress_callback = transfer_process(Path(fileitem.path).as_posix())
try: try:
with self.session.get(download_url, stream=True) as r: with self.session.stream("GET", download_url) as r:
r.raise_for_status() r.raise_for_status()
downloaded_size = 0 downloaded_size = 0
with open(local_path, "wb") as f: with open(local_path, "wb") as f:
for chunk in r.iter_content(chunk_size=self.chunk_size): for chunk in r.iter_bytes(chunk_size=self.chunk_size):
if global_vars.is_transfer_stopped(fileitem.path): if global_vars.is_transfer_stopped(fileitem.path):
logger.info(f"【115】{fileitem.path} 下载已取消!") logger.info(f"【115】{fileitem.path} 下载已取消!")
r.close()
return None return None
if chunk: f.write(chunk)
f.write(chunk) downloaded_size += len(chunk)
downloaded_size += len(chunk) if file_size:
# 更新进度 progress = (downloaded_size * 100) / file_size
if file_size: progress_callback(progress)
progress = (downloaded_size * 100) / file_size
progress_callback(progress)
# 完成下载 # 完成下载
progress_callback(100) progress_callback(100)
logger.info(f"【115】下载完成: {fileitem.name}") logger.info(f"【115】下载完成: {fileitem.name}")
except httpx.RequestError as e:
except requests.exceptions.RequestException as e:
logger.error(f"【115】下载网络错误: {fileitem.name} - {str(e)}") logger.error(f"【115】下载网络错误: {fileitem.name} - {str(e)}")
# 删除可能部分下载的文件 # 删除可能部分下载的文件
if local_path.exists(): if local_path.exists():
@@ -688,14 +709,10 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
""" """
try: try:
self._request_api( self._request_api(
"POST", "POST", "/open/ufile/delete", data={"file_ids": int(fileitem.fileid)}
"/open/ufile/delete",
data={
"file_ids": int(fileitem.fileid)
}
) )
return True return True
except requests.exceptions.HTTPError: except httpx.HTTPError:
return False return False
def rename(self, fileitem: schemas.FileItem, name: str) -> bool: def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
@@ -705,10 +722,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
resp = self._request_api( resp = self._request_api(
"POST", "POST",
"/open/ufile/update", "/open/ufile/update",
data={ data={"file_id": int(fileitem.fileid), "file_name": name},
"file_id": int(fileitem.fileid),
"file_name": name
}
) )
if not resp: if not resp:
return False return False
@@ -725,10 +739,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
"POST", "POST",
"/open/folder/get_info", "/open/folder/get_info",
"data", "data",
data={ data={"path": path.as_posix()},
"path": path.as_posix() no_error_log=True,
},
no_error_log=True
) )
if not resp: if not resp:
return None return None
@@ -739,10 +751,12 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
type="file" if resp["file_category"] == "1" else "dir", type="file" if resp["file_category"] == "1" else "dir",
name=resp["file_name"], name=resp["file_name"],
basename=Path(resp["file_name"]).stem, basename=Path(resp["file_name"]).stem,
extension=Path(resp["file_name"]).suffix[1:] if resp["file_category"] == "1" else None, extension=Path(resp["file_name"]).suffix[1:]
if resp["file_category"] == "1"
else None,
pickcode=resp["pick_code"], pickcode=resp["pick_code"],
size=resp['size_byte'] if resp["file_category"] == "1" else None, size=resp["size_byte"] if resp["file_category"] == "1" else None,
modify_time=resp["utime"] modify_time=resp["utime"],
) )
except Exception as e: except Exception as e:
logger.debug(f"【115】获取文件信息失败: {str(e)}") logger.debug(f"【115】获取文件信息失败: {str(e)}")
@@ -753,7 +767,9 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
获取指定路径的文件夹,如不存在则创建 获取指定路径的文件夹,如不存在则创建
""" """
def __find_dir(_fileitem: schemas.FileItem, _name: str) -> Optional[schemas.FileItem]: def __find_dir(
_fileitem: schemas.FileItem, _name: str
) -> Optional[schemas.FileItem]:
""" """
查找下级目录中匹配名称的目录 查找下级目录中匹配名称的目录
""" """
@@ -808,13 +824,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
data={ data={
"file_id": int(fileitem.fileid), "file_id": int(fileitem.fileid),
"pid": int(dest_fileitem.fileid), "pid": int(dest_fileitem.fileid),
} },
) )
if not resp: if not resp:
return False return False
if resp["state"]: if resp["state"]:
new_path = Path(path) / fileitem.name new_path = Path(path) / fileitem.name
new_item = self._delay_get_item(new_path) new_item = self.get_item(new_path)
if not new_item: if not new_item:
return False return False
if self.rename(new_item, new_name): if self.rename(new_item, new_name):
@@ -840,13 +856,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
data={ data={
"file_ids": int(fileitem.fileid), "file_ids": int(fileitem.fileid),
"to_cid": int(dest_fileitem.fileid), "to_cid": int(dest_fileitem.fileid),
} },
) )
if not resp: if not resp:
return False return False
if resp["state"]: if resp["state"]:
new_path = Path(path) / fileitem.name new_path = Path(path) / fileitem.name
new_file = self._delay_get_item(new_path) new_file = self.get_item(new_path)
if not new_file: if not new_file:
return False return False
if self.rename(new_file, new_name): if self.rename(new_file, new_name):
@@ -864,17 +880,12 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
获取带有企业级配额信息的存储使用情况 获取带有企业级配额信息的存储使用情况
""" """
try: try:
resp = self._request_api( resp = self._request_api("GET", "/open/user/info", "data")
"GET",
"/open/user/info",
"data"
)
if not resp: if not resp:
return None return None
space = resp["rt_space_info"] space = resp["rt_space_info"]
return schemas.StorageUsage( return schemas.StorageUsage(
total=space["all_total"]["size"], total=space["all_total"]["size"], available=space["all_remain"]["size"]
available=space["all_remain"]["size"]
) )
except NoCheckInException: except NoCheckInException:
return None return None
+25
View File
@@ -382,3 +382,28 @@ def rate_limit_window(max_calls: int, window_seconds: float,
limiter = WindowRateLimiter(max_calls, window_seconds, source, enable_logging) limiter = WindowRateLimiter(max_calls, window_seconds, source, enable_logging)
# 使用通用装饰器逻辑包装该限流器 # 使用通用装饰器逻辑包装该限流器
return rate_limit_handler(limiter, raise_on_limit) return rate_limit_handler(limiter, raise_on_limit)
class QpsRateLimiter:
"""
速率控制器,精确控制 QPS
"""
def __init__(self, qps: float | int):
if qps <= 0:
qps = float("inf")
self.interval = 1.0 / qps
self.lock = threading.Lock()
self.next_call_time = time.monotonic()
def acquire(self) -> None:
"""
获取调用许可,阻塞直到满足速率限制
"""
sleep_duration = 0
with self.lock:
now = time.monotonic()
sleep_duration = self.next_call_time - now
self.next_call_time = max(now, self.next_call_time) + self.interval
if sleep_duration > 0:
time.sleep(sleep_duration)
+92 -105
View File
@@ -12,7 +12,7 @@ MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调
获取所有可用的MCP工具列表。 获取所有可用的MCP工具列表。
**认证**: 需要API KEY从 URL 查询参数中获取 `apikey=xxx`,或请求头中获取 `X-API-KEY` **认证**: 需要API KEY在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
**响应示例**: **响应示例**:
```json ```json
@@ -46,7 +46,7 @@ MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调
调用指定的MCP工具。 调用指定的MCP工具。
**认证**: 需要Bearer Token **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
**请求体**: **请求体**:
```json ```json
@@ -84,7 +84,7 @@ MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调
获取指定工具的详细信息。 获取指定工具的详细信息。
**认证**: 需要Bearer Token **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
**路径参数**: **路径参数**:
- `tool_name`: 工具名称 - `tool_name`: 工具名称
@@ -114,7 +114,7 @@ MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调
获取指定工具的参数SchemaJSON Schema格式)。 获取指定工具的参数SchemaJSON Schema格式)。
**认证**: 需要Bearer Token **认证**: 需要API KEY,在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>`
**路径参数**: **路径参数**:
- `tool_name`: 工具名称 - `tool_name`: 工具名称
@@ -138,128 +138,115 @@ MoviePilot的智能体工具已通过HTTP API暴露,可以通过RESTful API调
} }
``` ```
## 使用示例 ## MCP客户端配置
### 使用curl调用工具 MoviePilot的MCP工具可以通过HTTP协议在支持MCP的客户端中使用。以下是常见MCP客户端的配置方法:
```bash ### Claude Desktop (Anthropic)
# 1. 获取访问令牌(通过登录API)
TOKEN=$(curl -X POST "http://localhost:3001/api/v1/login/access-token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=your_password" | jq -r '.access_token')
# 2. 列出所有工具 在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置:
curl -X GET "http://localhost:3001/api/v1/mcp/tools" \
-H "Authorization: Bearer $TOKEN"
# 3. 调用工具 **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
curl -X POST "http://localhost:3001/api/v1/mcp/tools/call" \ **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \ ```json
-d '{ {
"tool_name": "query_subscribes", "mcpServers": {
"arguments": { "moviepilot": {
"status": "all", "command": "npx",
"media_type": "all" "args": [
"-y",
"@modelcontextprotocol/server-http",
"http://localhost:3001/api/v1/mcp"
],
"env": {
"X-API-KEY": "your_api_key_here"
}
} }
}' }
}
# 4. 获取工具详情
curl -X GET "http://localhost:3001/api/v1/mcp/tools/add_subscribe" \
-H "Authorization: Bearer $TOKEN"
``` ```
### 使用Python调用 **注意**: 如果MCP HTTP服务器不支持环境变量传递API Key,可以使用查询参数方式:
```python ```json
import requests {
"mcpServers": {
# 配置 "moviepilot": {
BASE_URL = "http://localhost:3001/api/v1" "command": "npx",
TOKEN = "your_access_token" "args": [
HEADERS = {"Authorization": f"Bearer {TOKEN}"} "-y",
"@modelcontextprotocol/server-http",
# 1. 列出所有工具 "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here"
response = requests.get(f"{BASE_URL}/mcp/tools", headers=HEADERS) ]
tools = response.json()
print(f"可用工具数量: {len(tools)}")
# 2. 调用工具
tool_call = {
"tool_name": "add_subscribe",
"arguments": {
"title": "流浪地球",
"year": "2019",
"media_type": "电影"
} }
}
} }
response = requests.post(
f"{BASE_URL}/mcp/tools/call",
headers=HEADERS,
json=tool_call
)
result = response.json()
print(f"执行结果: {result['result']}")
# 3. 获取工具Schema
response = requests.get(
f"{BASE_URL}/mcp/tools/add_subscribe/schema",
headers=HEADERS
)
schema = response.json()
print(f"工具Schema: {schema}")
``` ```
### 使用JavaScript/TypeScript调用 ### 其他支持MCP的聊天客户端
```typescript 对于其他支持MCP协议的聊天客户端(如其他AI聊天助手、对话机器人等),通常可以通过配置文件或设置界面添加HTTP协议的MCP服务器。配置格式可能因客户端而异,但通常需要以下信息:
const BASE_URL = 'http://localhost:3001/api/v1';
const TOKEN = 'your_access_token';
// 列出所有工具 **配置参数**
async function listTools() { 1. **服务器类型**: HTTP
const response = await fetch(`${BASE_URL}/mcp/tools`, { 2. **服务器地址**: `http://your-moviepilot-host:3001/api/v1/mcp`
headers: { 3. **认证方式**:
'Authorization': `Bearer ${TOKEN}` - 在HTTP请求头中添加 `X-API-KEY: <your_api_key>`
- 或在URL查询参数中添加 `apikey=<your_api_key>`
**示例配置**(通用格式):
使用请求头方式:
```json
{
"mcpServers": {
"moviepilot": {
"url": "http://localhost:3001/api/v1/mcp",
"headers": {
"X-API-KEY": "your_api_key_here"
}
} }
}); }
return await response.json();
} }
// 调用工具
async function callTool(toolName: string, arguments: Record<string, any>) {
const response = await fetch(`${BASE_URL}/mcp/tools/call`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tool_name: toolName,
arguments: arguments
})
});
return await response.json();
}
// 使用示例
const result = await callTool('query_subscribes', {
status: 'all',
media_type: 'all'
});
console.log(result);
``` ```
或使用查询参数方式:
```json
{
"mcpServers": {
"moviepilot": {
"url": "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here"
}
}
}
```
**支持的端点**:
- `GET /tools` - 列出所有工具
- `POST /tools/call` - 调用工具
- `GET /tools/{tool_name}` - 获取工具详情
- `GET /tools/{tool_name}/schema` - 获取工具参数Schema
配置完成后,您就可以在聊天对话中使用MoviePilot的各种工具,例如:
- 添加媒体订阅
- 查询下载历史
- 搜索媒体资源
- 管理媒体服务器
- 等等...
### 获取API Key
API Key可以在MoviePilot的系统设置中生成和查看。请妥善保管您的API Key,不要泄露给他人。
## 认证 ## 认证
所有MCP API端点都需要认证。支持以下认证方式: 所有MCP API端点都需要认证。**仅支持API Key认证方式**
1. **Bearer Token**: 在请求头中添加 `Authorization: Bearer <token>` - **请求头方式**: 在请求头中添加 `X-API-KEY: <api_key>`
2. **API Key**: 在请求头中添加 `X-API-KEY: <api_key>` 或在查询参数中添加 `apikey=<api_key>` - **查询参数方式**: 在URL查询参数中添加 `apikey=<api_key>`
获取Token的方式: **获取API Key**: 在MoviePilot系统设置中生成和查看API Key。请妥善保管您的API Key,不要泄露给他人。
- 通过登录API: `POST /api/v1/login/access-token`
- 通过API Key: 在系统设置中生成API Key
## 错误处理 ## 错误处理
@@ -267,7 +254,7 @@ API会返回标准的HTTP状态码:
- `200 OK`: 请求成功 - `200 OK`: 请求成功
- `400 Bad Request`: 请求参数错误 - `400 Bad Request`: 请求参数错误
- `401 Unauthorized`: 未认证或Token无效 - `401 Unauthorized`: 未认证或API Key无效
- `404 Not Found`: 工具不存在 - `404 Not Found`: 工具不存在
- `500 Internal Server Error`: 服务器内部错误 - `500 Internal Server Error`: 服务器内部错误
+8 -8
View File
@@ -80,11 +80,11 @@ pympler~=1.1
smbprotocol~=1.15.0 smbprotocol~=1.15.0
setproctitle~=1.3.6 setproctitle~=1.3.6
httpx[socks]~=0.28.1 httpx[socks]~=0.28.1
langchain==0.3.27 langchain~=0.3.27
langchain-core==0.3.76 langchain-core~=0.3.76
langchain-community==0.3.29 langchain-community~=0.3.29
langchain-openai==0.3.33 langchain-openai~=0.3.33
langchain-google-genai==2.0.10 langchain-google-genai~=2.0.10
langchain-deepseek==0.1.4 langchain-deepseek~=0.1.4
langchain-experimental==0.3.4 langchain-experimental~=0.3.4
openai~=2.8.1 openai~=1.108.2
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = 'v2.8.3' APP_VERSION = 'v2.8.4'
FRONTEND_VERSION = 'v2.8.3' FRONTEND_VERSION = 'v2.8.3'