mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 08:26:53 +08:00
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:
+119
-9
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user