From 6d7b0733af4c6631bbe7e3ca5a7197f116985155 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 7 May 2026 13:01:20 +0800 Subject: [PATCH] fix(transfer): avoid polluted history fallback at shared roots Parent-path fallback should stop at shared download roots so an old root-level download cannot hijack unrelated manual transfers. Keep exact DownloadFiles matches allowed at the shared root to preserve valid no-subfolder lookups. Closes #5716 Regression from afcd895f525b9fd59477ad1997d5d5fa0c829412 Follow-up to #5702 --- app/chain/transfer.py | 128 ++++++++++++++++-- .../test_transfer_download_history_lookup.py | 124 ++++++++++++++++- 2 files changed, 240 insertions(+), 12 deletions(-) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index c5f488d1..16576c3b 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -20,7 +20,7 @@ from app.core.event import eventmanager from app.core.meta import MetaBase from app.core.metainfo import MetaInfoPath from app.db.downloadhistory_oper import DownloadHistoryOper -from app.db.models.downloadhistory import DownloadHistory +from app.db.models.downloadhistory import DownloadHistory, DownloadFiles from app.db.models.transferhistory import TransferHistory from app.db.systemconfig_oper import SystemConfigOper from app.db.transferhistory_oper import TransferHistoryOper @@ -1686,7 +1686,102 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ] @staticmethod + def _get_shared_download_roots(file_path: Path) -> set[str]: + """ + 获取当前文件所在的共享下载根目录边界。 + + 父目录兜底回查只应在种子自身目录内进行,不能越过共享下载根目录, + 否则历史中的单文件/无子目录任务会污染同级其它文件的识别结果。 + """ + shared_roots: set[str] = set() + media_type_dirs = {mtype.value for mtype in MediaType} + + for dir_info in DirectoryHelper().get_download_dirs(): + if not dir_info.download_path: + continue + + download_root = Path(dir_info.download_path) + if not file_path.is_relative_to(download_root): + continue + + shared_roots.add(download_root.as_posix()) + relative_parts = file_path.relative_to(download_root).parts + current_root = download_root + part_index = 0 + + if ( + not dir_info.media_type + and dir_info.download_type_folder + and len(relative_parts) > part_index + and relative_parts[part_index] in media_type_dirs + ): + current_root = current_root / relative_parts[part_index] + shared_roots.add(current_root.as_posix()) + part_index += 1 + + if ( + not dir_info.media_category + and dir_info.download_category_folder + and len(relative_parts) > part_index + ): + current_root = current_root / relative_parts[part_index] + shared_roots.add(current_root.as_posix()) + + return shared_roots + + @staticmethod + def _match_download_file( + download_file: DownloadFiles, + file_path: Path, + save_path: Path, + ) -> bool: + """ + 判断下载文件记录是否明确对应当前文件。 + """ + if download_file.fullpath == file_path.as_posix(): + return True + + filepath = download_file.filepath + if not filepath: + return False + + try: + return (save_path / Path(filepath)).as_posix() == file_path.as_posix() + except (TypeError, ValueError): + return False + + def _resolve_history_from_download_files( + self, + downloadhis: DownloadHistoryOper, + download_files: List[DownloadFiles], + file_path: Optional[Path] = None, + save_path: Optional[Path] = None, + ) -> Optional[DownloadHistory]: + """ + 从下载文件记录中解析唯一的下载历史。 + """ + if file_path and save_path: + download_files = [ + download_file + for download_file in download_files + if self._match_download_file( + download_file=download_file, + file_path=file_path, + save_path=save_path, + ) + ] + + download_hashes = { + download_file.download_hash + for download_file in download_files + if download_file.download_hash + } + if len(download_hashes) == 1: + return downloadhis.get_by_hash(next(iter(download_hashes))) + return None + def _resolve_download_history( + self, downloadhis: DownloadHistoryOper, file_path: Path, bluray_dir: bool = False, @@ -1707,20 +1802,35 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录, # 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。 + shared_download_roots = self._get_shared_download_roots(file_path) + for parent_path in file_path.parents: parent_posix = parent_path.as_posix() + download_files = downloadhis.get_files_by_savepath(parent_posix) or [] + + if parent_posix in shared_download_roots: + # 共享下载根目录只能接受有明确文件记录的匹配, + # 避免单文件/磁力任务把整个根目录污染成同一媒体。 + history = self._resolve_history_from_download_files( + downloadhis=downloadhis, + download_files=download_files, + file_path=file_path, + save_path=parent_path, + ) + if history: + return history + break + download_history = downloadhis.get_by_path(parent_posix) if download_history: return download_history - download_files = downloadhis.get_files_by_savepath(parent_posix) or [] - download_hashes = { - download_file.download_hash - for download_file in download_files - if download_file.download_hash - } - if len(download_hashes) == 1: - return downloadhis.get_by_hash(next(iter(download_hashes))) + history = self._resolve_history_from_download_files( + downloadhis=downloadhis, + download_files=download_files, + ) + if history: + return history return None diff --git a/tests/test_transfer_download_history_lookup.py b/tests/test_transfer_download_history_lookup.py index 13ff57af..eacf9501 100644 --- a/tests/test_transfer_download_history_lookup.py +++ b/tests/test_transfer_download_history_lookup.py @@ -1,6 +1,7 @@ import unittest from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch from app.chain.transfer import TransferChain @@ -32,6 +33,9 @@ class FakeDownloadHistoryOper: class TransferDownloadHistoryLookupTest(unittest.TestCase): + def setUp(self): + self.chain = object.__new__(TransferChain) + def test_resolve_download_history_falls_back_to_parent_download_path(self): expected = SimpleNamespace(download_hash="hash1", downloader="qb") oper = FakeDownloadHistoryOper( @@ -39,7 +43,7 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase): histories_by_path={"/downloads/season-pack": expected}, ) - history = TransferChain._resolve_download_history( + history = self.chain._resolve_download_history( downloadhis=oper, file_path=Path("/downloads/season-pack/Test.Show.S01E01.mkv"), ) @@ -58,7 +62,7 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase): }, ) - history = TransferChain._resolve_download_history( + history = self.chain._resolve_download_history( downloadhis=oper, file_path=Path("/downloads/season-pack/subs/Test.Show.S01E01.zh.ass"), ) @@ -79,13 +83,127 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase): }, ) - history = TransferChain._resolve_download_history( + history = self.chain._resolve_download_history( downloadhis=oper, file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"), ) self.assertIsNone(history) + def test_resolve_download_history_stops_at_shared_download_root_path(self): + oper = FakeDownloadHistoryOper( + histories_by_path={ + "/downloads": SimpleNamespace(download_hash="hash1", downloader="qb") + } + ) + + with patch( + "app.chain.transfer.DirectoryHelper.get_download_dirs", + return_value=[ + SimpleNamespace( + download_path="/downloads", + media_type=None, + download_type_folder=False, + media_category=None, + download_category_folder=False, + ) + ], + ): + history = self.chain._resolve_download_history( + downloadhis=oper, + file_path=Path("/downloads/Ghost.Concert.mkv"), + ) + + self.assertIsNone(history) + + def test_resolve_download_history_stops_at_shared_download_root_savepath(self): + expected = SimpleNamespace(download_hash="hash1", downloader="qb") + oper = FakeDownloadHistoryOper( + histories_by_hash={"hash1": expected}, + files_by_savepath={ + "/downloads": [ + SimpleNamespace(download_hash="hash1", filepath="Other.Show.mkv"), + ] + }, + ) + + with patch( + "app.chain.transfer.DirectoryHelper.get_download_dirs", + return_value=[ + SimpleNamespace( + download_path="/downloads", + media_type=None, + download_type_folder=False, + media_category=None, + download_category_folder=False, + ) + ], + ): + history = self.chain._resolve_download_history( + downloadhis=oper, + file_path=Path("/downloads/Ghost.Concert.mkv"), + ) + + self.assertIsNone(history) + + def test_resolve_download_history_accepts_shared_root_savepath_for_exact_file(self): + expected = SimpleNamespace(download_hash="hash1", downloader="qb") + oper = FakeDownloadHistoryOper( + histories_by_hash={"hash1": expected}, + files_by_savepath={ + "/downloads": [ + SimpleNamespace(download_hash="hash1", filepath="Ghost.Concert.mkv"), + ] + }, + ) + + with patch( + "app.chain.transfer.DirectoryHelper.get_download_dirs", + return_value=[ + SimpleNamespace( + download_path="/downloads", + media_type=None, + download_type_folder=False, + media_category=None, + download_category_folder=False, + ) + ], + ): + history = self.chain._resolve_download_history( + downloadhis=oper, + file_path=Path("/downloads/Ghost.Concert.mkv"), + ) + + self.assertIs(history, expected) + + def test_resolve_download_history_stops_at_type_category_download_root(self): + oper = FakeDownloadHistoryOper( + histories_by_path={ + "/downloads/电视剧/动漫": SimpleNamespace( + download_hash="hash1", downloader="qb" + ) + } + ) + + with patch( + "app.chain.transfer.DirectoryHelper.get_download_dirs", + return_value=[ + SimpleNamespace( + download_path="/downloads", + media_type=None, + download_type_folder=True, + media_category=None, + download_category_folder=True, + ) + ], + ): + history = self.chain._resolve_download_history( + downloadhis=oper, + file_path=Path("/downloads/电视剧/动漫/Ghost.Concert.mkv"), + ) + + self.assertIsNone(history) + if __name__ == "__main__": unittest.main()