fix(download): 下载历史回读下载器实际保存目录 (#6708)

This commit is contained in:
llzg
2026-09-15 23:25:47 +08:00
committed by GitHub
parent 4ec8d4c961
commit 4ca96a9dab
2 changed files with 160 additions and 1 deletions

View File

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

View File

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