Files
MoviePilot/app/monitor/poller.py
Aqr-K a2e70b443d 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>
2026-08-13 08:19:54 +08:00

285 lines
13 KiB
Python

import traceback
from pathlib import Path, PurePosixPath
from threading import Lock
from typing import Callable, Dict, List, Optional
from app.chain.storage import StorageChain
from app.log import logger
from app.monitor.dispatcher import TransferDispatcher
from app.monitor.snapshot import SnapshotStore
class RemotePoller:
"""
远程目录轮询监控:快照、比对并分发变化文件。
"""
# 同一存储连续异常达到该次数后推送告警
FAILURE_ALERT_THRESHOLD = 3
def __init__(self, store: SnapshotStore, dispatcher: TransferDispatcher,
alert_cb: Optional[Callable[[str, str], None]] = None):
"""
初始化远程轮询监控。
:param store: 快照存储
:param dispatcher: 整理分发器
:param alert_cb: 告警回调 (storage, message)
"""
self._store = store
self._dispatcher = dispatcher
self._alert_cb = alert_cb
# 快照锁按存储隔离,避免一个慢存储阻塞其他存储的轮询
self._locks: Dict[str, Lock] = {}
self._locks_guard = Lock()
# 各存储连续异常次数
self._failure_counts: Dict[str, int] = {}
def _get_lock(self, storage: str) -> Lock:
"""
获取指定存储的快照锁。
:param storage: 存储名称
:return: 快照锁
"""
with self._locks_guard:
return self._locks.setdefault(storage, Lock())
def _note_failure(self, storage: str, reason: str):
"""
记录一次轮询异常,连续异常达到阈值时推送告警。
:param storage: 存储名称
:param reason: 异常原因
"""
count = self._failure_counts.get(storage, 0) + 1
self._failure_counts[storage] = count
logger.warn(f"远程目录监控异常(连续第 {count} 次): {storage} - {reason}")
if count == self.FAILURE_ALERT_THRESHOLD and self._alert_cb:
self._alert_cb(storage,
f"远程目录监控连续 {count} 次异常: {storage}\n原因: {reason}\n将继续按周期重试")
def _note_success(self, storage: str):
"""
记录一次轮询成功,此前告警过时推送恢复消息。
:param storage: 存储名称
"""
if self._failure_counts.get(storage, 0) >= self.FAILURE_ALERT_THRESHOLD and self._alert_cb:
self._alert_cb(storage, f"远程目录监控已恢复: {storage}")
self._failure_counts[storage] = 0
@staticmethod
def _snapshot_for_path(snapshot: Dict[str, Dict], mon_path: Path) -> Dict[str, Dict]:
"""
提取指定监控目录范围内的快照。
:param snapshot: 完整存储快照
:param mon_path: 监控目录
:return: 目录范围内的快照
"""
root_path = PurePosixPath(mon_path.as_posix())
return {
file_path: file_info
for file_path, file_info in snapshot.items()
if PurePosixPath(file_path).is_relative_to(root_path)
}
def poll(self, storage: str, mon_paths: List[Path]) -> Optional[int]:
"""
执行一轮轮询监控。
:param storage: 存储名称
:param mon_paths: 监控路径列表
:return: 基线文件数量,本轮无有效结果时返回 None
"""
monitor_scope = ",".join(str(mon_path) for mon_path in mon_paths) or "未配置路径"
with self._get_lock(storage):
try:
# 加载上次快照数据,读取失败不能当作首次快照,否则会丢弃已有基线
old_snapshot_data, load_ok = self._store.load_checked(storage)
if not load_ok:
self._note_failure(storage, "读取快照基线失败,跳过本轮")
return None
old_snapshot = old_snapshot_data.get('snapshot', {}) if old_snapshot_data else {}
last_snapshot_time = old_snapshot_data.get('timestamp', 0) if old_snapshot_data else 0
is_first_snapshot = old_snapshot_data is None
path_snapshots = []
failed_paths = []
for mon_path in mon_paths:
logger.debug(f"开始对 {storage}:{mon_path} 进行快照...")
# 生成新快照(增量模式)
snapshot = StorageChain().snapshot_storage(
storage=storage,
path=mon_path,
last_snapshot_time=last_snapshot_time,
previous_snapshot=old_snapshot
)
if snapshot is None:
failed_paths.append(mon_path)
logger.warn(f"获取 {storage}:{mon_path} 快照失败")
continue
path_snapshots.append(snapshot)
logger.info(f"{storage}:{mon_path} 快照完成,发现 {len(snapshot)} 个文件")
if failed_paths and (is_first_snapshot or len(failed_paths) == len(mon_paths)):
# 首次基线必须完整建立;全部路径失败时本轮没有有效数据,均不落盘
self._note_failure(storage, f"快照失败:{','.join(str(path) for path in failed_paths)}")
return None
# 成功路径已在存储层完成增量对账;失败路径继续保留旧基线,避免临时故障丢失状态
current_snapshot = {}
for failed_path in failed_paths:
current_snapshot.update(self._snapshot_for_path(old_snapshot, failed_path))
for path_snapshot in path_snapshots:
current_snapshot.update(path_snapshot)
file_count = len(current_snapshot)
if not is_first_snapshot:
self._handle_changes(storage, old_snapshot, current_snapshot)
else:
logger.info(f"{storage} 首次快照完成,共 {file_count} 个文件")
logger.info("*** 首次快照仅建立基准,不会处理现有文件。后续监控将处理新增和修改的文件 ***")
# 保存合并后的基线。增量游标是整个存储共用的,若本轮有路径失败仍让
# 游标跟随成功路径前进,失败路径中时间落在新旧游标之间的变更会被
# 后续增量查询永久跳过,因此部分失败时把游标固定在旧值
pinned_time = last_snapshot_time if failed_paths else None
if not self._store.save(storage, current_snapshot, file_count, last_snapshot_time,
snapshot_time=pinned_time):
self._note_failure(storage, "保存快照基线失败")
return None
if failed_paths:
# 部分路径失败:成功路径已合并,失败路径保留旧基线与旧游标,下轮重试
self._note_failure(
storage,
f"部分路径快照失败: {','.join(str(path) for path in failed_paths)}"
)
else:
self._note_success(storage)
return file_count
except Exception as e:
logger.error(f"轮询监控 {storage}:{monitor_scope} 出现错误:{e}\n{traceback.format_exc()}")
self._note_failure(storage, str(e))
return None
def _handle_changes(self, storage: str, old_snapshot: dict, new_snapshot: dict):
"""
比对快照并把变化文件送入整理链。
:param storage: 存储名称
:param old_snapshot: 旧基线
:param new_snapshot: 本轮增量快照
"""
changes = SnapshotStore.compare(old_snapshot, new_snapshot)
added_files = [
file_path
for file_path in changes['added']
if self._dispatcher.is_transfer_candidate_path(Path(file_path))
]
modified_files = [
file_path
for file_path in changes['modified']
if self._dispatcher.is_transfer_candidate_path(Path(file_path))
]
# 处理新增文件
handled_added_count = 0
for new_file in added_files:
file_info = new_snapshot.get(new_file, {})
file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info
file_modify_time = file_info.get('modify_time') if isinstance(file_info, dict) else None
fileid = file_info.get('fileid') if isinstance(file_info, dict) else None
if self._dispatcher.handle_file(
storage=storage,
event_path=Path(new_file),
file_size=file_size,
file_modify_time=file_modify_time,
fileid=fileid,
):
handled_added_count += 1
# 处理修改文件
handled_modified_count = 0
for modified_file in modified_files:
file_info = new_snapshot.get(modified_file, {})
file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info
file_modify_time = file_info.get('modify_time') if isinstance(file_info, dict) else None
fileid = file_info.get('fileid') if isinstance(file_info, dict) else None
if self._dispatcher.handle_file(
storage=storage,
event_path=Path(modified_file),
file_size=file_size,
file_modify_time=file_modify_time,
fileid=fileid,
):
handled_modified_count += 1
if handled_added_count or handled_modified_count:
logger.info(f"{storage} 发现 {handled_added_count} 个新增文件,{handled_modified_count} 个修改文件")
else:
logger.debug(f"{storage} 无文件变化")
def force_full_scan(self, storage: str, mon_path: Path) -> bool:
"""
强制全量扫描并处理所有文件(包括已存在的文件)。
:param storage: 存储名称
:param mon_path: 监控路径
:return: 是否成功
"""
try:
logger.info(f"开始强制全量扫描: {storage}:{mon_path}")
# 生成快照
new_snapshot = StorageChain().snapshot_storage(
storage=storage,
path=mon_path,
last_snapshot_time=0 # 全量扫描,不使用增量
)
if new_snapshot is None:
logger.warn(f"获取 {storage}:{mon_path} 快照失败")
return False
file_count = len(new_snapshot)
logger.info(f"{storage}:{mon_path} 全量扫描完成,发现 {file_count} 个文件")
# 处理所有文件
processed_count = 0
for file_path, file_info in new_snapshot.items():
try:
if not self._dispatcher.is_transfer_candidate_path(Path(file_path)):
continue
file_size = file_info.get('size', 0) if isinstance(file_info, dict) else file_info
file_modify_time = file_info.get('modify_time') if isinstance(file_info, dict) else None
fileid = file_info.get('fileid') if isinstance(file_info, dict) else None
if self._dispatcher.handle_file(
storage=storage,
event_path=Path(file_path),
file_size=file_size,
file_modify_time=file_modify_time,
fileid=fileid,
):
processed_count += 1
except Exception as e:
logger.error(f"处理文件 {file_path} 失败: {e}")
continue
logger.info(f"{storage}:{mon_path} 全量扫描完成,共处理 {processed_count}/{file_count} 个文件")
# 全量扫描只覆盖单个路径,必须与已有基线合并后落盘。读取失败时无法
# 区分「基线不存在」与「读取异常」,此时落盘会抹掉同存储下其他监控
# 路径的基线,因此直接判定失败
old_snapshot_data, load_ok = self._store.load_checked(storage)
if not load_ok:
logger.error(f"读取快照基线失败,已跳过落盘以避免覆盖其他监控路径: {storage}:{mon_path}")
return False
old_snapshot = old_snapshot_data.get('snapshot', {}) if old_snapshot_data else {}
current_snapshot = {**old_snapshot, **new_snapshot}
if not self._store.save(storage, current_snapshot, len(current_snapshot)):
logger.error(f"保存快照基线失败,全量扫描未完成: {storage}:{mon_path}")
return False
return True
except Exception as e:
logger.error(f"强制全量扫描失败: {storage}:{mon_path} - {e}")
return False