mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 10:14:36 +08:00
* 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>
285 lines
13 KiB
Python
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
|