mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
获取父目录
|
||||
|
||||
@@ -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 显式区分。
|
||||
挂载完全无响应时代理会超时并抛 FileSystemTimeout(OSError 子类),
|
||||
同样落入下面的分支,转化成调用方能处理的查询失败。
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
删除文件
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
获取文件详情
|
||||
|
||||
Reference in New Issue
Block a user