refactor: 推进后端分层架构治理

This commit is contained in:
jxxghp
2026-08-18 00:29:14 +08:00
parent 5d0bacabd5
commit 5128ae9e1e
363 changed files with 34179 additions and 6019 deletions
+36 -723
View File
@@ -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",
]
+737
View File
@@ -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
+25 -23
View File
@@ -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):
"""
递归获取文件信息
"""
+29 -28
View File
@@ -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:
+34 -33
View File
@@ -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]:
"""
存储使用情况
"""
+24 -23
View File
@@ -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
)
+25 -24
View File
@@ -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")
)
+27 -26
View File
@@ -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,
)
+30 -29
View File
@@ -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:
+8 -10
View File
@@ -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