fix(monitor,transfer): 修复 FUSE 挂载无响应导致的监控冻死、整理链锁死与漏件 (#6276)

* wip(v3): 移植监控与整理韧性修复到 v3 基线

包含:监控看门狗隔离/挂载探测、整理队列持久化、文件系统子进程代理、
写入原子化。迁移重挂到 v3 链 8a4c7e1d2f90 -> 7f5c1d2e3a4b -> e3d9f4b7c806。
tmdb 相关测试尚未通过,待定位。

* fix(v3): 修正移植引入的 16 项测试失败

- poller.py:合并时我方保留的行仍用旧变量名 merged_snapshot,而 v3 已统一
  改名为 current_snapshot,导致 NameError 被外层 except 吞掉、快照从未保存
- smb.py:采纳 f-string 拆分写法,恢复 Python 3.11 可解析
- dispatcher 测试:历史查重由 _should_skip_by_history 统一承担,mock 点随之调整
- tmdb 缓存测试:补充 v3 新增的 media_source/media_id 字段
- tmdb 重试测试:为 fake 补充 match_multi/async_match_multi

尚余 3 项与 v3 识别流程的连接失败处理有关,待单独判断。

* fix(v3): 测试适配 v3 的 media_source/media_id 重构

v3 将媒体标识从 tmdbid 统一重构为 media_source + media_id,recognize_media
的 tmdbid 参数已被 **kwargs 静默吞掉——传了也不生效,流程会误降级到名称搜索。
tmdb 重试用例改用新参数后恢复正确路径。

同时修正 fake 的 match_multi 语义:真实实现(tmdbapi.match_multi)吞掉所有
异常并返回 None,连接失败与「未找到」在该路径上本就不可区分,fake 需保持一致。

至此移植引入的 19 项失败全部清零。

---------

Co-authored-by: Aqr-K <Aqr-K@users.noreply.github.com>
This commit is contained in:
Aqr-K
2026-08-13 08:19:54 +08:00
committed by GitHub
co-authored by Aqr-K
parent 4d11a38496
commit a2e70b443d
62 changed files with 7889 additions and 360 deletions
+8 -2
View File
@@ -8,6 +8,7 @@ from app import schemas
from app.helper.progress import ProgressHelper
from app.helper.storage import StorageHelper
from app.log import logger
from app.schemas.exception import StorageQueryError
from app.utils.crypto import HashUtils
@@ -179,9 +180,13 @@ class StorageBase(metaclass=ABCMeta):
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
默认实现不区分「不存在」与「查询失败」,由具体存储按需覆写。
默认保守失败:未覆写的存储无法区分「不存在」与「查询失败」,沿用
get_item() 会让 overwrite_mode=size 的覆盖保护在查询失败时被绕过,
把「无法确认」当成「目标不存在」而放行覆盖。具体存储必须先实现
「确认不存在」的判定,再覆写本方法。
"""
return self.get_item(path)
raise StorageQueryError(f"存储 {self.schema} 未实现严格查询,无法确认目标状态: {path}")
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
@@ -337,6 +342,7 @@ class StorageBase(metaclass=ABCMeta):
files_info[_fileitm.path] = {
'size': _fileitm.size or 0,
'modify_time': getattr(_fileitm, 'modify_time', 0),
'fileid': getattr(_fileitm, 'fileid', None),
'type': _fileitm.type
}
+49 -9
View File
@@ -10,7 +10,7 @@ from app.core.cache import cached
from app.core.config import settings, global_vars
from app.log import logger
from app.modules.filemanager.storages import StorageBase, transfer_process
from app.schemas.exception import OperationInterrupted
from app.schemas.exception import OperationInterrupted, StorageQueryError
from app.schemas.types import StorageSchema
from app.utils.http import RequestUtils
from app.utils.singleton import WeakSingleton
@@ -471,18 +471,58 @@ class Alist(StorageBase, metaclass=WeakSingleton):
)
return None
return self.__build_fileitem(path, result["data"])
def __build_fileitem(self, path: Path, data: dict) -> schemas.FileItem:
"""
根据接口返回数据构建文件项。
:param path: 文件路径
:param data: 接口返回的 data 字段
:return: 文件项
"""
return schemas.FileItem(
storage=self.schema.value,
type="dir" if result["data"]["is_dir"] else "file",
path=path.as_posix() + ("/" if result["data"]["is_dir"] else ""),
name=result["data"]["name"],
basename=Path(result["data"]["name"]).stem,
extension=Path(result["data"]["name"]).suffix[1:],
size=result["data"]["size"],
modify_time=self.__parse_timestamp(result["data"]["modified"]),
thumbnail=result["data"]["thumb"],
type="dir" if data["is_dir"] else "file",
path=path.as_posix() + ("/" if data["is_dir"] else ""),
name=data["name"],
basename=Path(data["name"]).stem,
extension=Path(data["name"]).suffix[1:],
size=data["size"],
modify_time=self.__parse_timestamp(data["modified"]),
thumbnail=data["thumb"],
)
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
只有接口明确回报「对象不存在」才是确定结果,连接失败、HTTP 异常与其他
业务错误都无法确认目标状态,必须保守失败以免覆盖保护被绕过。
"""
resp = RequestUtils(headers=self.__get_header_with_token()).post_res(
self.__get_api_url("/api/fs/get"),
json={
"path": path.as_posix(),
"password": "",
"page": 1,
"per_page": 0,
"refresh": False,
},
)
if resp is None:
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败,无法连接服务")
if resp.status_code != 200:
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败,状态码:{resp.status_code}")
try:
result = resp.json()
except Exception as err:
raise StorageQueryError(f"【OpenList】解析查询结果失败: {path} - {err}") from err
if result.get("code") != 200:
message = str(result.get("message") or "")
if "not found" in message.lower() or "not exist" in message.lower():
return None
raise StorageQueryError(f"【OpenList】查询文件 {path} 失败:{message}")
return self.__build_fileitem(path, result["data"])
def get_parent(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取父目录
+145 -46
View File
@@ -1,5 +1,6 @@
import os
import shutil
import time
from pathlib import Path
from typing import Optional, List
@@ -7,6 +8,7 @@ from app import schemas
from app.core.config import global_vars, settings
from app.helper.directory import DirectoryHelper
from app.log import logger
from app.modules.filemanager.fsproxy import fsproxy
from app.modules.filemanager.storages import StorageBase, transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema
@@ -47,6 +49,10 @@ class LocalStorage(StorageBase):
"""
获取文件项
"""
# 走代理读取:挂载挂死时这一步会在超时后抛 OSError,而不是永久悬挂线程。
# 顺带只 stat 一次——原先 size 与 modify_time 各 stat 一次,在网络挂载上
# 等于把这个热点路径的开销翻倍
info = fsproxy.stat(path)
return schemas.FileItem(
storage=self.schema.value,
type="file",
@@ -54,8 +60,8 @@ class LocalStorage(StorageBase):
name=path.name,
basename=path.stem,
extension=path.suffix[1:],
size=path.stat().st_size,
modify_time=path.stat().st_mtime,
size=info["size"],
modify_time=info["mtime"],
)
def __get_diritem(self, path: Path) -> schemas.FileItem:
@@ -68,7 +74,7 @@ class LocalStorage(StorageBase):
path=path.as_posix() + "/",
name=path.name,
basename=path.stem,
modify_time=path.stat().st_mtime,
modify_time=fsproxy.stat(path)["mtime"],
)
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
@@ -100,12 +106,14 @@ class LocalStorage(StorageBase):
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
try:
info = fsproxy.stat(path_obj)
except (FileNotFoundError, NotADirectoryError):
logger.warn(f"【本地】目录不存在:{path}")
return []
# 如果是文件
if path_obj.is_file():
if info["is_file"]:
ret_items.append(self.__get_fileitem(path_obj))
return ret_items
@@ -143,9 +151,11 @@ class LocalStorage(StorageBase):
"""
获取文件或目录,不存在返回None
"""
if not path.exists():
try:
info = fsproxy.stat(path)
except (FileNotFoundError, NotADirectoryError):
return None
if path.is_file():
if info["is_file"]:
return self.__get_fileitem(path)
return self.__get_diritem(path)
@@ -154,9 +164,11 @@ class LocalStorage(StorageBase):
获取文件或目录,无法确认状态时抛出 StorageQueryError。
Path.exists() 会把部分 errno(如 EBADF/ELOOP)归入「不存在」,
网络/FUSE 挂载抖动时会误判,这里用 stat 显式区分。
挂载完全无响应时代理会超时并抛 FileSystemTimeoutOSError 子类),
同样落入下面的分支,转化成调用方能处理的查询失败。
"""
try:
path.stat()
fsproxy.stat(path)
except (FileNotFoundError, NotADirectoryError):
return None
except OSError as e:
@@ -182,13 +194,18 @@ class LocalStorage(StorageBase):
if not fileitem.path:
return False
path_obj = Path(fileitem.path)
if not path_obj.exists():
return True
try:
if path_obj.is_file():
path_obj.unlink()
info = fsproxy.stat(path_obj)
except (FileNotFoundError, NotADirectoryError):
return True
except OSError as e:
logger.error(f"【本地】读取待删除文件状态失败:{e}")
return False
try:
if info["is_file"]:
fsproxy.unlink(path_obj)
else:
shutil.rmtree(path_obj, ignore_errors=True)
fsproxy.rmtree(path_obj)
except Exception as e:
logger.error(f"【本地】删除文件失败:{e}")
return False
@@ -199,10 +216,10 @@ class LocalStorage(StorageBase):
重命名文件
"""
path_obj = Path(fileitem.path)
if not path_obj.exists():
return False
try:
path_obj.rename(path_obj.parent / name)
fsproxy.rename(path_obj, path_obj.parent / name)
except (FileNotFoundError, NotADirectoryError):
return False
except Exception as e:
logger.error(f"【本地】重命名文件失败:{e}")
return False
@@ -214,6 +231,93 @@ class LocalStorage(StorageBase):
"""
return Path(fileitem.path)
# 写入中的临时文件后缀。点开头(隐藏)+ 专用后缀双重保证:即使进程被
# SIGKILL、临时文件残留,媒体库也不会把半成品当成媒体收录
PARTIAL_SUFFIX = ".mp-partial"
# 临时文件被认定为中断残留的时长(秒)。正常失败路径会自行清理,只有被
# 强杀才会残留;阈值取得宽松,避免误删仍在写入的大文件
PARTIAL_STALE_SECONDS = 24 * 3600
@classmethod
def _partial_path(cls, dest: Path) -> Path:
"""
生成写入中的临时文件路径。
必须与目标同目录:os.replace 只有在同一文件系统内才是原子的,放到
/tmp 之类的地方会退化成一次完整拷贝,原子性荡然无存。带 PID 是为了
避免多进程同时写同一目标时互相踩踏。
:param dest: 目标文件路径
:return: 临时文件路径
"""
return dest.parent / f".{dest.name}.{os.getpid()}{cls.PARTIAL_SUFFIX}"
@classmethod
def _cleanup_stale_partials(cls, directory: Path):
"""
清理目录下中断残留的临时文件。
只做局部清理而不是全库扫描:在网络挂载上遍历整个媒体库代价不可接受,
而残留只可能出现在曾经写入过的目录里,因此每次写入时顺带清理即可。
本方法是尽力而为的旁路操作,任何失败都不影响主流程。
:param directory: 目标目录
"""
try:
threshold = time.time() - cls.PARTIAL_STALE_SECONDS
for item in directory.glob(f"*{cls.PARTIAL_SUFFIX}"):
try:
if item.stat().st_mtime < threshold:
item.unlink()
logger.info(f"【本地】已清理中断残留的临时文件:{item}")
except OSError:
continue
except Exception as err:
logger.debug(f"【本地】清理临时文件失败:{directory} - {err}")
def _write_atomically(self, src: Path, dest: Path) -> bool:
"""
以「写临时名 → os.replace」的方式把源文件内容落到目标。
直接写目标路径的话,进程被杀(OOM、重启、宿主断电、SIGKILL)会在媒体库
里留下一个**叫最终文件名的半截文件**:媒体库会把它扫进去,后续的
「目标已存在」判断也会把它当成完成品。os.replace 在同目录内由内核保证
原子性,因此目标要么完整存在,要么根本不存在。
:param src: 源文件路径
:param dest: 目标文件路径
:return: 是否成功
"""
self._cleanup_stale_partials(dest.parent)
partial = self._partial_path(dest)
# 进度只在需要展示时才回调 UI,但代理内部始终按固定间隔上报——那是判定
# 「传输是否还在推进」的心跳,不能因为不展示进度就关掉
progress_callback = (
transfer_process(src.as_posix())
if self.__should_show_progress(src, dest) else None
)
try:
copied = fsproxy.copy(
src, partial,
progress_cb=progress_callback,
cancel_cb=lambda: global_vars.is_transfer_stopped(src.as_posix()),
chunk_size=self.chunk_size,
)
if not copied:
logger.info(f"【本地】{src} 复制未完成")
return False
os.replace(partial, dest)
return True
except Exception as err:
logger.error(f"【本地】复制文件失败:{err}")
return False
finally:
if progress_callback:
progress_callback(100)
# 失败路径留下的临时文件就地清掉;成功时 replace 已经把它移走
try:
if partial.exists():
partial.unlink()
except OSError:
pass
@staticmethod
def _copy_with_target_permissions(src: Path, dest: Path) -> Path:
"""
@@ -276,12 +380,13 @@ class LocalStorage(StorageBase):
try:
dir_path = Path(fileitem.path)
target_path = dir_path / (new_name or path.name)
if self._copy_with_progress(path, target_path):
# 先原子地把内容落到目标,确认完整之后才删源
if self._write_atomically(path, target_path):
# 上传删除源文件
path.unlink()
return self.get_item(target_path)
except Exception as err:
logger.error(f"【本地】移动文件失败:{err}")
logger.error(f"【本地】上传文件失败:{err}")
return None
@staticmethod
@@ -304,18 +409,7 @@ class LocalStorage(StorageBase):
"""
复制文件(带进度)
"""
try:
src = Path(fileitem.path)
dest = path / new_name
if self.__should_show_progress(src, dest):
if self._copy_with_progress(src, dest):
return True
else:
self._copy_with_target_permissions(src, dest)
return True
except Exception as err:
logger.error(f"【本地】复制文件失败:{err}")
return False
return self._write_atomically(Path(fileitem.path), path / new_name)
def move(
self,
@@ -326,23 +420,28 @@ class LocalStorage(StorageBase):
"""
移动文件(带进度)
"""
src = Path(fileitem.path)
dest = path / new_name
if src == dest:
# 目标和源文件相同,直接返回成功,不做任何操作
return True
try:
src = Path(fileitem.path)
dest = path / new_name
if src == dest:
# 目标和源文件相同,直接返回成功,不做任何操作
return True
if self.__should_show_progress(src, dest):
if self._copy_with_progress(src, dest):
# 复制成功删除源文件
src.unlink()
return True
else:
shutil.move(src, dest, copy_function=self._copy_with_target_permissions)
return True
except Exception as err:
logger.error(f"【本地】移动文件失败:{err}")
return False
# 同一文件系统内 rename 是原子操作:中断后要么完全成功、要么完全
# 没发生,既不需要临时文件也不会留下半成品。直接尝试而不预先比较
# st_dev,省掉挂载上的两次 stat——跨设备会以 EXDEV 失败并落到下面
os.replace(src, dest)
return True
except OSError as err:
logger.debug(f"【本地】直接移动未成功,降级为复制:{src} -> {dest} - {err}")
# 跨文件系统:先原子地把内容落到目标,确认完整之后才删源。
# 顺序不能反——先删源再失败就是永久丢件
if not self._write_atomically(src, dest):
return False
try:
src.unlink()
except OSError as err:
logger.warn(f"【本地】移动已完成但删除源文件失败:{src} - {err}")
return True
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
"""
@@ -10,6 +10,7 @@ from app import schemas
from app.core.config import settings
from app.log import logger
from app.modules.filemanager.storages import StorageBase, transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
@@ -299,6 +300,38 @@ class Rclone(StorageBase):
logger.debug(f"【rclone】获取文件项失败:{err}")
return None
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
rclone 用退出码 3/4 表示目录/文件不存在,其余非零退出无法区分
「不存在」与「查询失败」,必须保守失败以免覆盖保护被绕过。
"""
try:
ret = subprocess.run(
[
'rclone', 'lsjson',
f'MP:{path.parent}'
],
capture_output=True,
startupinfo=self.__get_hidden_shell()
)
except Exception as err:
raise StorageQueryError(f"【rclone】查询文件项失败: {path} - {err}") from err
if ret.returncode in (3, 4):
# 目录或文件不存在,是确定结果
return None
if ret.returncode != 0:
errmsg = (ret.stderr or b"").decode(errors="ignore").strip()
raise StorageQueryError(f"【rclone】查询文件项失败: {path} - {errmsg}")
try:
items = json.loads(ret.stdout)
except Exception as err:
raise StorageQueryError(f"【rclone】解析查询结果失败: {path} - {err}") from err
for item in items:
if item.get("Name") == path.name:
return self.__get_rcloneitem(item, parent=str(path.parent) + "/")
return None
def delete(self, fileitem: schemas.FileItem) -> bool:
"""
删除文件
+37 -1
View File
@@ -1,3 +1,4 @@
import errno
import threading
import time
from pathlib import Path
@@ -16,6 +17,7 @@ from app.core.config import settings, global_vars
from app.log import logger
from app.modules.filemanager import StorageBase
from app.modules.filemanager.storages import transfer_process
from app.schemas.exception import StorageQueryError
from app.schemas.types import StorageSchema
from app.utils.singleton import WeakSingleton
@@ -163,7 +165,8 @@ class SMB(StorageBase, metaclass=WeakSingleton):
# 构建完整的SMB路径
if path_str:
return f"{self._server_path}\\{path_str.replace('/', '\\')}"
normalized_path = path_str.replace("/", "\\")
return f"{self._server_path}\\{normalized_path}"
else:
return self._server_path
@@ -379,6 +382,39 @@ class SMB(StorageBase, metaclass=WeakSingleton):
logger.debug(f"【SMB】获取文件项失败: {e}")
return None
def get_item_strict(self, path: Path) -> Optional[schemas.FileItem]:
"""
获取文件或目录,确认不存在返回None;无法确认状态时抛出 StorageQueryError。
只有 ENOENT/ENOTDIR 才是「确认不存在」,连接中断、认证失败等都无法确认
目标状态,必须保守失败以免覆盖保护被绕过。
"""
try:
self._check_connection()
# 处理根目录
if str(path) == "/":
return schemas.FileItem(
storage=self.schema.value,
type="dir",
path="/",
name="",
basename="",
modify_time=int(time.time()),
)
smb_path = self._normalize_path(str(path).rstrip("/"))
try:
stat_result = smbclient.stat(smb_path)
except OSError as err:
if err.errno in (errno.ENOENT, errno.ENOTDIR):
return None
raise StorageQueryError(f"【SMB】查询文件项失败: {path} - {err}") from err
return self._create_fileitem(stat_result, smb_path, Path(path).name)
except StorageQueryError:
raise
except Exception as e:
raise StorageQueryError(f"【SMB】查询文件项失败: {path} - {e}") from e
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取文件详情