From 0609ac9e017aae9be7998e50b2ae66ff753cac10 Mon Sep 17 00:00:00 2001 From: JontyLee <31982854+JontyLee@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:56:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20=E5=A4=8D=E7=94=A8=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=99=A8=E5=B7=B2=E6=9C=89=E4=BB=BB=E5=8A=A1=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=87=8D=E5=A4=8D=E9=80=9A=E7=9F=A5=E4=B8=8E?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=96=87=E4=BB=B6=E6=98=8E=E7=BB=86=20(#7078?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/download/history.py | 35 ++++- app/chain/download/submission.py | 8 ++ app/modules/qbittorrent/__init__.py | 5 +- app/modules/rtorrent/__init__.py | 3 +- app/modules/transmission/__init__.py | 3 +- app/schemas/download.py | 3 + app/schemas/exports.py | 1 + docs/architecture-overview.md | 6 +- .../architecture/dependency-baseline.json | 10 +- tests/test_download_chain.py | 20 ++- tests/test_downloader_submission.py | 131 +++++++++++++++++- 11 files changed, 212 insertions(+), 13 deletions(-) diff --git a/app/chain/download/history.py b/app/chain/download/history.py index 7767256c1..5ed27c028 100644 --- a/app/chain/download/history.py +++ b/app/chain/download/history.py @@ -21,6 +21,7 @@ from app.domain.context import ( ) from app.domain.meta.metabase import MetaBase from app.domain.metainfo import MetaInfo +from app.runtime.log import logger from app.schemas.media import resolve_media_identity from app.schemas.types import ( EventType, @@ -76,9 +77,12 @@ class DownloadHistoryOwner(_DownloadOwnerBase): username: Optional[str], torrent_content: Union[str, bytes], custom_words: Optional[str], + already_exists: bool = False, ) -> None: """ 提交下载历史、文件明细和 durable 下载事件。 + + :param already_exists: 下载器中已存在同一任务时为 True,仅补记历史,不再重复通知与文件明细 """ if layout == "NoSubfolder" or not folder_name: download_path = download_dir / file_list[0] if file_list else download_dir @@ -140,12 +144,14 @@ class DownloadHistoryOwner(_DownloadOwnerBase): torrentname=meta.org_string, ) ) + if already_exists: + files_to_add = self._filter_unrecorded_download_files(files_to_add, download_hash) frozen_files = tuple(files_to_add) event_payload = { "hash": download_hash, "context": context, "username": username, "downloader": downloader, "episodes": episodes or meta.episode_list, "source": source, } - notification = self._build_download_notification( + notification = None if already_exists else self._build_download_notification( media=media, meta=meta, torrent=torrent, @@ -187,3 +193,30 @@ class DownloadHistoryOwner(_DownloadOwnerBase): downloader=downloader, ) self.eventmanager.send_event(EventType.DownloadAdded, event_payload) + + def _filter_unrecorded_download_files( + self, + files: list[DownloadFileWrite], + download_hash: str, + ) -> list[DownloadFileWrite]: + """ + 剔除同一任务 Hash 下已经记录的有效文件明细,避免重复提交写入成对副本。 + + :param files: 本次提交解析出的文件明细 + :param download_hash: 下载器中的任务哈希标识 + :return: 尚未记录过的文件明细 + """ + if not files: + return files + recorded = { + item.fullpath + for item in self.download_history_repository.get_files_by_hash(download_hash, state=1) + if item.fullpath + } + if not recorded: + return files + retained = [item for item in files if item.fullpath not in recorded] + skipped = len(files) - len(retained) + if skipped: + logger.info(f"下载任务 {download_hash} 的 {skipped} 条文件明细已记录,不再重复写入") + return retained diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index 7b124a466..502252039 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -40,6 +40,7 @@ from app.domain.meta.metabase import MetaBase from app.runtime.cache import FileCache from app.runtime.events import eventmanager from app.runtime.log import logger +from app.schemas.download import DOWNLOAD_TASK_ALREADY_EXISTS from app.schemas.event import ResourceDownloadEventData from app.schemas.file import FileURI from app.schemas.media import resolve_media_identity @@ -750,6 +751,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): actual_downloader=actual_downloader, download_hash=download_hash, layout=layout, + already_exists=error_msg == DOWNLOAD_TASK_ALREADY_EXISTS, ) else: self._record_rejected_download( @@ -779,10 +781,15 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): actual_downloader: Optional[str], download_hash: str, layout: Optional[str], + already_exists: bool = False, ) -> None: """ 按普通下载合同结算下载器明确返回的成功结果。 + + :param already_exists: 下载器中已存在同一任务时为 True,结算时复用现有任务而不重复通知 """ + if already_exists: + logger.info(f"{prepared.torrent.title} 下载器中已存在同一任务,沿用现有下载进度") # 下载器中已存在相同 Hash 时下载器会沿用原保存目录,须以实际目录记录 download_dir = self._resolve_actual_download_dir( downloader=actual_downloader, @@ -808,6 +815,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): username=username, torrent_content=prepared.torrent_content, custom_words=custom_words, + already_exists=already_exists, ) def _resolve_actual_download_dir( diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index 887c4e443..9c68d1c36 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -17,6 +17,7 @@ from app.modules.qbittorrent.qbittorrent import Qbittorrent from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo +from app.schemas.download import DOWNLOAD_TASK_ALREADY_EXISTS from app.schemas.transfer import DownloaderFile, DownloaderTorrent from app.schemas.types import ( DownloaderType, @@ -193,7 +194,7 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): server, torrent_hash, episodes or set(), custom_words, downloader, torrent_layout, already_exists=True, ) - return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "下载任务已存在" + return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, DOWNLOAD_TASK_ALREADY_EXISTS finally: torrents.clear() del torrents @@ -236,7 +237,7 @@ class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): if not selected: return None, None, None, message if already_exists: - message = "下载任务已存在" + message = DOWNLOAD_TASK_ALREADY_EXISTS return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, message @staticmethod diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index 05d9ed537..ce06c6054 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -14,6 +14,7 @@ from app.modules.rtorrent.rtorrent import Rtorrent, TorrentData from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo +from app.schemas.download import DOWNLOAD_TASK_ALREADY_EXISTS from app.schemas.transfer import DownloaderFile, DownloaderTorrent from app.schemas.types import ( DownloaderType, @@ -183,7 +184,7 @@ class RtorrentModule(_DownloaderModuleBase[Rtorrent]): downloader or self.get_default_config_name(), torrent_hash, torrent_layout, - "下载任务已存在", + DOWNLOAD_TASK_ALREADY_EXISTS, ) finally: torrents.clear() diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index db1728ff0..a28cb3c43 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -16,6 +16,7 @@ from app.modules.transmission.transmission import Transmission from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.schemas.dashboard import DownloaderInfo as _SchemaDownloaderInfo +from app.schemas.download import DOWNLOAD_TASK_ALREADY_EXISTS from app.schemas.transfer import DownloaderFile, DownloaderTorrent from app.schemas.types import ( DownloaderType, @@ -231,7 +232,7 @@ class TransmissionModule(_DownloaderModuleBase[Transmission]): ) if not selected: return None, None, None, message - return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, "下载任务已存在" + return downloader or self.get_default_config_name(), torrent_hash, torrent_layout, DOWNLOAD_TASK_ALREADY_EXISTS finally: torrents.clear() del torrents diff --git a/app/schemas/download.py b/app/schemas/download.py index 18b6f7797..2a23758a8 100644 --- a/app/schemas/download.py +++ b/app/schemas/download.py @@ -10,6 +10,9 @@ from pydantic import model_validator as _model_validator from app.schemas.types import MediaSource as _MediaSource from app.schemas.types import MusicEntityType as _MusicEntityType +# 下载器报告同一 hash 的任务已存在时返回的统一说明,订阅链路据此区分复用与新增提交。 +DOWNLOAD_TASK_ALREADY_EXISTS = "下载任务已存在" + class DownloadTask(BaseModel): """ diff --git a/app/schemas/exports.py b/app/schemas/exports.py index a16b405f8..6b8b3e56c 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -137,6 +137,7 @@ SCHEMA_EXPORTS = { 'CustomFilterRuleUpdateRequest': ('app.schemas.rule', 'CustomFilterRuleUpdateRequest'), 'CustomIdentifiersUpdateRequest': ('app.schemas.system', 'CustomIdentifiersUpdateRequest'), 'CustomRule': ('app.schemas.rule', 'CustomRule'), + 'DOWNLOAD_TASK_ALREADY_EXISTS': ('app.schemas.download', 'DOWNLOAD_TASK_ALREADY_EXISTS'), 'DashboardMemoryInfo': ('app.schemas.dashboard', 'DashboardMemoryInfo'), 'DashboardSystemInfo': ('app.schemas.dashboard', 'DashboardSystemInfo'), 'DataT': ('app.schemas.response', 'DataT'), diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index d91e2a7bc..a030671f4 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -7,7 +7,7 @@ > [`docs/rules/04-design-patterns.md`](rules/04-design-patterns.md) 为准,本文与其保持一致; > 如出现差异,以规则文档为准。 > -> *最后更新:2026-10-18* +> *最后更新:2026-10-19* --- @@ -811,13 +811,13 @@ flowchart LR ### 10.2 当前架构快照 -以下是 2026-10-18 核对的 `tests/fixtures/architecture/` 数据;这是源码合同快照, +以下是 2026-10-19 核对的 `tests/fixtures/architecture/` 数据;这是源码合同快照, 不代表某个已发布镜像或正在运行的实例已经验证。 | 指标 | 当前值 | |---|---:| | Python 模块 | 1124 | -| 内部导入边 | 9,615 | +| 内部导入边 | 9,621 | | 非平凡 SCC | 1(29 个模块的 TMDB vendor 精确 containment) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 51(精确 policy 准入,Application / Chain 均为 0) | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 1ef6c02c8..2e6c6c99c 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -975,8 +975,8 @@ "runtime_only": true } }, - "edge_count": 9615, - "edge_sha256": "3b153e37066b873384b640fc3e20b3a526d6ec44e7c16a6ba407e1ec2ab5fc6a", + "edge_count": 9621, + "edge_sha256": "30d190a87ebc01743763a67be2f52f9b88e59bc553279fb65b66b758131c344e", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -4674,6 +4674,8 @@ "app.chain.download.history -> app.domain.meta", "app.chain.download.history -> app.domain.meta.metabase", "app.chain.download.history -> app.domain.metainfo", + "app.chain.download.history -> app.runtime", + "app.chain.download.history -> app.runtime.log", "app.chain.download.history -> app.schemas", "app.chain.download.history -> app.schemas.media", "app.chain.download.history -> app.schemas.types", @@ -4745,6 +4747,7 @@ "app.chain.download.submission -> app.runtime.events", "app.chain.download.submission -> app.runtime.log", "app.chain.download.submission -> app.schemas", + "app.chain.download.submission -> app.schemas.download", "app.chain.download.submission -> app.schemas.event", "app.chain.download.submission -> app.schemas.file", "app.chain.download.submission -> app.schemas.media", @@ -8065,6 +8068,7 @@ "app.modules.qbittorrent -> app.runtime.settings", "app.modules.qbittorrent -> app.schemas", "app.modules.qbittorrent -> app.schemas.dashboard", + "app.modules.qbittorrent -> app.schemas.download", "app.modules.qbittorrent -> app.schemas.transfer", "app.modules.qbittorrent -> app.schemas.types", "app.modules.qbittorrent.qbittorrent -> app.domain", @@ -8139,6 +8143,7 @@ "app.modules.rtorrent -> app.runtime.settings", "app.modules.rtorrent -> app.schemas", "app.modules.rtorrent -> app.schemas.dashboard", + "app.modules.rtorrent -> app.schemas.download", "app.modules.rtorrent -> app.schemas.transfer", "app.modules.rtorrent -> app.schemas.types", "app.modules.rtorrent.rtorrent -> app.runtime", @@ -8537,6 +8542,7 @@ "app.modules.transmission -> app.runtime.settings", "app.modules.transmission -> app.schemas", "app.modules.transmission -> app.schemas.dashboard", + "app.modules.transmission -> app.schemas.download", "app.modules.transmission -> app.schemas.transfer", "app.modules.transmission -> app.schemas.types", "app.modules.transmission.transmission -> app.foundation", diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index d2d300214..d98e7dbfd 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -1965,9 +1965,13 @@ def _prepared_download_for_settle(download_dir: Path) -> download_submission._Pr ) -def _settle_accepted_download(chain, download_dir: Path) -> None: +def _settle_accepted_download(chain, download_dir: Path, already_exists: bool = False) -> None: """ 以固定下载上下文调用一次成功结算。 + + :param chain: 待结算的下载链实例 + :param download_dir: 提交前解析出的预期下载目录 + :param already_exists: 是否按下载器已存在同一任务结算 """ chain._settle_accepted_download( prepared=_prepared_download_for_settle(download_dir), @@ -1981,6 +1985,7 @@ def _settle_accepted_download(chain, download_dir: Path) -> None: actual_downloader="qb", download_hash="hash123", layout="NoSubfolder", + already_exists=already_exists, ) @@ -2043,6 +2048,19 @@ def test_settle_accepted_download_skips_lookup_for_remote_dir(): chain.list_torrents.assert_not_called() +def test_settle_accepted_download_marks_reuse_when_task_exists(): + """ + 下载器已存在同一任务时,复用事实须传递到结算,供历史跳过重复通知。 + """ + chain = DownloadChain.__new__(DownloadChain) + chain.list_torrents = MagicMock(return_value=[]) + chain._settle_download_success = MagicMock() + + _settle_accepted_download(chain, Path("/downloads/TV"), already_exists=True) + + assert chain._settle_download_success.call_args.kwargs["already_exists"] is True + + def test_settle_accepted_download_records_actual_path_for_existing_torrent(): """ 复现刷流先下载场景:下载历史与文件明细须记录下载器实际路径。 diff --git a/tests/test_downloader_submission.py b/tests/test_downloader_submission.py index c626f790b..f008dc9f4 100644 --- a/tests/test_downloader_submission.py +++ b/tests/test_downloader_submission.py @@ -5,16 +5,19 @@ import hashlib from pathlib import Path from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest from torrentool.bencode import Bencode from app.chain.download.facade import DownloadChain -from app.domain.context import Context +from app.domain.context import Context, MediaInfo, TorrentInfo +from app.domain.metainfo import MetaInfo from app.modules.qbittorrent import QbittorrentModule from app.modules.transmission import TransmissionModule from app.modules.transmission.transmission import Transmission +from app.schemas.download import DOWNLOAD_TASK_ALREADY_EXISTS +from app.schemas.types import MediaType _CUSTOM_WORDS = ( "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 " @@ -335,3 +338,127 @@ def test_qbittorrent_force_start_confirms_selection_result(downloader, started): assert result[1] == (torrent_hash if started else None) server.torrents_set_force_start.assert_called_once_with(torrent_hash) server.start_torrents.assert_not_called() + + +def _report_existing_task(server, module, torrent_hash) -> None: + """ + 让真实模块把本次添加解释为下载器中已存在的同一任务。 + + :param server: 被替换的外部下载器客户端 + :param module: 当前参数化的下载器模块 + :param torrent_hash: 下载器中已存在任务的哈希 + """ + server.add_torrent.return_value = ( + (False, []) if isinstance(module, QbittorrentModule) else None + ) + server.get_torrents.return_value = ( + [{"hash": torrent_hash, "name": "Same.Show.S01", "total_size": 42, "tags": ""}] + if isinstance(module, QbittorrentModule) else + [SimpleNamespace(hashString=torrent_hash, name="Same.Show.S01", total_size=42, labels=[])], + None, + ) + + +@pytest.mark.parametrize("existing", [True, False]) +def test_downloader_task_identity_decides_reuse_flag(downloader, existing): + """ + 复用已有任务与新增提交在结算入口必须可区分,供链路跳过重复通知与文件明细。 + """ + module, server, content, torrent_hash = downloader + if existing: + _report_existing_task(server, module, torrent_hash) + chain = DownloadChain.__new__(DownloadChain) + chain.run_module = lambda method, **kwargs: getattr(module, method)(**kwargs) + chain._settle_accepted_download = Mock() + chain._record_rejected_download = Mock() + + result, message = _submit(chain, content, None, None) + + assert result == torrent_hash + assert chain._settle_accepted_download.call_args.kwargs["already_exists"] is existing + assert (message == DOWNLOAD_TASK_ALREADY_EXISTS) is existing + chain._record_rejected_download.assert_not_called() + + +@pytest.mark.parametrize("durable", [False, True]) +@pytest.mark.parametrize("existing,expected_files", [ + ("matched", [_FILES[1]]), + ("all", []), + ("empty", _FILES), + ("unrelated", _FILES), +], ids=["matched", "all", "empty", "unrelated"]) +def test_reuse_settlement_keeps_history_and_skips_duplicate_effects(durable, existing, expected_files): + """ + 复用已有任务时仍记录历史并触发整理与事件,但不生成通知意图,也不重复写入已记录的文件明细。 + """ + recorded_file, new_file = _FILES + records = { + "matched": [f"/downloads/shared-show/{recorded_file}", ""], + "all": [f"/downloads/shared-show/{name}" for name in _FILES], + "empty": [], + "unrelated": ["/downloads/shared-show/Other.Show.S01E01.1080p.mkv"], + }[existing] + meta = MetaInfo(recorded_file) + media = MediaInfo(type=MediaType.TV, title="一念永恒", tmdb_id=107371) + torrent = TorrentInfo(title="A.Will.Eternal.S04.2160p") + chain = DownloadChain.__new__(DownloadChain) + chain.download_history_repository = MagicMock() + chain.download_history_repository.get_files_by_hash.return_value = [ + SimpleNamespace(fullpath=path) for path in records + ] + chain.durable_event_writer = MagicMock() if durable else None + chain._build_download_notification = MagicMock(return_value=object()) + chain._submit_download_added_task = MagicMock() + chain.post_message = MagicMock() + chain.eventmanager = MagicMock() + + chain._settle_download_success( + context=Context(meta_info=meta, media_info=media, torrent_info=torrent), + media=media, meta=meta, torrent=torrent, + folder_name="shared-show", file_list=[recorded_file, new_file], + download_dir=Path("/downloads"), layout="Original", downloader="qb", + download_hash="reuse-hash", download_episodes=None, episodes=None, + channel=None, source="subscribe", userid=None, username=None, + torrent_content=b"torrent", custom_words=None, already_exists=True, + ) + + if durable: + payload = chain.durable_event_writer.download_added.call_args.kwargs + assert payload["notification_payload"] is None + history, files = payload["history"], payload["files"] + else: + history, files = chain.download_history_repository.add.call_args.args + chain.post_message.assert_not_called() + chain._submit_download_added_task.assert_called_once() + chain.eventmanager.send_event.assert_called_once() + assert history.download_hash == "reuse-hash" + assert [item.filepath for item in files] == list(expected_files) + chain._build_download_notification.assert_not_called() + chain.download_history_repository.get_files_by_hash.assert_called_once_with("reuse-hash", state=1) + + +def test_reuse_settlement_without_media_files_skips_file_lookup(): + """ + 复用任务且本次种子没有可记录的媒体文件时,不再查询历史明细,也不写入文件记录。 + """ + meta = MetaInfo("readme.txt") + media = MediaInfo(type=MediaType.TV, title="一念永恒", tmdb_id=107371) + torrent = TorrentInfo(title="A.Will.Eternal.S04.2160p") + chain = DownloadChain.__new__(DownloadChain) + chain.download_history_repository = MagicMock() + chain.durable_event_writer = MagicMock() + chain._build_download_notification = MagicMock(return_value=object()) + + chain._settle_download_success( + context=Context(meta_info=meta, media_info=media, torrent_info=torrent), + media=media, meta=meta, torrent=torrent, + folder_name="shared-show", file_list=["readme.txt"], + download_dir=Path("/downloads"), layout="Original", downloader="qb", + download_hash="reuse-hash", download_episodes=None, episodes=None, + channel=None, source="subscribe", userid=None, username=None, + torrent_content=b"torrent", custom_words=None, already_exists=True, + ) + + payload = chain.durable_event_writer.download_added.call_args.kwargs + assert payload["files"] == () + chain.download_history_repository.get_files_by_hash.assert_not_called()