From c9767415740dba16bb8488ab6e622a56e9d1e715 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 30 Jul 2026 17:45:13 +0800 Subject: [PATCH] feat(transfer): control mounted directory cleanup --- app/chain/transfer.py | 48 ++++++- app/schemas/types.py | 2 + app/utils/system.py | 16 ++- tests/test_transfer_mounted_disk_cleanup.py | 146 ++++++++++++++++++++ 4 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 tests/test_transfer_mounted_disk_cleanup.py diff --git a/app/chain/transfer.py b/app/chain/transfer.py index b6cb9de20..c2a6849c1 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -928,6 +928,36 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): 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( self, task: TransferTask, transferinfo: TransferInfo, / ) -> Tuple[bool, str]: @@ -1189,10 +1219,16 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): tasks = self.jobview.success_tasks( task.mediainfo, task.meta.begin_season ) + system_config_oper = SystemConfigOper() # 获取整理屏蔽词 - transfer_exclude_words = SystemConfigOper().get( + transfer_exclude_words = system_config_oper.get( 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() for t in tasks: if t.download_hash and t.download_hash not in processed_hashes: @@ -1209,7 +1245,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): logger.info( 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) diff --git a/app/schemas/types.py b/app/schemas/types.py index 931db073e..d660d3931 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -215,6 +215,8 @@ class SystemConfigKey(Enum): NotificationSwitchs = "NotificationSwitchs" # 目录配置 Directories = "Directories" + # 挂载型本地盘是否删除空目录 + MountedLocalDiskDeleteEmptyDirs = "MountedLocalDiskDeleteEmptyDirs" # 存储配置 Storages = "Storages" # 搜索站点范围 diff --git a/app/utils/system.py b/app/utils/system.py index dd581d2fa..c07bc4f9a 100644 --- a/app/utils/system.py +++ b/app/utils/system.py @@ -828,10 +828,13 @@ class SystemUtils: return False @staticmethod - def is_network_filesystem(directory: Path) -> bool: + def is_network_filesystem( + directory: Path, include_local_fuse: bool = False + ) -> bool: """ 检测是否为网络文件系统 :param directory: 目录路径 + :param include_local_fuse: 是否将本地 FUSE 挂载视为挂载文件系统 :return: 是否为网络文件系统 """ try: @@ -849,7 +852,10 @@ class SystemUtils: "fuseblk", # 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 network_fs = ['nfs', 'cifs', 'smbfs', 'fuse', 'sshfs', 'ftpfs'] return any(fs in output for fs in network_fs) @@ -859,7 +865,11 @@ class SystemUtils: capture_output=True, text=True, timeout=5) if result.returncode == 0: 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': # Windows 检查网络驱动器 return str(directory).startswith('\\\\') diff --git a/tests/test_transfer_mounted_disk_cleanup.py b/tests/test_transfer_mounted_disk_cleanup.py new file mode 100644 index 000000000..56c393874 --- /dev/null +++ b/tests/test_transfer_mounted_disk_cleanup.py @@ -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