From bde74da51d69272df8fcadb62d7fc3a7f12c1856 Mon Sep 17 00:00:00 2001 From: kaestnerheisser207-web <310035214+kaestnerheisser207-web@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:41:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(monitor):=20=E9=81=BF=E5=85=8D=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=9C=AA=E7=A8=B3=E5=AE=9A=E5=91=8A=E8=AD=A6=20(#7215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/monitor/watcher.py | 19 +++++ tests/test_monitor_watchfiles.py | 125 +++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/app/monitor/watcher.py b/app/monitor/watcher.py index 820c478a0..23a130794 100644 --- a/app/monitor/watcher.py +++ b/app/monitor/watcher.py @@ -322,6 +322,9 @@ class LocalDirectoryWatcher: if change_type not in self._HANDLE_CHANGES: continue event_path = Path(path_str) + # 下载临时文件会持续写入,须在稳定性排队前排除,避免正常下载触发超时告警。 + if self._is_download_temporary_file(event_path): + continue event = self._build_event(change_type=change_type, event_path=event_path) if not event: # 「文件已消失」与「挂载抖动瞬时不可见」在此刻无法区分,静默丢弃就是 @@ -353,6 +356,19 @@ class LocalDirectoryWatcher: except Exception as err: logger.error(f"处理本地目录监控事件失败: {path_str} - {err}") + @staticmethod + def _is_download_temporary_file(event_path: Path) -> bool: + """ + 按当前下载临时后缀配置过滤文件,沿用整理分发器的大小写不敏感匹配。 + + :param event_path: 待检查的监控路径 + :return: 文件后缀是否属于配置的下载临时后缀 + """ + return bool(event_path.suffix) and event_path.suffix.casefold() in { + extension.casefold() + for extension in get_runtime_setting('DOWNLOAD_TMPEXT') + } + def _queue_stability_event(self, change_type: Change, event_path: Path, file_size: int) -> None: """ @@ -409,6 +425,9 @@ class LocalDirectoryWatcher: if not item: continue event_path = item["path"] + # 配置可能在文件入队后变化,告警前再次核对,避免旧条目继续误报。 + if self._is_download_temporary_file(event_path): + continue if ( not item.get("warning_sent") and now - item.get("first_seen", now) >= self.FILE_STABILITY_WARNING_SECONDS diff --git a/tests/test_monitor_watchfiles.py b/tests/test_monitor_watchfiles.py index 57d8abded..6156d0815 100644 --- a/tests/test_monitor_watchfiles.py +++ b/tests/test_monitor_watchfiles.py @@ -6,6 +6,7 @@ import threading from pathlib import Path from unittest.mock import MagicMock +import pytest from watchfiles import Change from app.application.history import TransferHistorySnapshot @@ -174,6 +175,130 @@ def test_running_watcher_reports_long_unstable_file_and_recovery(tmp_path, monke assert len(callback.events) == 1 +@pytest.mark.parametrize("suffix", [".!qB", ".!QB", ".part", ".PART", ".aria2"]) +def test_running_watcher_ignores_download_temporary_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, suffix: str +) -> None: + """ + 配置的临时下载后缀不进入稳定性队列,长期写入也不应告警。 + + :param tmp_path: 隔离的临时下载目录 + :param monkeypatch: 替换运行配置、监控线程状态和单调时钟 + :param suffix: 下载器使用的临时后缀,包含大小写和自定义后缀 + """ + monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", lambda _: [".!qb", ".part", ".aria2"]) + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 1000.0) + temporary_file = tmp_path / f"episode.mp4{suffix}" + temporary_file.write_bytes(b"downloading") + callback = CallbackRecorder() + watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True) + monkeypatch.setattr(watcher, "is_alive", lambda: True) + + watcher._handle_changes({(Change.added, temporary_file.as_posix())}) + temporary_file.write_bytes(b"still downloading") + watcher._handle_changes({(Change.modified, temporary_file.as_posix())}) + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2801.0) + watcher._process_pending_stability() + + assert watcher._pending_stability == {} + assert callback.events == [] + assert callback.stability_events == [] + + +def test_running_watcher_dispatches_completed_download_after_rename( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + 临时文件下载完成并改名后,正式视频仍需稳定两次再分发一次。 + + :param tmp_path: 隔离的临时下载目录 + :param monkeypatch: 替换临时后缀配置、监控线程状态和单调时钟 + """ + monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", lambda _: [".!qb"]) + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 1000.0) + temporary_file = tmp_path / "episode.mp4.!qB" + completed_file = tmp_path / "episode.mp4" + temporary_file.write_bytes(b"complete") + callback = CallbackRecorder() + watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True) + monkeypatch.setattr(watcher, "is_alive", lambda: True) + watcher._handle_changes({(Change.added, temporary_file.as_posix())}) + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2801.0) + watcher._process_pending_stability() + + temporary_file.rename(completed_file) + watcher._handle_changes({ + (Change.deleted, temporary_file.as_posix()), + (Change.added, completed_file.as_posix()), + }) + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2803.0) + watcher._process_pending_stability() + assert callback.events == [] + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2805.0) + watcher._process_pending_stability() + + assert [event[2] for event in callback.events] == [completed_file.as_posix()] + assert watcher._pending_stability == {} + assert callback.stability_events == [] + + +def test_running_watcher_rechecks_temporary_suffix_for_pending_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + 临时后缀配置热更新后,已入队的文件不应继续产生超时告警。 + + :param tmp_path: 隔离的临时下载目录 + :param monkeypatch: 替换运行配置、监控线程状态和单调时钟 + """ + temporary_extensions: list[str] = [] + monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", lambda _: temporary_extensions) + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 1000.0) + temporary_file = tmp_path / "episode.mp4.!qB" + temporary_file.write_bytes(b"downloading") + callback = CallbackRecorder() + watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True) + monkeypatch.setattr(watcher, "is_alive", lambda: True) + watcher._handle_changes({(Change.added, temporary_file.as_posix())}) + assert temporary_file.as_posix() in watcher._pending_stability + + temporary_extensions.append(".!qb") + monkeypatch.setattr("app.monitor.watcher.time.monotonic", lambda: 2801.0) + watcher._process_pending_stability() + + assert watcher._pending_stability == {} + assert callback.events == [] + assert callback.stability_events == [] + + +def test_running_watcher_filters_temporary_files_in_added_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + 新增目录递归展开时跳过临时文件,并保留正式视频的稳定性检测。 + + :param tmp_path: 隔离的目录树 + :param monkeypatch: 替换监控运行配置和线程状态 + """ + runtime_settings = {"DOWNLOAD_TMPEXT": [".!qb"], "MONITOR_RESCAN_DELAYS": "30,120"} + monkeypatch.setattr("app.monitor.watcher.get_runtime_setting", runtime_settings.__getitem__) + added_dir = tmp_path / "season" + added_dir.mkdir() + temporary_file = added_dir / "episode.mp4.!qB" + completed_file = added_dir / "complete.mp4" + temporary_file.write_bytes(b"downloading") + completed_file.write_bytes(b"complete") + callback = CallbackRecorder() + watcher = LocalDirectoryWatcher(tmp_path, callback=callback, force_polling=True) + monkeypatch.setattr(watcher, "is_alive", lambda: True) + + watcher._handle_changes({(Change.added, added_dir.as_posix())}) + + assert set(watcher._pending_stability) == {completed_file.as_posix()} + assert callback.events == [] + assert callback.stability_events == [] + + def test_handle_changes_skips_missing_paths(tmp_path): """ 事件到达时已经消失的路径不应触发整理。