From 4ca96a9dabdc8224dcebf934c238f4a2f23dd661 Mon Sep 17 00:00:00 2001 From: llzg <69387175+llzg@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:25:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20=E4=B8=8B=E8=BD=BD=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=9B=9E=E8=AF=BB=E4=B8=8B=E8=BD=BD=E5=99=A8=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E4=BF=9D=E5=AD=98=E7=9B=AE=E5=BD=95=20(#6708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/download/submission.py | 52 ++++++++++++++- tests/test_download_chain.py | 109 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index e12c484cf..041b7dfc1 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -532,6 +532,12 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): layout: Optional[str], ) -> None: """按普通下载合同结算下载器明确返回的成功结果。""" + # 下载器中已存在相同 Hash 时下载器会沿用原保存目录,须以实际目录记录 + download_dir = self._resolve_actual_download_dir( + downloader=actual_downloader, + download_hash=download_hash, + fallback=prepared.download_dir, + ) self._settle_download_success( context=context, media=prepared.media, @@ -539,7 +545,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): torrent=prepared.torrent, folder_name=prepared.folder_name, file_list=prepared.file_list, - download_dir=prepared.download_dir, + download_dir=download_dir, layout=layout, downloader=actual_downloader, download_hash=download_hash, @@ -553,6 +559,50 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): custom_words=custom_words, ) + def _resolve_actual_download_dir( + self, + *, + downloader: Optional[str], + download_hash: str, + fallback: Path, + ) -> Path: + """ + 获取下载器中该任务的真实保存目录,作为下载历史与文件明细的落库路径。 + + 下载器中已存在相同 Hash 的任务时(例如刷流插件先下载),下载器会沿用 + 其原有保存目录;此时按预期下载目录记录会得到磁盘上不存在的路径,后续 + 整理与手动整理都无法关联该文件。仅本地绝对路径需要回读,远程存储的 + 目录由对应存储协议自身管理,保持原值不变。 + """ + if not download_hash or not fallback.is_absolute(): + return fallback + try: + torrents = self.list_torrents(hashs=[download_hash], downloader=downloader) + except Exception as err: + logger.debug(f"查询下载任务实际保存目录失败:{str(err)}") + return fallback + if not torrents: + return fallback + torrent = next( + ( + item for item in torrents + if str(getattr(item, "hash", "")) == str(download_hash) + ), + torrents[0], + ) + save_path = getattr(torrent, "save_path", None) + if not save_path: + return fallback + actual_dir = Path(str(save_path)) + if not actual_dir.is_absolute(): + return fallback + if actual_dir != fallback: + logger.warn( + f"下载器中已存在该任务,实际保存目录 {actual_dir.as_posix()} 与预期 " + f"{fallback.as_posix()} 不一致,按实际目录记录下载历史" + ) + return actual_dir + def _record_rejected_download( self, *, diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 00b465ee2..5c7a6a684 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -1443,3 +1443,112 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch): assert torrent.site_name == "示例站点" assert torrent.userid == "user-1" assert torrent.username == "tester" + + +def _prepared_download_for_settle(download_dir: Path) -> download_submission._PreparedDownload: + """构造仅用于下载结算路径断言的准备事实。""" + file_name = "The.Ordinary.Jackpot.S01E01.1080p.mkv" + return download_submission._PreparedDownload( + torrent=TorrentInfo(title=file_name), + media=MediaInfo(type=MediaType.TV, title="中头奖还是要上班"), + meta=MetaInfo(file_name), + torrent_content=b"torrent", + folder_name="", + file_list=[file_name], + download_dir=download_dir, + download_uri=download_dir.as_posix(), + download_episodes=None, + site_downloader="qb", + ) + + +def _settle_accepted_download(chain, download_dir: Path) -> None: + """以固定下载上下文调用一次成功结算。""" + chain._settle_accepted_download( + prepared=_prepared_download_for_settle(download_dir), + context=Context(), + episodes=None, + channel=None, + source="subscribe", + userid=None, + username="admin", + custom_words=None, + actual_downloader="qb", + download_hash="hash123", + layout="NoSubfolder", + ) + + +def test_settle_accepted_download_uses_downloader_actual_save_dir(): + """下载器中已存在同 Hash 任务时,下载历史须落到下载器实际保存目录。""" + chain = DownloadChain.__new__(DownloadChain) + chain.list_torrents = MagicMock(return_value=[DownloaderTorrent( + hash="hash123", + downloader="qb", + save_path="/volume4/flow", + content_path="/volume4/flow/The.Ordinary.Jackpot.S01E01.1080p.mkv", + )]) + chain._settle_download_success = MagicMock() + + _settle_accepted_download(chain, Path("/volume5/PT/downloads/TV/日韩剧")) + + assert chain._settle_download_success.call_args.kwargs["download_dir"] == Path("/volume4/flow") + chain.list_torrents.assert_called_once_with(hashs=["hash123"], downloader="qb") + + +def test_settle_accepted_download_keeps_expected_dir_without_downloader_task(): + """下载器查询不到任务时保持预期目录,不改变既有结算行为。""" + chain = DownloadChain.__new__(DownloadChain) + chain.list_torrents = MagicMock(return_value=[]) + chain._settle_download_success = MagicMock() + + _settle_accepted_download(chain, Path("/downloads/TV")) + + assert chain._settle_download_success.call_args.kwargs["download_dir"] == Path("/downloads/TV") + + +def test_settle_accepted_download_falls_back_when_downloader_lookup_fails(): + """下载器查询失败时回退预期目录,不能因回读失败阻断下载结算。""" + chain = DownloadChain.__new__(DownloadChain) + chain.list_torrents = MagicMock(side_effect=RuntimeError("downloader unavailable")) + chain._settle_download_success = MagicMock() + + _settle_accepted_download(chain, Path("/downloads/TV")) + + assert chain._settle_download_success.call_args.kwargs["download_dir"] == Path("/downloads/TV") + + +def test_settle_accepted_download_skips_lookup_for_remote_dir(): + """远程存储保存目录不由本地回读,保持预期值且不查询下载器。""" + chain = DownloadChain.__new__(DownloadChain) + chain.list_torrents = MagicMock(return_value=[]) + chain._settle_download_success = MagicMock() + + _settle_accepted_download(chain, Path("smb:/downloads/TV")) + + assert chain._settle_download_success.call_args.kwargs["download_dir"] == Path("smb:/downloads/TV") + chain.list_torrents.assert_not_called() + + +def test_settle_accepted_download_records_actual_path_for_existing_torrent(): + """复现刷流先下载场景:下载历史与文件明细须记录下载器实际路径。""" + chain = DownloadChain.__new__(DownloadChain) + chain.runtime_config = SimpleNamespace(media_extensions=(".mkv",)) + chain.durable_event_writer = None + chain.download_history_repository = MagicMock() + chain._build_download_notification = MagicMock(return_value=None) + chain._submit_download_added_task = MagicMock() + chain.eventmanager = MagicMock() + chain.list_torrents = MagicMock(return_value=[DownloaderTorrent( + hash="hash123", + downloader="qb", + save_path="/volume4/flow", + content_path="/volume4/flow/The.Ordinary.Jackpot.S01E01.1080p.mkv", + )]) + + _settle_accepted_download(chain, Path("/volume5/PT/downloads/TV/日韩剧")) + + history, files = chain.download_history_repository.add.call_args.args + assert history.path == "/volume4/flow/The.Ordinary.Jackpot.S01E01.1080p.mkv" + assert files[0].fullpath == "/volume4/flow/The.Ordinary.Jackpot.S01E01.1080p.mkv" + assert files[0].savepath == "/volume4/flow"