Files
MoviePilot/app/api/endpoints/storage.py
T
jxxghp a6dbd799d5 refactor(api): 渠道与存储管理端点通用化,端点层零目标特色
- schemas 新增公共 ManageRequest(target+action+params) 与 StorageAction 词汇表
- endpoint 层收敛为 POST /notification/manage、/storage/manage 两个通用接口,
  不再定义任何渠道/存储特定的名称、参数与响应字段,前端上送参数原样透传
- chain 层 manage_channel/manage_storage 接受字符串标识纯透明转发,
  StorageChain 移除全部特色管理方法
- FileManagerModule 新增 storage_manage 统一入口,模块返回归一化为
  {success, message, data};wechatclawbot channel_manage 同步归一化,
  路由标识兼容枚举名/值/对象
- dashboard 与 agentopsassistant 的用量查询改走 manage_storage(usage)
- 新增 10 项存储契约守护测试,通知侧补充 3 项链透传与字符串路由测试,
  API 守护测试对 ManageRequest 开放映射按设计豁免
2026-08-16 07:18:49 +08:00

247 lines
7.9 KiB
Python

import fnmatch
import math
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import Depends, HTTPException
from starlette.responses import FileResponse, Response
from app import schemas
from app.api.response import ResponseAPIRouter
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.runtime.config import settings
from app.db.models import User
from app.api.deps import (
get_current_active_manage_user,
get_current_active_superuser,
)
from app.runtime.progress import ProgressHelper
from app.schemas.types import ProgressKey
from app.foundation import text as text_tools
router = ResponseAPIRouter()
@router.post(
"/manage", summary="网盘存储统一管理", response_model=schemas.Response[Dict[str, Any]]
)
def manage(
request: schemas.ManageRequest, _: User = Depends(get_current_active_superuser)
) -> Any:
"""
网盘存储统一管理入口
端点层不定义任何存储特定的名称与参数,
存储标识、管理动作与表单参数由前端上送并原样透传给存储模块
"""
result = StorageChain().manage_storage(
storage=request.target,
action=request.action,
**request.params,
)
return schemas.Response(
success=bool(result.get("success")),
message=result.get("message"),
data=result.get("data"),
)
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
def list_files(
fileitem: schemas.FileItem,
sort: Optional[str] = "updated_at",
keyword: Optional[str] = None,
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件项
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
:param keyword: 通配符过滤,支持 * 和 ?,如 *.mkv、movie?.*
:param _: token
:return: 所有目录和文件
"""
file_list = StorageChain().list_files(fileitem)
if file_list:
if keyword:
_pat = re.compile(fnmatch.translate(keyword), re.IGNORECASE)
file_list = [f for f in file_list if _pat.match(f.name or "")]
if sort == "name":
file_list.sort(key=lambda x: text_tools.natural_sort_key(x.name or ""))
else:
file_list.sort(key=lambda x: x.modify_time or -math.inf, reverse=True)
return file_list
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response[None])
def mkdir(
fileitem: schemas.FileItem,
name: str,
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
创建目录
:param fileitem: 文件项
:param name: 目录名称
:param _: token
"""
if not name:
return schemas.Response(success=False)
result = StorageChain().create_folder(fileitem, name)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response[None])
def delete(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
删除文件或目录
:param fileitem: 文件项
:param _: token
"""
result = StorageChain().delete_file(fileitem)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post(
"/download",
summary="下载文件",
response_model=None,
response_class=FileResponse,
responses={
200: {
"description": "文件内容",
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
},
},
404: {"model": schemas.Response[None], "description": "文件下载失败"},
},
)
def download(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
:param fileitem: 文件项
:param _: token
"""
# 临时目录
tmp_file = StorageChain().download_file(fileitem)
if tmp_file:
return FileResponse(path=tmp_file)
return schemas.Response(success=False)
@router.post(
"/image",
summary="预览图片",
response_model=None,
response_class=Response,
responses={
200: {
"description": "图片内容",
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
},
}
},
)
def image(
fileitem: schemas.FileItem, _: User = Depends(get_current_active_manage_user)
) -> Any:
"""
下载文件或目录
:param fileitem: 文件项
:param _: token
"""
# 临时目录
tmp_file = StorageChain().download_file(fileitem)
if not tmp_file:
raise HTTPException(status_code=500, detail="图片读取出错")
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response[None])
def rename(
fileitem: schemas.FileItem,
new_name: str,
recursive: Optional[bool] = False,
_: User = Depends(get_current_active_manage_user),
) -> Any:
"""
重命名文件或目录
:param fileitem: 文件项
:param new_name: 新名称
:param recursive: 是否递归修改
:param _: token
"""
if not new_name:
return schemas.Response(success=False, message="新名称为空")
# 重命名目录内文件
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
if sub_files:
# 开始进度
progress = ProgressHelper(ProgressKey.BatchRename)
progress.start()
total = len(sub_files)
handled = 0
for sub_file in sub_files:
handled += 1
progress.update(
value=handled / total * 100, text=f"正在处理 {sub_file.name} ..."
)
if sub_file.type == "dir":
continue
if not sub_file.extension:
continue
if f".{sub_file.extension.lower()}" not in media_exts:
continue
sub_path = Path(f"{fileitem.path}{sub_file.name}")
context = MediaChain().recognize_by_path(
sub_path,
obtain_images=False,
)
if not context or not context.media_info:
progress.end()
return schemas.Response(
success=False, message=f"{sub_path.name} 未识别到媒体信息"
)
new_path = transferchain.recommend_name(
meta=context.meta_info, mediainfo=context.media_info
)
if not new_path:
progress.end()
return schemas.Response(
success=False, message=f"{sub_path.name} 未识别到新名称"
)
ret: schemas.Response = rename(
fileitem=sub_file, new_name=Path(new_path).name, recursive=False
)
if not ret.success:
progress.end()
return schemas.Response(
success=False, message=f"{sub_path.name} 重命名失败!"
)
progress.end()
# 重命名自己
result = StorageChain().rename_file(fileitem, new_name)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)