mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
@@ -1,732 +1,45 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, List, Tuple, Union, Dict, Callable
|
||||
"""文件管理模块的惰性兼容入口。
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.application.directory import DirectoryHelper
|
||||
from app.application.messaging.message import MessageHelper
|
||||
from app.foundation.reflection import ModuleHelper
|
||||
from app.runtime.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.modules.filemanager.transhandler import TransHandler
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, TransferDirectoryConf, FileItem, StorageUsage
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType, StorageAction
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.foundation import text as text_tools
|
||||
宿主能力清单和历史调用方继续使用 ``app.modules.filemanager:FileManagerModule``;
|
||||
实现移入 ``module`` 后,包初始化不再反向加载传输处理器和存储实现。
|
||||
"""
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
文件整理模块
|
||||
"""
|
||||
_EXPORTS = {
|
||||
"DirectoryHelper": ("app.modules.filemanager.module", "DirectoryHelper"),
|
||||
"FileManagerModule": ("app.modules.filemanager.module", "FileManagerModule"),
|
||||
"StorageBase": ("app.modules.filemanager.storages", "StorageBase"),
|
||||
"TransHandler": ("app.modules.filemanager.transhandler", "TransHandler"),
|
||||
"settings": ("app.modules.filemanager.module", "settings"),
|
||||
}
|
||||
|
||||
_storage_schemas = []
|
||||
_support_storages = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""按需解析旧包级导出,并缓存解析结果。"""
|
||||
contract = _EXPORTS.get(name)
|
||||
if contract is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, symbol_name = contract
|
||||
value = getattr(import_module(module_name), symbol_name)
|
||||
if name == "FileManagerModule":
|
||||
# 保持插件反射、Pickle 和能力入口依赖的历史类路径。
|
||||
value.__module__ = __name__
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化文件整理模块支持的存储实现"""
|
||||
# 加载模块
|
||||
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
|
||||
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
|
||||
# 获取存储类型
|
||||
self._support_storages = [storage.schema.value for storage in self._storage_schemas if storage.schema]
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""获取模块名称"""
|
||||
return "文件整理"
|
||||
def __dir__() -> list[str]:
|
||||
"""向交互式工具公开兼容符号而不提前导入实现。"""
|
||||
return sorted({*globals(), *_EXPORTS})
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""
|
||||
获取模块类型
|
||||
"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.FileManager
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
|
||||
"""
|
||||
return 4
|
||||
|
||||
def stop(self):
|
||||
"""停止文件整理模块"""
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
# 检查目录
|
||||
dirs = self.directoryhelper.get_dirs()
|
||||
if not dirs:
|
||||
return False, "未设置任何目录"
|
||||
for d in dirs:
|
||||
# 下载目录
|
||||
download_path = d.download_path
|
||||
if not download_path:
|
||||
return False, f"{d.name} 的下载目录未设置"
|
||||
if d.storage == "local" and not Path(download_path).exists():
|
||||
return False, f"{d.name} 的下载目录 {download_path} 不存在"
|
||||
# 仅在启用整理时检查媒体库目录
|
||||
library_path = d.library_path
|
||||
if d.transfer_type:
|
||||
if not library_path:
|
||||
return False, f"{d.name} 的媒体库目录未设置"
|
||||
if d.library_storage == "local" and not Path(library_path).exists():
|
||||
return False, f"{d.name} 的媒体库目录 {library_path} 不存在"
|
||||
# 硬链接
|
||||
if d.transfer_type == "link" \
|
||||
and d.storage == "local" \
|
||||
and d.library_storage == "local" \
|
||||
and not SystemUtils.is_same_disk(Path(download_path), Path(library_path)):
|
||||
return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接"
|
||||
# 存储
|
||||
storage_oper = self.__get_storage_oper(d.storage)
|
||||
if storage_oper:
|
||||
if not storage_oper.check():
|
||||
return False, f"{d.name} 的存储测试不通过"
|
||||
if d.transfer_type and d.transfer_type not in storage_oper.support_transtype():
|
||||
return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式"
|
||||
|
||||
return True, ""
|
||||
|
||||
def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]:
|
||||
"""
|
||||
获取存储操作对象
|
||||
"""
|
||||
for storage_schema in self._storage_schemas:
|
||||
if storage_schema.schema \
|
||||
and storage_schema.schema.value == _storage \
|
||||
and (not _func or hasattr(storage_schema, _func)):
|
||||
return storage_schema()
|
||||
return None
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def storage_manage(self, storage: str, action: StorageAction, **params) -> Dict[str, Any]:
|
||||
"""
|
||||
网盘存储统一管理入口,按存储标识路由
|
||||
|
||||
动作语义与参数解释交给具体存储实现,
|
||||
统一返回 {"success": bool, "message": ..., "data": ...}
|
||||
"""
|
||||
try:
|
||||
action = StorageAction(action)
|
||||
except ValueError:
|
||||
return {"success": False, "message": f"不支持的存储管理动作:{action}"}
|
||||
if storage not in self._support_storages:
|
||||
return {"success": False, "message": f"不支持的存储类型:{storage}"}
|
||||
|
||||
if action == StorageAction.SAVE_CONFIG:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的配置保存"}
|
||||
storage_oper.set_config(params.get("conf") or {})
|
||||
return {"success": True}
|
||||
if action == StorageAction.RESET_CONFIG:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的重置存储配置"}
|
||||
storage_oper.reset_config()
|
||||
return {"success": True}
|
||||
if action == StorageAction.SUPPORT_TRANSTYPE:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的整理方式获取"}
|
||||
# 与旧契约一致:返回值包装为 transtype,空结果同样返回成功空结构
|
||||
return {"success": True, "data": {"transtype": storage_oper.support_transtype() or {}}}
|
||||
if action == StorageAction.USAGE:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的存储使用情况"}
|
||||
# 实现返回 pydantic 模型,转为 dict 后才能透过通用响应的开放映射校验
|
||||
return {"success": True, "data": (storage_oper.usage() or StorageUsage()).model_dump()}
|
||||
|
||||
# 登录类动作:存储实现不支持时返回失败信息
|
||||
oper_method = action.value
|
||||
storage_oper = self.__get_storage_oper(storage, oper_method)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"{storage} 不支持 {oper_method}"}
|
||||
result = getattr(storage_oper, oper_method)(**params)
|
||||
if result is None:
|
||||
return {"success": False, "message": f"{storage} 的 {oper_method} 执行失败"}
|
||||
data, errmsg = result
|
||||
return {"success": bool(data), "message": errmsg, "data": data}
|
||||
|
||||
@staticmethod
|
||||
def recommend_name(meta: MetaBase, mediainfo: MediaInfo,
|
||||
episodes_info: Optional[List[TmdbEpisode]] = None) -> Optional[str]:
|
||||
"""
|
||||
获取重命名后的名称
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param episodes_info: 集信息,由调用方链层预先获取
|
||||
:return: 重命名后的名称(含目录)
|
||||
"""
|
||||
handler = TransHandler()
|
||||
# 重命名格式
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
# 获取重命名后的名称
|
||||
path = handler.get_rename_path(
|
||||
template_string=rename_format,
|
||||
rename_dict=handler.get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=Path(meta.title).suffix)
|
||||
)
|
||||
return path.as_posix() if path else ""
|
||||
|
||||
def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]:
|
||||
"""
|
||||
浏览文件
|
||||
:param fileitem: 源文件
|
||||
:param recursion: 是否递归,此时只浏览文件
|
||||
:return: 文件项列表
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||
return None
|
||||
|
||||
def __get_files(_item: FileItem, _r: Optional[bool] = False):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = storage_oper.list(_item)
|
||||
if _items:
|
||||
if _r:
|
||||
for t in _items:
|
||||
if t.type == "dir":
|
||||
__get_files(t, _r)
|
||||
else:
|
||||
result.append(t)
|
||||
else:
|
||||
result.extend(_items)
|
||||
|
||||
# 返回结果
|
||||
result = []
|
||||
__get_files(fileitem, recursion)
|
||||
|
||||
return result
|
||||
|
||||
def any_files(self, fileitem: FileItem, extensions: list = None) -> Optional[bool]:
|
||||
"""
|
||||
查询当前目录下是否存在指定扩展名任意文件
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||
return None
|
||||
|
||||
def __any_file(_item: FileItem):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = storage_oper.list(_item)
|
||||
if _items:
|
||||
if not extensions:
|
||||
return True
|
||||
for t in _items:
|
||||
if (t.type == "file"
|
||||
and t.extension
|
||||
and f".{t.extension.lower()}" in extensions):
|
||||
return True
|
||||
elif t.type == "dir":
|
||||
if __any_file(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 返回结果
|
||||
return __any_file(fileitem)
|
||||
|
||||
def create_folder(self, fileitem: FileItem, name: str) -> Optional[FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 源文件
|
||||
:param name: 目录名
|
||||
:return: 创建的目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的目录创建")
|
||||
return None
|
||||
return storage_oper.create_folder(fileitem, name)
|
||||
|
||||
def get_folder(self, storage: str, path: Path) -> Optional[FileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的目录获取")
|
||||
return None
|
||||
return storage_oper.get_folder(path)
|
||||
|
||||
def delete_file(self, fileitem: FileItem) -> Optional[bool]:
|
||||
"""
|
||||
删除文件或目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的删除处理")
|
||||
return False
|
||||
return storage_oper.delete(fileitem)
|
||||
|
||||
def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]:
|
||||
"""
|
||||
重命名文件或目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的重命名处理")
|
||||
return False
|
||||
return storage_oper.rename(fileitem, name)
|
||||
|
||||
def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
下载文件
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的下载处理")
|
||||
return None
|
||||
return storage_oper.download(fileitem, path=path)
|
||||
|
||||
def upload_file(self, fileitem: FileItem, path: Path, new_name: Optional[str] = None) -> Optional[FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的上传处理")
|
||||
return None
|
||||
return storage_oper.upload(fileitem, path, new_name)
|
||||
|
||||
def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]:
|
||||
"""
|
||||
根据路径获取文件项
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的文件获取")
|
||||
return None
|
||||
return storage_oper.get_item(path)
|
||||
|
||||
def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]:
|
||||
"""
|
||||
获取上级目录项
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件获取")
|
||||
return None
|
||||
return storage_oper.get_parent(fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path,
|
||||
last_snapshot_time: float = None, max_depth: int = 5,
|
||||
previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
快照存储
|
||||
:param storage: 存储类型
|
||||
:param path: 路径
|
||||
:param last_snapshot_time: 上次快照时间,用于增量快照
|
||||
:param max_depth: 最大递归深度,避免过深遍历
|
||||
:param previous_snapshot: 上次完整快照,用于增量对账
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的快照处理")
|
||||
return None
|
||||
return storage_oper.snapshot(
|
||||
path,
|
||||
last_snapshot_time=last_snapshot_time,
|
||||
max_depth=max_depth,
|
||||
previous_snapshot=previous_snapshot
|
||||
)
|
||||
|
||||
def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo,
|
||||
target_directory: TransferDirectoryConf = None,
|
||||
target_storage: Optional[str] = None, target_path: Path = None,
|
||||
transfer_type: Optional[str] = None, scrape: Optional[bool] = None,
|
||||
library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
source_oper: Callable = None, target_oper: Callable = None,
|
||||
preview: Optional[bool] = False) -> TransferInfo:
|
||||
"""
|
||||
文件整理
|
||||
:param fileitem: 文件信息
|
||||
:param meta: 预识别的元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param target_directory: 目标目录配置
|
||||
:param target_storage: 目标存储
|
||||
:param target_path: 目标路径
|
||||
:param transfer_type: 转移模式
|
||||
:param scrape: 是否刮削元数据
|
||||
:param library_type_folder: 是否按媒体类型创建目录
|
||||
:param library_category_folder: 是否按媒体类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
handler = TransHandler()
|
||||
# 检查目录路径
|
||||
if fileitem.storage == "local" and not Path(fileitem.path).exists():
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{fileitem.path} 不存在")
|
||||
# 目标路径不能是文件
|
||||
if target_path and target_path.is_file():
|
||||
logger.error(f"整理目标路径 {target_path} 是一个文件")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{target_path} 不是有效目录")
|
||||
# 获取目标路径
|
||||
if target_directory:
|
||||
# 目标媒体库目录未设置
|
||||
if not target_directory.library_path:
|
||||
logger.error(f"目标媒体库目录未设置,无法整理文件,源路径:{fileitem.path}")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message="目标媒体库目录未设置")
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
transfer_type = target_directory.transfer_type
|
||||
# 目标存储
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
# 是否需要重命名
|
||||
need_rename = target_directory.renaming
|
||||
# 是否需要通知
|
||||
need_notify = target_directory.notify
|
||||
# 覆盖模式
|
||||
overwrite_mode = target_directory.overwrite_mode
|
||||
# 是否需要刮削
|
||||
need_scrape = target_directory.scraping if scrape is None else scrape
|
||||
# 拼装媒体库一、二级子目录
|
||||
target_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
|
||||
need_type_folder=library_type_folder,
|
||||
need_category_folder=library_category_folder)
|
||||
elif target_path:
|
||||
need_scrape = scrape or False
|
||||
need_rename = True
|
||||
need_notify = False
|
||||
overwrite_mode = "never"
|
||||
# 手动整理的场景,有自定义目标路径
|
||||
target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path,
|
||||
need_type_folder=library_type_folder,
|
||||
need_category_folder=library_category_folder)
|
||||
else:
|
||||
# 未找到有效的媒体库目录
|
||||
logger.error(
|
||||
f"{mediainfo.type.value if mediainfo.type else '未知类型'} {mediainfo.title_year} 未找到有效的媒体库目录,无法整理文件,源路径:{fileitem.path}")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message="未找到有效的媒体库目录")
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
logger.error(f"{target_directory.name} 未设置整理方式")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{target_directory.name} 未设置整理方式")
|
||||
|
||||
# 源操作对象
|
||||
if not source_oper:
|
||||
source_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not source_oper:
|
||||
return TransferInfo(success=False,
|
||||
message=f"不支持的存储类型:{fileitem.storage}",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify
|
||||
)
|
||||
# 目的操作对象
|
||||
if not target_oper:
|
||||
if not target_storage:
|
||||
target_storage = fileitem.storage
|
||||
target_oper = self.__get_storage_oper(target_storage)
|
||||
if not target_oper:
|
||||
return TransferInfo(success=False,
|
||||
message=f"不支持的存储类型:{target_storage}",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
|
||||
# 整理
|
||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||
return handler.transfer_media(fileitem=fileitem,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type,
|
||||
need_scrape=need_scrape,
|
||||
need_rename=need_rename,
|
||||
need_notify=need_notify,
|
||||
overwrite_mode=overwrite_mode,
|
||||
episodes_info=episodes_info,
|
||||
preview=preview,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper)
|
||||
|
||||
@staticmethod
|
||||
def _build_library_lookup_meta(
|
||||
mediainfo: Union[MediaInfo, MusicInfo],
|
||||
) -> MetaBase:
|
||||
"""构造标准媒体库路径反查使用的最小元数据。"""
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
music_type = getattr(mediainfo, "music_type", None)
|
||||
album = getattr(mediainfo, "album", None)
|
||||
if not album and music_type == MUSIC_ENTITY_ALBUM:
|
||||
album = mediainfo.title
|
||||
return MetaMusic(
|
||||
title=mediainfo.title,
|
||||
artists=list(getattr(mediainfo, "artists", None) or []),
|
||||
album=album,
|
||||
album_artist=getattr(mediainfo, "album_artist", None),
|
||||
year=mediainfo.year,
|
||||
disc_number=getattr(mediainfo, "disc_number", None),
|
||||
track_number=getattr(mediainfo, "track_number", None),
|
||||
total_tracks=getattr(mediainfo, "total_tracks", None),
|
||||
media_source=getattr(mediainfo, "source", None),
|
||||
media_id=getattr(mediainfo, "media_id", None),
|
||||
)
|
||||
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
|
||||
meta.type = mediainfo.type
|
||||
if meta.year is None:
|
||||
meta.year = mediainfo.year
|
||||
if meta.begin_season is None:
|
||||
meta.begin_season = 1
|
||||
if meta.begin_episode is None:
|
||||
meta.begin_episode = 1
|
||||
return meta
|
||||
|
||||
@staticmethod
|
||||
def _music_file_identity(fileitem: FileItem) -> Tuple[Optional[int], Optional[int], str]:
|
||||
"""从标准音乐文件路径提取碟号、曲序和归一化曲名。"""
|
||||
file_path = Path(fileitem.path or fileitem.name or "")
|
||||
file_meta = MetaMusic(
|
||||
org_string=file_path.name,
|
||||
title=file_path.stem,
|
||||
).apply_path_context(file_path)
|
||||
return (
|
||||
file_meta.disc_number,
|
||||
file_meta.track_number,
|
||||
text_tools.normalize_upper(file_meta.title or file_path.stem),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _music_recording_exists(
|
||||
cls,
|
||||
fileitems: List[FileItem],
|
||||
mediainfo: MusicInfo,
|
||||
) -> bool:
|
||||
"""按曲名和可用曲序判断单曲是否存在,避免专辑内任一文件造成误判。"""
|
||||
target_title = text_tools.normalize_upper(mediainfo.title or "")
|
||||
target_track = getattr(mediainfo, "track_number", None)
|
||||
target_disc = getattr(mediainfo, "disc_number", None)
|
||||
if not target_title:
|
||||
return False
|
||||
for fileitem in fileitems:
|
||||
disc_number, track_number, title = cls._music_file_identity(fileitem)
|
||||
if title != target_title:
|
||||
continue
|
||||
if target_track is not None and track_number not in (None, target_track):
|
||||
continue
|
||||
if target_disc is not None and disc_number not in (None, target_disc):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _music_album_is_complete(
|
||||
cls,
|
||||
fileitems: List[FileItem],
|
||||
total_tracks: Optional[int],
|
||||
) -> bool:
|
||||
"""按去重后的曲序或曲名判断本地专辑是否达到目标曲目数。"""
|
||||
if not fileitems:
|
||||
return False
|
||||
if not total_tracks:
|
||||
return False
|
||||
track_identities = {
|
||||
(
|
||||
disc_number or 1,
|
||||
track_number if track_number is not None else title,
|
||||
)
|
||||
for disc_number, track_number, title in (
|
||||
cls._music_file_identity(fileitem) for fileitem in fileitems
|
||||
)
|
||||
if track_number is not None or title
|
||||
}
|
||||
return len(track_identities) >= total_tracks
|
||||
|
||||
def media_files(self, mediainfo: Union[MediaInfo, MusicInfo]) -> List[FileItem]:
|
||||
"""
|
||||
获取对应媒体的媒体库文件列表
|
||||
:param mediainfo: 媒体信息
|
||||
"""
|
||||
handler = TransHandler()
|
||||
ret_fileitems = []
|
||||
# 检查本地媒体库
|
||||
dest_dirs = DirectoryHelper().get_library_dirs()
|
||||
# 检查每一个媒体库目录
|
||||
for dest_dir in dest_dirs:
|
||||
# 存储
|
||||
storage_oper = self.__get_storage_oper(dest_dir.library_storage)
|
||||
if not storage_oper:
|
||||
continue
|
||||
# 媒体分类路径
|
||||
dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir)
|
||||
# 重命名格式
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
# 元数据补上常用属性,尽可能确保重命名后的路径不出现空白
|
||||
meta = self._build_library_lookup_meta(mediainfo)
|
||||
# 获取路径(重命名路径)
|
||||
target_path = handler.get_rename_path(
|
||||
path=dir_path,
|
||||
template_string=rename_format,
|
||||
rename_dict=handler.get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 获取重命名后的媒体文件根路径
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format,
|
||||
rename_path=target_path,
|
||||
media_type=mediainfo.type,
|
||||
)
|
||||
if not media_path:
|
||||
# 忽略
|
||||
continue
|
||||
if dir_path != media_path and dir_path.is_relative_to(media_path):
|
||||
# 兜底检查,避免不必要的扫盘
|
||||
logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!")
|
||||
continue
|
||||
# 检索媒体文件
|
||||
fileitem = storage_oper.get_item(media_path)
|
||||
if not fileitem:
|
||||
continue
|
||||
try:
|
||||
media_files = self.list_files(fileitem, True)
|
||||
except Exception as e:
|
||||
logger.debug(f"获取媒体文件列表失败:{str(e)}")
|
||||
continue
|
||||
if media_files:
|
||||
media_extensions = (
|
||||
settings.RMT_AUDIOEXT
|
||||
if mediainfo.type == MediaType.MUSIC
|
||||
else settings.RMT_MEDIAEXT
|
||||
)
|
||||
for media_file in media_files:
|
||||
if (
|
||||
media_file.extension
|
||||
and f".{media_file.extension.lower()}" in media_extensions
|
||||
):
|
||||
if media_file not in ret_fileitems:
|
||||
ret_fileitems.append(media_file)
|
||||
return ret_fileitems
|
||||
|
||||
def media_exists(
|
||||
self,
|
||||
mediainfo: Union[MediaInfo, MusicInfo],
|
||||
**kwargs,
|
||||
) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param server: 指定媒体服务器名称时跳过本地文件系统检查
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if kwargs.get("server"):
|
||||
return None
|
||||
|
||||
if not settings.LOCAL_EXISTS_SEARCH:
|
||||
return None
|
||||
|
||||
logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...")
|
||||
|
||||
# 检查媒体库
|
||||
fileitems = self.media_files(mediainfo)
|
||||
if not fileitems:
|
||||
logger.debug(f"{mediainfo.title_year} 不在本地媒体库中")
|
||||
return None
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影存在任何文件为存在
|
||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
exists = self._music_album_is_complete(
|
||||
fileitems,
|
||||
getattr(mediainfo, "total_tracks", None),
|
||||
)
|
||||
else:
|
||||
exists = self._music_recording_exists(fileitems, mediainfo)
|
||||
if not exists:
|
||||
logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整")
|
||||
return None
|
||||
logger.info(f"{mediainfo.title_year} 在本地音乐库中找到了")
|
||||
return ExistMediaInfo(type=MediaType.MUSIC)
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧检索集数
|
||||
seasons: Dict[int, list] = {}
|
||||
for fileitem in fileitems:
|
||||
file_meta = MetaInfo(fileitem.basename)
|
||||
season_index = file_meta.begin_season if file_meta.begin_season is not None else 1
|
||||
episode_index = file_meta.begin_episode
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in seasons:
|
||||
seasons[season_index] = []
|
||||
if episode_index not in seasons[season_index]:
|
||||
seasons[season_index].append(episode_index)
|
||||
# 返回剧集情况
|
||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
|
||||
return None
|
||||
__all__ = [
|
||||
"DirectoryHelper",
|
||||
"FileManagerModule",
|
||||
"StorageBase",
|
||||
"TransHandler",
|
||||
"settings",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, List, Tuple, Union, Dict, Callable
|
||||
|
||||
from app.runtime.config import settings
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.application.directory import DirectoryHelper
|
||||
from app.application.messaging.message import MessageHelper
|
||||
from app.foundation.reflection import ModuleHelper
|
||||
from app.runtime.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.modules.filemanager.transhandler import TransHandler
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.mediaserver import ExistMediaInfo
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.workflow import FileItem
|
||||
from app.schemas.file import StorageUsage
|
||||
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaType, ModuleType, OtherModulesType, StorageAction
|
||||
from app.adapters.system.host import SystemUtils
|
||||
from app.foundation import text as text_tools
|
||||
|
||||
|
||||
class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
文件整理模块
|
||||
"""
|
||||
|
||||
_storage_schemas = []
|
||||
_support_storages = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""初始化文件整理模块支持的存储实现"""
|
||||
# 加载模块
|
||||
self._storage_schemas = ModuleHelper.load('app.modules.filemanager.storages',
|
||||
filter_func=lambda _, obj: hasattr(obj, 'schema') and obj.schema)
|
||||
# 获取存储类型
|
||||
self._support_storages = [storage.schema.value for storage in self._storage_schemas if storage.schema]
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""获取模块名称"""
|
||||
return "文件整理"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""
|
||||
获取模块类型
|
||||
"""
|
||||
return ModuleType.Other
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> OtherModulesType:
|
||||
"""
|
||||
获取模块子类型
|
||||
"""
|
||||
return OtherModulesType.FileManager
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""
|
||||
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
|
||||
"""
|
||||
return 4
|
||||
|
||||
def stop(self):
|
||||
"""停止文件整理模块"""
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
# 检查目录
|
||||
dirs = self.directoryhelper.get_dirs()
|
||||
if not dirs:
|
||||
return False, "未设置任何目录"
|
||||
for d in dirs:
|
||||
# 下载目录
|
||||
download_path = d.download_path
|
||||
if not download_path:
|
||||
return False, f"{d.name} 的下载目录未设置"
|
||||
if d.storage == "local" and not Path(download_path).exists():
|
||||
return False, f"{d.name} 的下载目录 {download_path} 不存在"
|
||||
# 仅在启用整理时检查媒体库目录
|
||||
library_path = d.library_path
|
||||
if d.transfer_type:
|
||||
if not library_path:
|
||||
return False, f"{d.name} 的媒体库目录未设置"
|
||||
if d.library_storage == "local" and not Path(library_path).exists():
|
||||
return False, f"{d.name} 的媒体库目录 {library_path} 不存在"
|
||||
# 硬链接
|
||||
if d.transfer_type == "link" \
|
||||
and d.storage == "local" \
|
||||
and d.library_storage == "local" \
|
||||
and not SystemUtils.is_same_disk(Path(download_path), Path(library_path)):
|
||||
return False, f"{d.name} 的下载目录 {download_path} 与媒体库目录 {library_path} 不在同一磁盘,无法硬链接"
|
||||
# 存储
|
||||
storage_oper = self.__get_storage_oper(d.storage)
|
||||
if storage_oper:
|
||||
if not storage_oper.check():
|
||||
return False, f"{d.name} 的存储测试不通过"
|
||||
if d.transfer_type and d.transfer_type not in storage_oper.support_transtype():
|
||||
return False, f"{d.name} 的存储不支持 {d.transfer_type} 整理方式"
|
||||
|
||||
return True, ""
|
||||
|
||||
def __get_storage_oper(self, _storage: str, _func: Optional[str] = None) -> Optional[StorageBase]:
|
||||
"""
|
||||
获取存储操作对象
|
||||
"""
|
||||
for storage_schema in self._storage_schemas:
|
||||
if storage_schema.schema \
|
||||
and storage_schema.schema.value == _storage \
|
||||
and (not _func or hasattr(storage_schema, _func)):
|
||||
return storage_schema()
|
||||
return None
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
def storage_manage(self, storage: str, action: StorageAction, **params) -> Dict[str, Any]:
|
||||
"""
|
||||
网盘存储统一管理入口,按存储标识路由
|
||||
|
||||
动作语义与参数解释交给具体存储实现,
|
||||
统一返回 {"success": bool, "message": ..., "data": ...}
|
||||
"""
|
||||
try:
|
||||
action = StorageAction(action)
|
||||
except ValueError:
|
||||
return {"success": False, "message": f"不支持的存储管理动作:{action}"}
|
||||
if storage not in self._support_storages:
|
||||
return {"success": False, "message": f"不支持的存储类型:{storage}"}
|
||||
|
||||
if action == StorageAction.SAVE_CONFIG:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的配置保存"}
|
||||
storage_oper.set_config(params.get("conf") or {})
|
||||
return {"success": True}
|
||||
if action == StorageAction.RESET_CONFIG:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的重置存储配置"}
|
||||
storage_oper.reset_config()
|
||||
return {"success": True}
|
||||
if action == StorageAction.SUPPORT_TRANSTYPE:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的整理方式获取"}
|
||||
# 与旧契约一致:返回值包装为 transtype,空结果同样返回成功空结构
|
||||
return {"success": True, "data": {"transtype": storage_oper.support_transtype() or {}}}
|
||||
if action == StorageAction.USAGE:
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"不支持 {storage} 的存储使用情况"}
|
||||
# 实现返回 pydantic 模型,转为 dict 后才能透过通用响应的开放映射校验
|
||||
return {"success": True, "data": (storage_oper.usage() or StorageUsage()).model_dump()}
|
||||
|
||||
# 登录类动作:存储实现不支持时返回失败信息
|
||||
oper_method = action.value
|
||||
storage_oper = self.__get_storage_oper(storage, oper_method)
|
||||
if not storage_oper:
|
||||
return {"success": False, "message": f"{storage} 不支持 {oper_method}"}
|
||||
result = getattr(storage_oper, oper_method)(**params)
|
||||
if result is None:
|
||||
return {"success": False, "message": f"{storage} 的 {oper_method} 执行失败"}
|
||||
data, errmsg = result
|
||||
return {"success": bool(data), "message": errmsg, "data": data}
|
||||
|
||||
@staticmethod
|
||||
def recommend_name(meta: MetaBase, mediainfo: MediaInfo,
|
||||
episodes_info: Optional[List[TmdbEpisode]] = None) -> Optional[str]:
|
||||
"""
|
||||
获取重命名后的名称
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param episodes_info: 集信息,由调用方链层预先获取
|
||||
:return: 重命名后的名称(含目录)
|
||||
"""
|
||||
handler = TransHandler()
|
||||
# 重命名格式
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
# 获取重命名后的名称
|
||||
path = handler.get_rename_path(
|
||||
template_string=rename_format,
|
||||
rename_dict=handler.get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=Path(meta.title).suffix)
|
||||
)
|
||||
return path.as_posix() if path else ""
|
||||
|
||||
def list_files(self, fileitem: FileItem, recursion: Optional[bool] = False) -> Optional[List[FileItem]]:
|
||||
"""
|
||||
浏览文件
|
||||
:param fileitem: 源文件
|
||||
:param recursion: 是否递归,此时只浏览文件
|
||||
:return: 文件项列表
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||
return None
|
||||
|
||||
def __get_files(_item: FileItem, _r: Optional[bool] = False):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = storage_oper.list(_item)
|
||||
if _items:
|
||||
if _r:
|
||||
for t in _items:
|
||||
if t.type == "dir":
|
||||
__get_files(t, _r)
|
||||
else:
|
||||
result.append(t)
|
||||
else:
|
||||
result.extend(_items)
|
||||
|
||||
# 返回结果
|
||||
result = []
|
||||
__get_files(fileitem, recursion)
|
||||
|
||||
return result
|
||||
|
||||
def any_files(self, fileitem: FileItem, extensions: list = None) -> Optional[bool]:
|
||||
"""
|
||||
查询当前目录下是否存在指定扩展名任意文件
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件浏览")
|
||||
return None
|
||||
|
||||
def __any_file(_item: FileItem):
|
||||
"""
|
||||
递归处理
|
||||
"""
|
||||
_items = storage_oper.list(_item)
|
||||
if _items:
|
||||
if not extensions:
|
||||
return True
|
||||
for t in _items:
|
||||
if (t.type == "file"
|
||||
and t.extension
|
||||
and f".{t.extension.lower()}" in extensions):
|
||||
return True
|
||||
elif t.type == "dir":
|
||||
if __any_file(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 返回结果
|
||||
return __any_file(fileitem)
|
||||
|
||||
def create_folder(self, fileitem: FileItem, name: str) -> Optional[FileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 源文件
|
||||
:param name: 目录名
|
||||
:return: 创建的目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的目录创建")
|
||||
return None
|
||||
return storage_oper.create_folder(fileitem, name)
|
||||
|
||||
def get_folder(self, storage: str, path: Path) -> Optional[FileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的目录获取")
|
||||
return None
|
||||
return storage_oper.get_folder(path)
|
||||
|
||||
def delete_file(self, fileitem: FileItem) -> Optional[bool]:
|
||||
"""
|
||||
删除文件或目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的删除处理")
|
||||
return False
|
||||
return storage_oper.delete(fileitem)
|
||||
|
||||
def rename_file(self, fileitem: FileItem, name: str) -> Optional[bool]:
|
||||
"""
|
||||
重命名文件或目录
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的重命名处理")
|
||||
return False
|
||||
return storage_oper.rename(fileitem, name)
|
||||
|
||||
def download_file(self, fileitem: FileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
下载文件
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的下载处理")
|
||||
return None
|
||||
return storage_oper.download(fileitem, path=path)
|
||||
|
||||
def upload_file(self, fileitem: FileItem, path: Path, new_name: Optional[str] = None) -> Optional[FileItem]:
|
||||
"""
|
||||
上传文件
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的上传处理")
|
||||
return None
|
||||
return storage_oper.upload(fileitem, path, new_name)
|
||||
|
||||
def get_file_item(self, storage: str, path: Path) -> Optional[FileItem]:
|
||||
"""
|
||||
根据路径获取文件项
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的文件获取")
|
||||
return None
|
||||
return storage_oper.get_item(path)
|
||||
|
||||
def get_parent_item(self, fileitem: FileItem) -> Optional[FileItem]:
|
||||
"""
|
||||
获取上级目录项
|
||||
"""
|
||||
if fileitem.storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {fileitem.storage} 的文件获取")
|
||||
return None
|
||||
return storage_oper.get_parent(fileitem)
|
||||
|
||||
def snapshot_storage(self, storage: str, path: Path,
|
||||
last_snapshot_time: float = None, max_depth: int = 5,
|
||||
previous_snapshot: Optional[Dict[str, Dict]] = None) -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
快照存储
|
||||
:param storage: 存储类型
|
||||
:param path: 路径
|
||||
:param last_snapshot_time: 上次快照时间,用于增量快照
|
||||
:param max_depth: 最大递归深度,避免过深遍历
|
||||
:param previous_snapshot: 上次完整快照,用于增量对账
|
||||
"""
|
||||
if storage not in self._support_storages:
|
||||
return None
|
||||
storage_oper = self.__get_storage_oper(storage)
|
||||
if not storage_oper:
|
||||
logger.error(f"不支持 {storage} 的快照处理")
|
||||
return None
|
||||
return storage_oper.snapshot(
|
||||
path,
|
||||
last_snapshot_time=last_snapshot_time,
|
||||
max_depth=max_depth,
|
||||
previous_snapshot=previous_snapshot
|
||||
)
|
||||
|
||||
def transfer(self, fileitem: FileItem, meta: MetaBase, mediainfo: MediaInfo,
|
||||
target_directory: TransferDirectoryConf = None,
|
||||
target_storage: Optional[str] = None, target_path: Path = None,
|
||||
transfer_type: Optional[str] = None, scrape: Optional[bool] = None,
|
||||
library_type_folder: Optional[bool] = None, library_category_folder: Optional[bool] = None,
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
source_oper: Callable = None, target_oper: Callable = None,
|
||||
preview: Optional[bool] = False) -> TransferInfo:
|
||||
"""
|
||||
文件整理
|
||||
:param fileitem: 文件信息
|
||||
:param meta: 预识别的元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param target_directory: 目标目录配置
|
||||
:param target_storage: 目标存储
|
||||
:param target_path: 目标路径
|
||||
:param transfer_type: 转移模式
|
||||
:param scrape: 是否刮削元数据
|
||||
:param library_type_folder: 是否按媒体类型创建目录
|
||||
:param library_category_folder: 是否按媒体类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param source_oper: 源存储操作对象
|
||||
:param target_oper: 目标存储操作对象
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
handler = TransHandler()
|
||||
# 检查目录路径
|
||||
if fileitem.storage == "local" and not Path(fileitem.path).exists():
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{fileitem.path} 不存在")
|
||||
# 目标路径不能是文件
|
||||
if target_path and target_path.is_file():
|
||||
logger.error(f"整理目标路径 {target_path} 是一个文件")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{target_path} 不是有效目录")
|
||||
# 获取目标路径
|
||||
if target_directory:
|
||||
# 目标媒体库目录未设置
|
||||
if not target_directory.library_path:
|
||||
logger.error(f"目标媒体库目录未设置,无法整理文件,源路径:{fileitem.path}")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message="目标媒体库目录未设置")
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
transfer_type = target_directory.transfer_type
|
||||
# 目标存储
|
||||
if not target_storage:
|
||||
target_storage = target_directory.library_storage
|
||||
# 是否需要重命名
|
||||
need_rename = target_directory.renaming
|
||||
# 是否需要通知
|
||||
need_notify = target_directory.notify
|
||||
# 覆盖模式
|
||||
overwrite_mode = target_directory.overwrite_mode
|
||||
# 是否需要刮削
|
||||
need_scrape = target_directory.scraping if scrape is None else scrape
|
||||
# 拼装媒体库一、二级子目录
|
||||
target_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=target_directory,
|
||||
need_type_folder=library_type_folder,
|
||||
need_category_folder=library_category_folder)
|
||||
elif target_path:
|
||||
need_scrape = scrape or False
|
||||
need_rename = True
|
||||
need_notify = False
|
||||
overwrite_mode = "never"
|
||||
# 手动整理的场景,有自定义目标路径
|
||||
target_path = handler.get_dest_path(mediainfo=mediainfo, target_path=target_path,
|
||||
need_type_folder=library_type_folder,
|
||||
need_category_folder=library_category_folder)
|
||||
else:
|
||||
# 未找到有效的媒体库目录
|
||||
logger.error(
|
||||
f"{mediainfo.type.value if mediainfo.type else '未知类型'} {mediainfo.title_year} 未找到有效的媒体库目录,无法整理文件,源路径:{fileitem.path}")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message="未找到有效的媒体库目录")
|
||||
# 整理方式
|
||||
if not transfer_type:
|
||||
logger.error(f"{target_directory.name} 未设置整理方式")
|
||||
return TransferInfo(success=False,
|
||||
fileitem=fileitem,
|
||||
message=f"{target_directory.name} 未设置整理方式")
|
||||
|
||||
# 源操作对象
|
||||
if not source_oper:
|
||||
source_oper = self.__get_storage_oper(fileitem.storage)
|
||||
if not source_oper:
|
||||
return TransferInfo(success=False,
|
||||
message=f"不支持的存储类型:{fileitem.storage}",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify
|
||||
)
|
||||
# 目的操作对象
|
||||
if not target_oper:
|
||||
if not target_storage:
|
||||
target_storage = fileitem.storage
|
||||
target_oper = self.__get_storage_oper(target_storage)
|
||||
if not target_oper:
|
||||
return TransferInfo(success=False,
|
||||
message=f"不支持的存储类型:{target_storage}",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
need_notify=need_notify)
|
||||
|
||||
# 整理
|
||||
logger.info(f"获取整理目标路径:【{target_storage}】{target_path}")
|
||||
return handler.transfer_media(fileitem=fileitem,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
transfer_type=transfer_type,
|
||||
need_scrape=need_scrape,
|
||||
need_rename=need_rename,
|
||||
need_notify=need_notify,
|
||||
overwrite_mode=overwrite_mode,
|
||||
episodes_info=episodes_info,
|
||||
preview=preview,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper)
|
||||
|
||||
@staticmethod
|
||||
def _build_library_lookup_meta(
|
||||
mediainfo: Union[MediaInfo, MusicInfo],
|
||||
) -> MetaBase:
|
||||
"""构造标准媒体库路径反查使用的最小元数据。"""
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
music_type = getattr(mediainfo, "music_type", None)
|
||||
album = getattr(mediainfo, "album", None)
|
||||
if not album and music_type == MUSIC_ENTITY_ALBUM:
|
||||
album = mediainfo.title
|
||||
return MetaMusic(
|
||||
title=mediainfo.title,
|
||||
artists=list(getattr(mediainfo, "artists", None) or []),
|
||||
album=album,
|
||||
album_artist=getattr(mediainfo, "album_artist", None),
|
||||
year=mediainfo.year,
|
||||
disc_number=getattr(mediainfo, "disc_number", None),
|
||||
track_number=getattr(mediainfo, "track_number", None),
|
||||
total_tracks=getattr(mediainfo, "total_tracks", None),
|
||||
media_source=getattr(mediainfo, "source", None),
|
||||
media_id=getattr(mediainfo, "media_id", None),
|
||||
)
|
||||
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
if meta.type == MediaType.UNKNOWN and mediainfo.type is not None:
|
||||
meta.type = mediainfo.type
|
||||
if meta.year is None:
|
||||
meta.year = mediainfo.year
|
||||
if meta.begin_season is None:
|
||||
meta.begin_season = 1
|
||||
if meta.begin_episode is None:
|
||||
meta.begin_episode = 1
|
||||
return meta
|
||||
|
||||
@staticmethod
|
||||
def _music_file_identity(fileitem: FileItem) -> Tuple[Optional[int], Optional[int], str]:
|
||||
"""从标准音乐文件路径提取碟号、曲序和归一化曲名。"""
|
||||
file_path = Path(fileitem.path or fileitem.name or "")
|
||||
file_meta = MetaMusic(
|
||||
org_string=file_path.name,
|
||||
title=file_path.stem,
|
||||
).apply_path_context(file_path)
|
||||
return (
|
||||
file_meta.disc_number,
|
||||
file_meta.track_number,
|
||||
text_tools.normalize_upper(file_meta.title or file_path.stem),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _music_recording_exists(
|
||||
cls,
|
||||
fileitems: List[FileItem],
|
||||
mediainfo: MusicInfo,
|
||||
) -> bool:
|
||||
"""按曲名和可用曲序判断单曲是否存在,避免专辑内任一文件造成误判。"""
|
||||
target_title = text_tools.normalize_upper(mediainfo.title or "")
|
||||
target_track = getattr(mediainfo, "track_number", None)
|
||||
target_disc = getattr(mediainfo, "disc_number", None)
|
||||
if not target_title:
|
||||
return False
|
||||
for fileitem in fileitems:
|
||||
disc_number, track_number, title = cls._music_file_identity(fileitem)
|
||||
if title != target_title:
|
||||
continue
|
||||
if target_track is not None and track_number not in (None, target_track):
|
||||
continue
|
||||
if target_disc is not None and disc_number not in (None, target_disc):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _music_album_is_complete(
|
||||
cls,
|
||||
fileitems: List[FileItem],
|
||||
total_tracks: Optional[int],
|
||||
) -> bool:
|
||||
"""按去重后的曲序或曲名判断本地专辑是否达到目标曲目数。"""
|
||||
if not fileitems:
|
||||
return False
|
||||
if not total_tracks:
|
||||
return False
|
||||
track_identities = {
|
||||
(
|
||||
disc_number or 1,
|
||||
track_number if track_number is not None else title,
|
||||
)
|
||||
for disc_number, track_number, title in (
|
||||
cls._music_file_identity(fileitem) for fileitem in fileitems
|
||||
)
|
||||
if track_number is not None or title
|
||||
}
|
||||
return len(track_identities) >= total_tracks
|
||||
|
||||
def media_files(self, mediainfo: Union[MediaInfo, MusicInfo]) -> List[FileItem]:
|
||||
"""
|
||||
获取对应媒体的媒体库文件列表
|
||||
:param mediainfo: 媒体信息
|
||||
"""
|
||||
handler = TransHandler()
|
||||
ret_fileitems = []
|
||||
# 检查本地媒体库
|
||||
dest_dirs = DirectoryHelper().get_library_dirs()
|
||||
# 检查每一个媒体库目录
|
||||
for dest_dir in dest_dirs:
|
||||
# 存储
|
||||
storage_oper = self.__get_storage_oper(dest_dir.library_storage)
|
||||
if not storage_oper:
|
||||
continue
|
||||
# 媒体分类路径
|
||||
dir_path = handler.get_dest_dir(mediainfo=mediainfo, target_dir=dest_dir)
|
||||
# 重命名格式
|
||||
rename_format = settings.RENAME_FORMAT(mediainfo.type)
|
||||
# 元数据补上常用属性,尽可能确保重命名后的路径不出现空白
|
||||
meta = self._build_library_lookup_meta(mediainfo)
|
||||
# 获取路径(重命名路径)
|
||||
target_path = handler.get_rename_path(
|
||||
path=dir_path,
|
||||
template_string=rename_format,
|
||||
rename_dict=handler.get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 获取重命名后的媒体文件根路径
|
||||
media_path = DirectoryHelper.get_media_root_path(
|
||||
rename_format,
|
||||
rename_path=target_path,
|
||||
media_type=mediainfo.type,
|
||||
)
|
||||
if not media_path:
|
||||
# 忽略
|
||||
continue
|
||||
if dir_path != media_path and dir_path.is_relative_to(media_path):
|
||||
# 兜底检查,避免不必要的扫盘
|
||||
logger.warn(f"{media_path} 是媒体库目录 {dir_path} 的父目录,忽略获取媒体文件列表,请检查重命名格式!")
|
||||
continue
|
||||
# 检索媒体文件
|
||||
fileitem = storage_oper.get_item(media_path)
|
||||
if not fileitem:
|
||||
continue
|
||||
try:
|
||||
media_files = self.list_files(fileitem, True)
|
||||
except Exception as e:
|
||||
logger.debug(f"获取媒体文件列表失败:{str(e)}")
|
||||
continue
|
||||
if media_files:
|
||||
media_extensions = (
|
||||
settings.RMT_AUDIOEXT
|
||||
if mediainfo.type == MediaType.MUSIC
|
||||
else settings.RMT_MEDIAEXT
|
||||
)
|
||||
for media_file in media_files:
|
||||
if (
|
||||
media_file.extension
|
||||
and f".{media_file.extension.lower()}" in media_extensions
|
||||
):
|
||||
if media_file not in ret_fileitems:
|
||||
ret_fileitems.append(media_file)
|
||||
return ret_fileitems
|
||||
|
||||
def media_exists(
|
||||
self,
|
||||
mediainfo: Union[MediaInfo, MusicInfo],
|
||||
**kwargs,
|
||||
) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param server: 指定媒体服务器名称时跳过本地文件系统检查
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if kwargs.get("server"):
|
||||
return None
|
||||
|
||||
if not settings.LOCAL_EXISTS_SEARCH:
|
||||
return None
|
||||
|
||||
logger.debug(f"正在本地媒体库中查找 {mediainfo.title_year}...")
|
||||
|
||||
# 检查媒体库
|
||||
fileitems = self.media_files(mediainfo)
|
||||
if not fileitems:
|
||||
logger.debug(f"{mediainfo.title_year} 不在本地媒体库中")
|
||||
return None
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影存在任何文件为存在
|
||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
if getattr(mediainfo, "music_type", None) == MUSIC_ENTITY_ALBUM:
|
||||
exists = self._music_album_is_complete(
|
||||
fileitems,
|
||||
getattr(mediainfo, "total_tracks", None),
|
||||
)
|
||||
else:
|
||||
exists = self._music_recording_exists(fileitems, mediainfo)
|
||||
if not exists:
|
||||
logger.debug(f"{mediainfo.title_year} 在本地音乐库中尚不完整")
|
||||
return None
|
||||
logger.info(f"{mediainfo.title_year} 在本地音乐库中找到了")
|
||||
return ExistMediaInfo(type=MediaType.MUSIC)
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧检索集数
|
||||
seasons: Dict[int, list] = {}
|
||||
for fileitem in fileitems:
|
||||
file_meta = MetaInfo(fileitem.basename)
|
||||
season_index = file_meta.begin_season if file_meta.begin_season is not None else 1
|
||||
episode_index = file_meta.begin_episode
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in seasons:
|
||||
seasons[season_index] = []
|
||||
if episode_index not in seasons[season_index]:
|
||||
seasons[season_index].append(episode_index)
|
||||
# 返回剧集情况
|
||||
logger.info(f"{mediainfo.title_year} 在本地文件系统中找到了这些季集:{seasons}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
|
||||
return None
|
||||
@@ -4,7 +4,9 @@ from typing import Optional, List, Dict, Tuple, Callable, Union
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.system import StorageConf as _SchemaStorageConf
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.progress import ProgressHelper
|
||||
from app.application.storage import StorageHelper
|
||||
from app.runtime.log import logger
|
||||
@@ -69,7 +71,7 @@ class StorageBase(metaclass=ABCMeta):
|
||||
"""检查存储登录状态"""
|
||||
pass
|
||||
|
||||
def get_config(self) -> Optional[schemas.StorageConf]:
|
||||
def get_config(self) -> Optional[_SchemaStorageConf]:
|
||||
"""
|
||||
获取配置
|
||||
"""
|
||||
@@ -122,7 +124,7 @@ class StorageBase(metaclass=ABCMeta):
|
||||
return safe_name
|
||||
|
||||
def _build_download_path(
|
||||
self, fileitem: schemas.FileItem, path: Path
|
||||
self, fileitem: _SchemaFileItem, path: Path
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
构造本地下载路径,避免远端文件名携带目录片段时越过目标目录。
|
||||
@@ -148,14 +150,14 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
@@ -164,20 +166,20 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
|
||||
|
||||
@@ -188,28 +190,28 @@ class StorageBase(metaclass=ABCMeta):
|
||||
"""
|
||||
raise StorageQueryError(f"存储 {self.schema} 未实现严格查询,无法确认目标状态: {path}")
|
||||
|
||||
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def get_parent(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取父目录
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path).parent)
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Path:
|
||||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Path:
|
||||
"""
|
||||
下载文件,保存到本地,返回本地临时文件地址
|
||||
:param fileitem: 文件项
|
||||
@@ -218,8 +220,8 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
def upload(self, fileitem: _SchemaFileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
上传文件
|
||||
:param fileitem: 上传目录项
|
||||
@@ -229,14 +231,14 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
@@ -246,7 +248,7 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
@@ -256,21 +258,21 @@ class StorageBase(metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
软链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
@@ -292,8 +294,8 @@ class StorageBase(metaclass=ABCMeta):
|
||||
if PurePosixPath(file_path).is_relative_to(root_path)
|
||||
}
|
||||
|
||||
def __remove_deleted_children(_fileitm: schemas.FileItem,
|
||||
sub_files: List[schemas.FileItem]) -> None:
|
||||
def __remove_deleted_children(_fileitm: _SchemaFileItem,
|
||||
sub_files: List[_SchemaFileItem]) -> None:
|
||||
"""
|
||||
清理已确认遍历目录中不再存在的直接子项。
|
||||
未变化的子目录仍保留旧基线,避免增量遍历将其误删。
|
||||
@@ -311,7 +313,7 @@ class StorageBase(metaclass=ABCMeta):
|
||||
if direct_child_path not in child_paths:
|
||||
files_info.pop(old_file_path, None)
|
||||
|
||||
def __snapshot_file(_fileitm: schemas.FileItem, current_depth: int = 0):
|
||||
def __snapshot_file(_fileitm: _SchemaFileItem, current_depth: int = 0):
|
||||
"""
|
||||
递归获取文件信息
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,8 @@ from typing import List, Optional, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.config import settings, global_vars
|
||||
from app.runtime.log import logger
|
||||
from app.modules.filemanager import StorageBase
|
||||
@@ -287,16 +288,16 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
return ret_data.get(result_key)
|
||||
return ret_data
|
||||
|
||||
def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> schemas.FileItem:
|
||||
def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> _SchemaFileItem:
|
||||
"""
|
||||
获取文件信息
|
||||
"""
|
||||
if not fileinfo:
|
||||
return schemas.FileItem()
|
||||
return _SchemaFileItem()
|
||||
if not parent.endswith("/"):
|
||||
parent += "/"
|
||||
if fileinfo.get("type") == "folder":
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=fileinfo.get("file_id"),
|
||||
parent_fileid=fileinfo.get("parent_file_id"),
|
||||
@@ -309,7 +310,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
drive_id=fileinfo.get("drive_id"),
|
||||
)
|
||||
else:
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=fileinfo.get("file_id"),
|
||||
parent_fileid=fileinfo.get("parent_file_id"),
|
||||
@@ -343,7 +344,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
def init_storage(self):
|
||||
pass
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
目录遍历实现
|
||||
"""
|
||||
@@ -386,7 +387,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
break
|
||||
return items
|
||||
|
||||
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def _delay_get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
自动延迟重试 get_item 模块
|
||||
"""
|
||||
@@ -398,8 +399,8 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
return None
|
||||
|
||||
def create_folder(
|
||||
self, parent_item: schemas.FileItem, name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, parent_item: _SchemaFileItem, name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
"""
|
||||
@@ -588,10 +589,10 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def upload(
|
||||
self,
|
||||
target_dir: schemas.FileItem,
|
||||
target_dir: _SchemaFileItem,
|
||||
local_path: Path,
|
||||
new_name: Optional[str] = None,
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
文件上传:分片、支持秒传
|
||||
"""
|
||||
@@ -721,7 +722,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
)
|
||||
return self.__get_fileitem(result, parent=target_dir.path)
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
带实时进度显示的下载
|
||||
"""
|
||||
@@ -801,7 +802,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
def check(self) -> bool:
|
||||
return self.access_token is not None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件/目录
|
||||
"""
|
||||
@@ -815,7 +816,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
except requests.exceptions.HTTPError:
|
||||
return False
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件/目录
|
||||
"""
|
||||
@@ -835,7 +836,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __get_by_path_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:
|
||||
def __get_by_path_item(self, path: Path, drive_id: str = None) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
按路径查询文件/目录项,无法确认状态时抛出 StorageQueryError。
|
||||
NotFound 系列错误码表示确认不存在,其余错误(网络失败、限流、
|
||||
@@ -861,7 +862,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
f"【阿里云盘】查询文件信息出错: {path} - {code} {resp.get('message')}")
|
||||
return self.__get_fileitem(resp, parent=str(path.parent))
|
||||
|
||||
def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path, drive_id: str = None) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取指定路径的文件/目录项
|
||||
"""
|
||||
@@ -871,7 +872,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。
|
||||
"""
|
||||
@@ -882,14 +883,14 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
except Exception as e:
|
||||
raise StorageQueryError(f"【阿里云盘】查询文件信息失败: {path} - {e}") from e
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取指定路径的文件夹,如不存在则创建
|
||||
"""
|
||||
|
||||
def __find_dir(
|
||||
_fileitem: schemas.FileItem, _name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
_fileitem: _SchemaFileItem, _name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
查找下级目录中匹配名称的目录
|
||||
"""
|
||||
@@ -905,7 +906,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
if folder:
|
||||
return folder
|
||||
# 逐级查找和创建目录
|
||||
fileitem = schemas.FileItem(
|
||||
fileitem = _SchemaFileItem(
|
||||
storage=self.schema.value, path="/", drive_id=self._default_drive_id
|
||||
)
|
||||
for part in path.parts[1:]:
|
||||
@@ -920,13 +921,13 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
fileitem = dir_file
|
||||
return fileitem
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件/目录详细信息
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path))
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件到指定路径
|
||||
:param fileitem: 要复制的文件项
|
||||
@@ -958,7 +959,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
self.rename(new_file, new_name)
|
||||
return True
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件到指定路径
|
||||
:param fileitem: 要移动的文件项
|
||||
@@ -988,13 +989,13 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
return False
|
||||
return True
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
获取带有企业级配额信息的存储使用情况
|
||||
"""
|
||||
@@ -1005,7 +1006,7 @@ class AliPan(StorageBase, metaclass=WeakSingleton):
|
||||
space = resp.get("personal_space_info") or {}
|
||||
total_size = space.get("total_size") or 0
|
||||
used_size = space.get("used_size") or 0
|
||||
return schemas.StorageUsage(
|
||||
return _SchemaStorageUsage(
|
||||
total=total_size, available=total_size - used_size
|
||||
)
|
||||
except NoCheckInException:
|
||||
|
||||
@@ -5,7 +5,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.cache import cached
|
||||
from app.runtime.config import settings, global_vars
|
||||
from app.runtime.log import logger
|
||||
@@ -54,7 +55,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def _delay_get_item(
|
||||
self, path: Path, /, refresh: bool = False
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
自动延迟重试 get_item 模块
|
||||
|
||||
@@ -70,8 +71,8 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
return None
|
||||
|
||||
def __build_transfer_item(
|
||||
self, source_item: schemas.FileItem, target_path: Path
|
||||
) -> schemas.FileItem:
|
||||
self, source_item: _SchemaFileItem, target_path: Path
|
||||
) -> _SchemaFileItem:
|
||||
"""
|
||||
根据目标路径构造文件项,用于 OpenList 操作成功但元数据短时间不可见的场景。
|
||||
目录项路径需要遵循 FileItem 以斜杠结尾的约定。
|
||||
@@ -80,7 +81,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
if source_item.type == "dir" and not target_path_str.endswith("/"):
|
||||
target_path_str = f"{target_path_str}/"
|
||||
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type=source_item.type,
|
||||
path=target_path_str,
|
||||
@@ -197,12 +198,12 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def list(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
fileitem: _SchemaFileItem,
|
||||
password: Optional[str] = "",
|
||||
page: int = 1,
|
||||
per_page: int = 0,
|
||||
refresh: bool = False,
|
||||
) -> List[schemas.FileItem]:
|
||||
) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
:param fileitem: 文件项
|
||||
@@ -291,7 +292,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
page_content = page_data.get("content") or []
|
||||
items.extend(
|
||||
[
|
||||
schemas.FileItem(
|
||||
_SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir" if item["is_dir"] else "file",
|
||||
path=(Path(fileitem.path) / item["name"]).as_posix()
|
||||
@@ -321,8 +322,8 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
current_page += 1
|
||||
|
||||
def create_folder(
|
||||
self, fileitem: schemas.FileItem, name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, fileitem: _SchemaFileItem, name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
@@ -364,7 +365,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
return self._delay_get_item(
|
||||
path, refresh=True
|
||||
) or self.__build_transfer_item(
|
||||
schemas.FileItem(
|
||||
_SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=fileitem.path,
|
||||
@@ -374,7 +375,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
path,
|
||||
)
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
|
||||
@@ -386,7 +387,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
return folder
|
||||
if not folder:
|
||||
folder = self.create_folder(
|
||||
schemas.FileItem(
|
||||
_SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=path.parent.as_posix(),
|
||||
@@ -404,7 +405,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
page: int = 1,
|
||||
per_page: int = 0,
|
||||
refresh: bool = False,
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
:param path: 文件路径
|
||||
@@ -473,14 +474,14 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
return self.__build_fileitem(path, result["data"])
|
||||
|
||||
def __build_fileitem(self, path: Path, data: dict) -> schemas.FileItem:
|
||||
def __build_fileitem(self, path: Path, data: dict) -> _SchemaFileItem:
|
||||
"""
|
||||
根据接口返回数据构建文件项。
|
||||
:param path: 文件路径
|
||||
:param data: 接口返回的 data 字段
|
||||
:return: 文件项
|
||||
"""
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir" if data["is_dir"] else "file",
|
||||
path=path.as_posix() + ("/" if data["is_dir"] else ""),
|
||||
@@ -492,7 +493,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
thumbnail=data["thumb"],
|
||||
)
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
|
||||
只有接口明确回报「对象不存在」才是确定结果,连接失败、HTTP 异常与其他
|
||||
@@ -523,7 +524,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败:{message}")
|
||||
return self.__build_fileitem(path, result["data"])
|
||||
|
||||
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def get_parent(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取父目录
|
||||
|
||||
@@ -532,7 +533,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
"""
|
||||
return self.get_folder(Path(fileitem.path).parent)
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件或目录
|
||||
|
||||
@@ -570,7 +571,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
return False
|
||||
return True
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
|
||||
@@ -619,7 +620,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def download(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
fileitem: _SchemaFileItem,
|
||||
path: Path = None,
|
||||
password: Optional[str] = "",
|
||||
) -> Optional[Path]:
|
||||
@@ -712,11 +713,11 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def upload(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
fileitem: _SchemaFileItem,
|
||||
path: Path,
|
||||
new_name: Optional[str] = None,
|
||||
task: bool = False,
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
上传文件(带进度)
|
||||
:param fileitem: 上传目录项
|
||||
@@ -830,13 +831,13 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
"X-File-Sha256": sha256_hash.hexdigest(),
|
||||
}
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path))
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
@@ -892,8 +893,8 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
return True
|
||||
|
||||
def copy_item(
|
||||
self, fileitem: schemas.FileItem, path: Path, new_name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, fileitem: _SchemaFileItem, path: Path, new_name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
复制文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。
|
||||
"""
|
||||
@@ -913,7 +914,7 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
) or self.__build_transfer_item(fileitem, target_path)
|
||||
return None
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
@@ -967,8 +968,8 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
return True
|
||||
|
||||
def move_item(
|
||||
self, fileitem: schemas.FileItem, path: Path, new_name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, fileitem: _SchemaFileItem, path: Path, new_name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
移动文件并返回目标文件项,兼容 OpenList 成功响应不携带目标对象的格式。
|
||||
"""
|
||||
@@ -979,19 +980,19 @@ class Alist(StorageBase, metaclass=WeakSingleton):
|
||||
fileitem, target_path
|
||||
)
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
软链接文件
|
||||
"""
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.config import global_vars, settings
|
||||
from app.application.directory import DirectoryHelper
|
||||
from app.runtime.log import logger
|
||||
@@ -45,7 +46,7 @@ class LocalStorage(StorageBase):
|
||||
"""
|
||||
return True
|
||||
|
||||
def __get_fileitem(self, path: Path) -> schemas.FileItem:
|
||||
def __get_fileitem(self, path: Path) -> _SchemaFileItem:
|
||||
"""
|
||||
获取文件项
|
||||
"""
|
||||
@@ -53,7 +54,7 @@ class LocalStorage(StorageBase):
|
||||
# 顺带只 stat 一次——原先 size 与 modify_time 各 stat 一次,在网络挂载上
|
||||
# 等于把这个热点路径的开销翻倍
|
||||
info = fsproxy.stat(path)
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=path.as_posix(),
|
||||
@@ -64,11 +65,11 @@ class LocalStorage(StorageBase):
|
||||
modify_time=info["mtime"],
|
||||
)
|
||||
|
||||
def __get_diritem(self, path: Path) -> schemas.FileItem:
|
||||
def __get_diritem(self, path: Path) -> _SchemaFileItem:
|
||||
"""
|
||||
获取目录项
|
||||
"""
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=path.as_posix() + "/",
|
||||
@@ -77,7 +78,7 @@ class LocalStorage(StorageBase):
|
||||
modify_time=fsproxy.stat(path)["mtime"],
|
||||
)
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
@@ -88,7 +89,7 @@ class LocalStorage(StorageBase):
|
||||
if SystemUtils.is_windows():
|
||||
partitions = SystemUtils.get_windows_drives() or ["C:/"]
|
||||
for partition in partitions:
|
||||
ret_items.append(schemas.FileItem(
|
||||
ret_items.append(_SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=partition + "/",
|
||||
@@ -126,7 +127,7 @@ class LocalStorage(StorageBase):
|
||||
ret_items.append(self.__get_fileitem(item))
|
||||
return ret_items
|
||||
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
@@ -139,7 +140,7 @@ class LocalStorage(StorageBase):
|
||||
path_obj.mkdir(parents=True, exist_ok=True)
|
||||
return self.__get_diritem(path_obj)
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取目录
|
||||
"""
|
||||
@@ -147,7 +148,7 @@ class LocalStorage(StorageBase):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return self.__get_diritem(path)
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
@@ -159,7 +160,7 @@ class LocalStorage(StorageBase):
|
||||
return self.__get_fileitem(path)
|
||||
return self.__get_diritem(path)
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,无法确认状态时抛出 StorageQueryError。
|
||||
Path.exists() 会把部分 errno(如 EBADF/ELOOP)归入「不存在」,
|
||||
@@ -178,7 +179,7 @@ class LocalStorage(StorageBase):
|
||||
except OSError as e:
|
||||
raise StorageQueryError(f"【本地】读取文件信息失败: {path} - {e}") from e
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
@@ -187,7 +188,7 @@ class LocalStorage(StorageBase):
|
||||
return None
|
||||
return self.__get_fileitem(path_obj)
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
@@ -211,7 +212,7 @@ class LocalStorage(StorageBase):
|
||||
return False
|
||||
return True
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
@@ -225,7 +226,7 @@ class LocalStorage(StorageBase):
|
||||
return False
|
||||
return True
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
下载文件
|
||||
"""
|
||||
@@ -370,10 +371,10 @@ class LocalStorage(StorageBase):
|
||||
|
||||
def upload(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
fileitem: _SchemaFileItem,
|
||||
path: Path,
|
||||
new_name: Optional[str] = None
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
上传文件(带进度)
|
||||
"""
|
||||
@@ -402,7 +403,7 @@ class LocalStorage(StorageBase):
|
||||
|
||||
def copy(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
fileitem: _SchemaFileItem,
|
||||
path: Path,
|
||||
new_name: str
|
||||
) -> bool:
|
||||
@@ -413,7 +414,7 @@ class LocalStorage(StorageBase):
|
||||
|
||||
def move(
|
||||
self,
|
||||
fileitem: schemas.FileItem,
|
||||
fileitem: _SchemaFileItem,
|
||||
path: Path,
|
||||
new_name: str
|
||||
) -> bool:
|
||||
@@ -443,7 +444,7 @@ class LocalStorage(StorageBase):
|
||||
logger.warn(f"【本地】移动已完成但删除源文件失败:{src} - {err}")
|
||||
return True
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
"""
|
||||
@@ -454,7 +455,7 @@ class LocalStorage(StorageBase):
|
||||
return False
|
||||
return True
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
软链接文件
|
||||
"""
|
||||
@@ -465,7 +466,7 @@ class LocalStorage(StorageBase):
|
||||
return False
|
||||
return True
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
@@ -475,7 +476,7 @@ class LocalStorage(StorageBase):
|
||||
[Path(d.library_path) for d in directory_helper.get_local_library_dirs() if d.library_path],
|
||||
btrfs_fsid_dedup=settings.BTRFS_FSID_DEDUP,
|
||||
)
|
||||
return schemas.StorageUsage(
|
||||
return _SchemaStorageUsage(
|
||||
total=total_storage,
|
||||
available=free_storage
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Union
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.log import logger
|
||||
from app.modules.filemanager.storages import StorageBase, transfer_process
|
||||
@@ -114,14 +115,14 @@ class Rclone(StorageBase):
|
||||
|
||||
return None
|
||||
|
||||
def __get_rcloneitem(self, item: dict, parent: Optional[str] = "/") -> schemas.FileItem:
|
||||
def __get_rcloneitem(self, item: dict, parent: Optional[str] = "/") -> _SchemaFileItem:
|
||||
"""
|
||||
获取rclone文件项
|
||||
"""
|
||||
if not item:
|
||||
return schemas.FileItem()
|
||||
return _SchemaFileItem()
|
||||
if item.get("IsDir"):
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=f"{parent}{item.get('Name')}" + "/",
|
||||
@@ -130,7 +131,7 @@ class Rclone(StorageBase):
|
||||
modify_time=time_tools.parse_timestamp(item.get("ModTime"))
|
||||
)
|
||||
else:
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=f"{parent}{item.get('Name')}",
|
||||
@@ -171,7 +172,7 @@ class Rclone(StorageBase):
|
||||
|
||||
def __wait_for_item(
|
||||
self, path: Path, retries: int = 3, delay: float = 0.2
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
等待目录或文件在远端可见,兼容云盘最终一致性延迟。
|
||||
"""
|
||||
@@ -198,7 +199,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】存储检查失败:{err}")
|
||||
return False
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
@@ -220,7 +221,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】浏览文件失败:{err}")
|
||||
return []
|
||||
|
||||
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
|
||||
def create_folder(self, fileitem: _SchemaFileItem, name: str) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
:param fileitem: 父目录
|
||||
@@ -253,7 +254,7 @@ class Rclone(StorageBase):
|
||||
return folder
|
||||
return None
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
根据文件路程获取目录,不存在则创建
|
||||
"""
|
||||
@@ -264,7 +265,7 @@ class Rclone(StorageBase):
|
||||
if folder:
|
||||
return folder
|
||||
# 逐级查找和创建目录
|
||||
fileitem = schemas.FileItem(storage=self.schema.value, type="dir", path="/")
|
||||
fileitem = _SchemaFileItem(storage=self.schema.value, type="dir", path="/")
|
||||
for part in normalized.parts[1:]:
|
||||
current_path = Path(self.__normalize_remote_path(Path(fileitem.path) / part))
|
||||
with self.__get_path_lock(current_path):
|
||||
@@ -277,7 +278,7 @@ class Rclone(StorageBase):
|
||||
fileitem = dir_file
|
||||
return fileitem
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
@@ -300,7 +301,7 @@ class Rclone(StorageBase):
|
||||
logger.debug(f"【rclone】获取文件项失败:{err}")
|
||||
return None
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
|
||||
rclone 用退出码 3/4 表示目录/文件不存在,其余非零退出无法区分
|
||||
@@ -332,7 +333,7 @@ class Rclone(StorageBase):
|
||||
return self.__get_rcloneitem(item, parent=str(path.parent) + "/")
|
||||
return None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件
|
||||
"""
|
||||
@@ -350,7 +351,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】删除文件失败:{err}")
|
||||
return False
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
@@ -369,7 +370,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】重命名文件失败:{err}")
|
||||
return False
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
带实时进度显示的下载
|
||||
"""
|
||||
@@ -426,8 +427,8 @@ class Rclone(StorageBase):
|
||||
local_path.unlink()
|
||||
return None
|
||||
|
||||
def upload(self, fileitem: schemas.FileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
|
||||
def upload(self, fileitem: _SchemaFileItem, path: Path,
|
||||
new_name: Optional[str] = None) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
带实时进度显示的上传
|
||||
:param fileitem: 上传目录项
|
||||
@@ -483,7 +484,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】上传失败: {target_name} - {err}")
|
||||
return None
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
@@ -503,7 +504,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】获取文件详情失败:{err}")
|
||||
return None
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
:param fileitem: 文件项
|
||||
@@ -558,7 +559,7 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】移动失败: {fileitem.name} - {err}")
|
||||
return False
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
:param fileitem: 文件项
|
||||
@@ -613,13 +614,13 @@ class Rclone(StorageBase):
|
||||
logger.error(f"【rclone】复制失败: {fileitem.name} - {err}")
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
@@ -647,7 +648,7 @@ class Rclone(StorageBase):
|
||||
)
|
||||
if ret.returncode == 0:
|
||||
items = json.loads(ret.stdout)
|
||||
return schemas.StorageUsage(
|
||||
return _SchemaStorageUsage(
|
||||
total=items.get("total"),
|
||||
available=items.get("free")
|
||||
)
|
||||
|
||||
@@ -12,7 +12,8 @@ from smbprotocol.exceptions import (
|
||||
SMBAuthenticationError,
|
||||
)
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.config import settings, global_vars
|
||||
from app.runtime.log import logger
|
||||
from app.modules.filemanager import StorageBase
|
||||
@@ -172,7 +173,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def _create_fileitem(
|
||||
self, stat_result, file_path: str, name: str
|
||||
) -> schemas.FileItem:
|
||||
) -> _SchemaFileItem:
|
||||
"""
|
||||
创建文件项
|
||||
"""
|
||||
@@ -195,7 +196,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
modify_time = int(time.time())
|
||||
|
||||
if is_directory:
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=relative_path,
|
||||
@@ -204,7 +205,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
modify_time=modify_time,
|
||||
)
|
||||
else:
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=relative_path,
|
||||
@@ -217,7 +218,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
except Exception as e:
|
||||
logger.error(f"【SMB】创建文件项失败:{e}")
|
||||
# 返回基本的文件项信息
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="file",
|
||||
path=file_path.replace(self._server_path, "").replace("\\", "/"),
|
||||
@@ -249,7 +250,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
浏览文件
|
||||
"""
|
||||
@@ -295,8 +296,8 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
return []
|
||||
|
||||
def create_folder(
|
||||
self, fileitem: schemas.FileItem, name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, fileitem: _SchemaFileItem, name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
"""
|
||||
@@ -310,7 +311,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
smbclient.mkdir(new_path)
|
||||
|
||||
# 返回创建的目录信息
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path=f"{fileitem.path.rstrip('/')}/{name}/",
|
||||
@@ -322,7 +323,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】创建目录失败: {e}")
|
||||
return None
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取目录,如目录不存在则创建
|
||||
"""
|
||||
@@ -349,7 +350,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
return folder
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,不存在返回None
|
||||
"""
|
||||
@@ -358,7 +359,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
# 处理根目录
|
||||
if str(path) == "/":
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path="/",
|
||||
@@ -382,7 +383,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.debug(f"【SMB】获取文件项失败: {e}")
|
||||
return None
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
|
||||
只有 ENOENT/ENOTDIR 才是「确认不存在」,连接中断、认证失败等都无法确认
|
||||
@@ -393,7 +394,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
# 处理根目录
|
||||
if str(path) == "/":
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
type="dir",
|
||||
path="/",
|
||||
@@ -415,13 +416,13 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
except Exception as e:
|
||||
raise StorageQueryError(f"【SMB】查询文件项失败: {path} - {e}") from e
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件详情
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path))
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件或目录
|
||||
"""
|
||||
@@ -523,7 +524,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】递归删除失败: {smb_path} - {e}")
|
||||
raise SMBConnectionError(f"递归删除失败 {smb_path}: {e}")
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件
|
||||
"""
|
||||
@@ -543,7 +544,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】重命名失败: {e}")
|
||||
return False
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
带实时进度显示的下载
|
||||
"""
|
||||
@@ -595,8 +596,8 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
return None
|
||||
|
||||
def upload(
|
||||
self, fileitem: schemas.FileItem, path: Path, new_name: Optional[str] = None
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, fileitem: _SchemaFileItem, path: Path, new_name: Optional[str] = None
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
带实时进度显示的上传
|
||||
"""
|
||||
@@ -643,7 +644,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】上传失败: {target_name} - {e}")
|
||||
return None
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制文件
|
||||
"""
|
||||
@@ -670,7 +671,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】复制失败: {e}")
|
||||
return False
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动文件
|
||||
"""
|
||||
@@ -689,7 +690,7 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】移动失败: {e}")
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
"""
|
||||
硬链接文件
|
||||
Samba服务器需要开启 unix extensions 支持
|
||||
@@ -722,17 +723,17 @@ class SMB(StorageBase, metaclass=WeakSingleton):
|
||||
logger.error(f"【SMB】创建硬链接失败: {e}")
|
||||
return False
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
try:
|
||||
self._check_connection()
|
||||
volume_stat = smbclient.stat_volume(self._server_path)
|
||||
return schemas.StorageUsage(
|
||||
return _SchemaStorageUsage(
|
||||
total=volume_stat.total_size,
|
||||
available=volume_stat.caller_available_size,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,8 @@ from oss2 import SizedFileAdapter, determine_part_size
|
||||
from oss2.models import PartInfo
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.file import StorageUsage as _SchemaStorageUsage
|
||||
from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
from app.runtime.config import settings, global_vars
|
||||
from app.runtime.log import logger
|
||||
from app.modules.filemanager import StorageBase
|
||||
@@ -474,7 +475,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
def init_storage(self):
|
||||
pass
|
||||
|
||||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||||
def list(self, fileitem: _SchemaFileItem) -> List[_SchemaFileItem]:
|
||||
"""
|
||||
目录遍历实现
|
||||
"""
|
||||
@@ -520,7 +521,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
item_name = item["fn"]
|
||||
full_path = parent_path / item_name
|
||||
items.append(
|
||||
schemas.FileItem(
|
||||
_SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=str(item["fid"]),
|
||||
parent_fileid=cid,
|
||||
@@ -542,8 +543,8 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
return items
|
||||
|
||||
def create_folder(
|
||||
self, parent_item: schemas.FileItem, name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
self, parent_item: _SchemaFileItem, name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
创建目录
|
||||
"""
|
||||
@@ -564,7 +565,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
return self.get_item(new_path)
|
||||
logger.warn(f"【115】创建目录失败: {resp.get('error')}")
|
||||
return None
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=str(resp["data"]["file_id"]),
|
||||
path=new_path.as_posix() + "/",
|
||||
@@ -576,10 +577,10 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def upload(
|
||||
self,
|
||||
target_dir: schemas.FileItem,
|
||||
target_dir: _SchemaFileItem,
|
||||
local_path: Path,
|
||||
new_name: Optional[str] = None,
|
||||
) -> Optional[schemas.FileItem]:
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
实现带秒传、断点续传和二次认证的文件上传
|
||||
"""
|
||||
@@ -678,7 +679,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
params={"file_id": int(file_id)},
|
||||
)
|
||||
if info_resp:
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=str(info_resp["file_id"]),
|
||||
path=target_path.as_posix()
|
||||
@@ -872,11 +873,11 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def __build_uploaded_fileitem(
|
||||
self, target_path: Path, local_path: Path, file_size: int
|
||||
) -> schemas.FileItem:
|
||||
) -> _SchemaFileItem:
|
||||
"""
|
||||
构造已上传文件项,用于兼容 115 上传成功后目录索引延迟刷新。
|
||||
"""
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
path=target_path.as_posix(),
|
||||
type="file",
|
||||
@@ -887,7 +888,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
modify_time=local_path.stat().st_mtime if local_path.exists() else None,
|
||||
)
|
||||
|
||||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||||
def download(self, fileitem: _SchemaFileItem, path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
带实时进度显示的下载
|
||||
"""
|
||||
@@ -957,7 +958,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
def check(self) -> bool:
|
||||
return self.access_token is not None
|
||||
|
||||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||||
def delete(self, fileitem: _SchemaFileItem) -> bool:
|
||||
"""
|
||||
删除文件/目录
|
||||
"""
|
||||
@@ -969,7 +970,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||||
def rename(self, fileitem: _SchemaFileItem, name: str) -> bool:
|
||||
"""
|
||||
重命名文件/目录
|
||||
"""
|
||||
@@ -984,7 +985,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __get_info_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def __get_info_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
查询指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。
|
||||
接口业务码 20004(记录不存在)、430004(路径不存在)与 0 一样
|
||||
@@ -1004,7 +1005,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
if not data or not data.get("file_id"):
|
||||
# 115 对记录不存在和路径不存在返回不同业务码,两者都可确认目标不存在
|
||||
return None
|
||||
return schemas.FileItem(
|
||||
return _SchemaFileItem(
|
||||
storage=self.schema.value,
|
||||
fileid=str(data["file_id"]),
|
||||
path=path.as_posix() + ("/" if data["file_category"] == "0" else ""),
|
||||
@@ -1019,7 +1020,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
modify_time=data["utime"],
|
||||
)
|
||||
|
||||
def get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取指定路径的文件/目录项
|
||||
"""
|
||||
@@ -1029,7 +1030,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
logger.debug(f"【115】获取文件信息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_item_strict(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取指定路径的文件/目录项,无法确认状态时抛出 StorageQueryError。
|
||||
"""
|
||||
@@ -1040,14 +1041,14 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
except Exception as e:
|
||||
raise StorageQueryError(f"【115】查询文件信息失败: {path} - {e}") from e
|
||||
|
||||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||||
def get_folder(self, path: Path) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取指定路径的文件夹,如不存在则创建
|
||||
"""
|
||||
|
||||
def __find_dir(
|
||||
_fileitem: schemas.FileItem, _name: str
|
||||
) -> Optional[schemas.FileItem]:
|
||||
_fileitem: _SchemaFileItem, _name: str
|
||||
) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
查找下级目录中匹配名称的目录
|
||||
"""
|
||||
@@ -1063,7 +1064,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
if folder:
|
||||
return folder
|
||||
# 逐级查找和创建目录
|
||||
fileitem = schemas.FileItem(storage=self.schema.value, path="/")
|
||||
fileitem = _SchemaFileItem(storage=self.schema.value, path="/")
|
||||
for part in path.parts[1:]:
|
||||
dir_file = __find_dir(fileitem, part)
|
||||
if dir_file:
|
||||
@@ -1076,13 +1077,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
fileitem = dir_file
|
||||
return fileitem
|
||||
|
||||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||||
def detail(self, fileitem: _SchemaFileItem) -> Optional[_SchemaFileItem]:
|
||||
"""
|
||||
获取文件/目录详细信息
|
||||
"""
|
||||
return self.get_item(Path(fileitem.path))
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def copy(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
复制
|
||||
"""
|
||||
@@ -1115,7 +1116,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
return True
|
||||
return False
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
def move(self, fileitem: _SchemaFileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
移动
|
||||
"""
|
||||
@@ -1147,13 +1148,13 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
return True
|
||||
return False
|
||||
|
||||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def link(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||||
def softlink(self, fileitem: _SchemaFileItem, target_file: Path) -> bool:
|
||||
pass
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
def usage(self) -> Optional[_SchemaStorageUsage]:
|
||||
"""
|
||||
存储使用情况
|
||||
"""
|
||||
@@ -1162,7 +1163,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
if not resp:
|
||||
return None
|
||||
space = resp["rt_space_info"]
|
||||
return schemas.StorageUsage(
|
||||
return _SchemaStorageUsage(
|
||||
total=space["all_total"]["size"], available=space["all_remain"]["size"]
|
||||
)
|
||||
except NoCheckInException:
|
||||
|
||||
@@ -15,16 +15,14 @@ from app.application.directory import DirectoryHelper
|
||||
from app.application.messaging.message import TemplateHelper
|
||||
from app.runtime.log import logger
|
||||
from app.modules.filemanager.storages import StorageBase
|
||||
from app.schemas import (
|
||||
TransferInfo,
|
||||
TmdbEpisode,
|
||||
TransferDirectoryConf,
|
||||
FileItem,
|
||||
TransferInterceptEventData,
|
||||
TransferOverwriteCheckEventData,
|
||||
TransferRenameBuildEventData,
|
||||
TransferRenameEventData,
|
||||
)
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.workflow import FileItem
|
||||
from app.schemas.event import TransferInterceptEventData
|
||||
from app.schemas.event import TransferOverwriteCheckEventData
|
||||
from app.schemas.event import TransferRenameBuildEventData
|
||||
from app.schemas.event import TransferRenameEventData
|
||||
from app.schemas.exception import StorageQueryError
|
||||
from app.schemas.types import MediaType, ChainEventType
|
||||
from app.adapters.system.host import SystemUtils
|
||||
|
||||
Reference in New Issue
Block a user