fix(download): 复用下载器已有任务时不再重复通知与记录文件明细 (#7078)

This commit is contained in:
JontyLee
2026-10-19 17:56:58 +08:00
committed by GitHub
parent 6e5c9d0afb
commit 0609ac9e01
11 changed files with 212 additions and 13 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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'),

View File

@@ -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) |

View File

@@ -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",

View File

@@ -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():
"""
复现刷流先下载场景:下载历史与文件明细须记录下载器实际路径。

View File

@@ -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()