feat(transfer): control mounted directory cleanup

This commit is contained in:
jxxghp
2026-07-30 17:45:13 +08:00
parent 04facef64d
commit c976741574
4 changed files with 207 additions and 5 deletions
+46 -2
View File
@@ -928,6 +928,36 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or "/@eaDir" in normalized_path or "/@eaDir" in normalized_path
) )
@staticmethod
def __should_delete_empty_source_directories(
task: TransferTask,
delete_mounted_local_disk_empty_dirs: bool,
mounted_filesystem_cache: Dict[Path, bool],
) -> bool:
"""
判断移动整理后是否应删除源空目录。
仅在关闭挂载盘空目录清理且源存储为本地时检测文件系统,
避免默认流程产生额外系统调用。
"""
if delete_mounted_local_disk_empty_dirs:
return True
if task.fileitem.storage != "local":
return True
source_directory = (
Path(task.target_directory.download_path)
if task.target_directory and task.target_directory.download_path
else Path(task.fileitem.path).parent
)
if source_directory not in mounted_filesystem_cache:
mounted_filesystem_cache[source_directory] = (
SystemUtils.is_network_filesystem(
source_directory, include_local_fuse=True
)
)
return not mounted_filesystem_cache[source_directory]
def __default_callback( def __default_callback(
self, task: TransferTask, transferinfo: TransferInfo, / self, task: TransferTask, transferinfo: TransferInfo, /
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
@@ -1189,10 +1219,16 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
tasks = self.jobview.success_tasks( tasks = self.jobview.success_tasks(
task.mediainfo, task.meta.begin_season task.mediainfo, task.meta.begin_season
) )
system_config_oper = SystemConfigOper()
# 获取整理屏蔽词 # 获取整理屏蔽词
transfer_exclude_words = SystemConfigOper().get( transfer_exclude_words = system_config_oper.get(
SystemConfigKey.TransferExcludeWords SystemConfigKey.TransferExcludeWords
) )
# 挂载盘空目录清理默认开启
delete_mounted_local_disk_empty_dirs = system_config_oper.get(
SystemConfigKey.MountedLocalDiskDeleteEmptyDirs
) is not False
mounted_filesystem_cache: Dict[Path, bool] = {}
processed_hashes = set() processed_hashes = set()
for t in tasks: for t in tasks:
if t.download_hash and t.download_hash not in processed_hashes: if t.download_hash and t.download_hash not in processed_hashes:
@@ -1209,7 +1245,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info( logger.info(
f"移动模式删除种子成功:{t.download_hash}" f"移动模式删除种子成功:{t.download_hash}"
) )
if not t.download_hash and t.fileitem: if (
not t.download_hash
and t.fileitem
and self.__should_delete_empty_source_directories(
t,
delete_mounted_local_disk_empty_dirs,
mounted_filesystem_cache,
)
):
# 删除剩余空目录 # 删除剩余空目录
StorageChain().delete_media_file(t.fileitem, delete_self=False) StorageChain().delete_media_file(t.fileitem, delete_self=False)
+2
View File
@@ -215,6 +215,8 @@ class SystemConfigKey(Enum):
NotificationSwitchs = "NotificationSwitchs" NotificationSwitchs = "NotificationSwitchs"
# 目录配置 # 目录配置
Directories = "Directories" Directories = "Directories"
# 挂载型本地盘是否删除空目录
MountedLocalDiskDeleteEmptyDirs = "MountedLocalDiskDeleteEmptyDirs"
# 存储配置 # 存储配置
Storages = "Storages" Storages = "Storages"
# 搜索站点范围 # 搜索站点范围
+13 -3
View File
@@ -828,10 +828,13 @@ class SystemUtils:
return False return False
@staticmethod @staticmethod
def is_network_filesystem(directory: Path) -> bool: def is_network_filesystem(
directory: Path, include_local_fuse: bool = False
) -> bool:
""" """
检测是否为网络文件系统 检测是否为网络文件系统
:param directory: 目录路径 :param directory: 目录路径
:param include_local_fuse: 是否将本地 FUSE 挂载视为挂载文件系统
:return: 是否为网络文件系统 :return: 是否为网络文件系统
""" """
try: try:
@@ -849,7 +852,10 @@ class SystemUtils:
"fuseblk", "fuseblk",
# TBD # TBD
] ]
if any(fs in output for fs in local_fs): if (
not include_local_fuse
and any(fs in output for fs in local_fs)
):
return False return False
network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs'] network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs']
return any(fs in output for fs in network_fs) return any(fs in output for fs in network_fs)
@@ -859,7 +865,11 @@ class SystemUtils:
capture_output=True, text=True, timeout=5) capture_output=True, text=True, timeout=5)
if result.returncode == 0: if result.returncode == 0:
output = result.stdout.lower() output = result.stdout.lower()
return 'nfs' in output or 'smbfs' in output return (
'nfs' in output
or 'smbfs' in output
or (include_local_fuse and 'fuse' in output)
)
elif system == 'Windows': elif system == 'Windows':
# Windows 检查网络驱动器 # Windows 检查网络驱动器
return str(directory).startswith('\\\\') return str(directory).startswith('\\\\')
+146
View File
@@ -0,0 +1,146 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from app.chain.transfer import TransferChain
from app.schemas import FileItem, TransferDirectoryConf, TransferTask
from app.utils.system import SystemUtils
def _make_task(
storage: str = "local",
download_path: str = "/mnt/clouddrive/downloads",
) -> TransferTask:
return TransferTask(
fileitem=FileItem(
storage=storage,
path=f"{download_path}/Test.Show.S01E01.mkv",
type="file",
name="Test.Show.S01E01.mkv",
),
target_directory=TransferDirectoryConf(
storage=storage,
download_path=download_path,
),
)
def test_enabled_cleanup_skips_filesystem_detection():
"""
开关开启时应保持旧行为,且不产生额外文件系统检测。
"""
with patch(
"app.chain.transfer.SystemUtils.is_network_filesystem"
) as is_network_filesystem:
should_delete = (
TransferChain._TransferChain__should_delete_empty_source_directories(
_make_task(),
True,
{},
)
)
assert should_delete is True
is_network_filesystem.assert_not_called()
def test_disabled_cleanup_keeps_mounted_local_source_directories():
"""
开关关闭时应保留网络或 FUSE 挂载的本地源目录。
"""
with patch(
"app.chain.transfer.SystemUtils.is_network_filesystem",
return_value=True,
) as is_network_filesystem:
should_delete = (
TransferChain._TransferChain__should_delete_empty_source_directories(
_make_task(),
False,
{},
)
)
assert should_delete is False
is_network_filesystem.assert_called_once_with(
Path("/mnt/clouddrive/downloads"), include_local_fuse=True
)
def test_disabled_cleanup_still_deletes_ordinary_local_source_directories():
"""
开关关闭时普通本地文件系统仍应删除空目录。
"""
with patch(
"app.chain.transfer.SystemUtils.is_network_filesystem",
return_value=False,
):
should_delete = (
TransferChain._TransferChain__should_delete_empty_source_directories(
_make_task(download_path="/downloads"),
False,
{},
)
)
assert should_delete is True
def test_disabled_cleanup_does_not_change_remote_storage_cleanup():
"""
开关关闭时非本地存储仍应执行原有空目录清理。
"""
with patch(
"app.chain.transfer.SystemUtils.is_network_filesystem"
) as is_network_filesystem:
should_delete = (
TransferChain._TransferChain__should_delete_empty_source_directories(
_make_task(storage="alist", download_path="/downloads"),
False,
{},
)
)
assert should_delete is True
is_network_filesystem.assert_not_called()
def test_mounted_filesystem_detection_is_cached_by_source_directory():
"""
同一源根目录的批量任务应只检测一次文件系统。
"""
mounted_filesystem_cache = {}
with patch(
"app.chain.transfer.SystemUtils.is_network_filesystem",
return_value=True,
) as is_network_filesystem:
for _ in range(2):
should_delete = (
TransferChain._TransferChain__should_delete_empty_source_directories(
_make_task(),
False,
mounted_filesystem_cache,
)
)
assert should_delete is False
is_network_filesystem.assert_called_once_with(
Path("/mnt/clouddrive/downloads"), include_local_fuse=True
)
def test_cleanup_detection_includes_local_fuse_mounts():
"""
空目录清理场景应将原本排除的本地 FUSE 文件系统视为挂载盘。
"""
df_result = SimpleNamespace(
returncode=0,
stdout="Filesystem Type 1K-blocks Used Available Use% Mounted on\n"
"shfs fuse.shfs 1 1 1 1% /mnt/user\n",
)
with patch("app.utils.system.platform.system", return_value="Linux"), patch(
"app.utils.system.subprocess.run", return_value=df_result
):
assert SystemUtils.is_network_filesystem(Path("/mnt/user")) is False
assert SystemUtils.is_network_filesystem(
Path("/mnt/user"), include_local_fuse=True
) is True