mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
feat(transfer): control mounted directory cleanup
This commit is contained in:
+46
-2
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ class SystemConfigKey(Enum):
|
|||||||
NotificationSwitchs = "NotificationSwitchs"
|
NotificationSwitchs = "NotificationSwitchs"
|
||||||
# 目录配置
|
# 目录配置
|
||||||
Directories = "Directories"
|
Directories = "Directories"
|
||||||
|
# 挂载型本地盘是否删除空目录
|
||||||
|
MountedLocalDiskDeleteEmptyDirs = "MountedLocalDiskDeleteEmptyDirs"
|
||||||
# 存储配置
|
# 存储配置
|
||||||
Storages = "Storages"
|
Storages = "Storages"
|
||||||
# 搜索站点范围
|
# 搜索站点范围
|
||||||
|
|||||||
+13
-3
@@ -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('\\\\')
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user