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 afcd895f52

Follow-up to #5702
This commit is contained in:
jxxghp
2026-05-07 13:01:20 +08:00
parent 49a51cca25
commit 6d7b0733af
2 changed files with 240 additions and 12 deletions
+119 -9
View File
@@ -20,7 +20,7 @@ from app.core.event import eventmanager
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfoPath from app.core.metainfo import MetaInfoPath
from app.db.downloadhistory_oper import DownloadHistoryOper 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.models.transferhistory import TransferHistory
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.db.transferhistory_oper import TransferHistoryOper from app.db.transferhistory_oper import TransferHistoryOper
@@ -1686,7 +1686,102 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
] ]
@staticmethod @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( def _resolve_download_history(
self,
downloadhis: DownloadHistoryOper, downloadhis: DownloadHistoryOper,
file_path: Path, file_path: Path,
bluray_dir: bool = False, bluray_dir: bool = False,
@@ -1707,20 +1802,35 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
# 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录, # 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录,
# 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。 # 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。
shared_download_roots = self._get_shared_download_roots(file_path)
for parent_path in file_path.parents: for parent_path in file_path.parents:
parent_posix = parent_path.as_posix() 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) download_history = downloadhis.get_by_path(parent_posix)
if download_history: if download_history:
return download_history return download_history
download_files = downloadhis.get_files_by_savepath(parent_posix) or [] history = self._resolve_history_from_download_files(
download_hashes = { downloadhis=downloadhis,
download_file.download_hash download_files=download_files,
for download_file in download_files )
if download_file.download_hash if history:
} return history
if len(download_hashes) == 1:
return downloadhis.get_by_hash(next(iter(download_hashes)))
return None return None
+121 -3
View File
@@ -1,6 +1,7 @@
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
from app.chain.transfer import TransferChain from app.chain.transfer import TransferChain
@@ -32,6 +33,9 @@ class FakeDownloadHistoryOper:
class TransferDownloadHistoryLookupTest(unittest.TestCase): class TransferDownloadHistoryLookupTest(unittest.TestCase):
def setUp(self):
self.chain = object.__new__(TransferChain)
def test_resolve_download_history_falls_back_to_parent_download_path(self): def test_resolve_download_history_falls_back_to_parent_download_path(self):
expected = SimpleNamespace(download_hash="hash1", downloader="qb") expected = SimpleNamespace(download_hash="hash1", downloader="qb")
oper = FakeDownloadHistoryOper( oper = FakeDownloadHistoryOper(
@@ -39,7 +43,7 @@ class TransferDownloadHistoryLookupTest(unittest.TestCase):
histories_by_path={"/downloads/season-pack": expected}, histories_by_path={"/downloads/season-pack": expected},
) )
history = TransferChain._resolve_download_history( history = self.chain._resolve_download_history(
downloadhis=oper, downloadhis=oper,
file_path=Path("/downloads/season-pack/Test.Show.S01E01.mkv"), 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, downloadhis=oper,
file_path=Path("/downloads/season-pack/subs/Test.Show.S01E01.zh.ass"), 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, downloadhis=oper,
file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"), file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"),
) )
self.assertIsNone(history) 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__": if __name__ == "__main__":
unittest.main() unittest.main()