fix(transfer): support nested download categories (#6156)

This commit is contained in:
jxxghp
2026-07-20 16:14:17 +08:00
parent 9ed0704c5b
commit ed92be08af
2 changed files with 289 additions and 165 deletions
+31 -2
View File
@@ -782,6 +782,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
} }
def __init__(self): def __init__(self):
"""初始化文件整理处理链。"""
super().__init__() super().__init__()
# 主要媒体文件后缀 # 主要媒体文件后缀
self._media_exts = settings.RMT_MEDIAEXT self._media_exts = settings.RMT_MEDIAEXT
@@ -841,6 +842,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
logger.info("文件整理线程已停止") logger.info("文件整理线程已停止")
def on_config_changed(self): def on_config_changed(self):
"""配置变更时重启文件整理线程。"""
self.__stop() self.__stop()
self.__init() self.__init()
@@ -2213,6 +2215,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
""" """
shared_roots: set[str] = set() shared_roots: set[str] = set()
media_type_dirs = {mtype.value for mtype in MediaType} media_type_dirs = {mtype.value for mtype in MediaType}
media_categories = None
for dir_info in DirectoryHelper().get_download_dirs(): for dir_info in DirectoryHelper().get_download_dirs():
if not dir_info.download_path: if not dir_info.download_path:
@@ -2226,6 +2229,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
relative_parts = file_path.relative_to(download_root).parts relative_parts = file_path.relative_to(download_root).parts
current_root = download_root current_root = download_root
part_index = 0 part_index = 0
media_type = dir_info.media_type
if ( if (
not dir_info.media_type not dir_info.media_type
@@ -2235,6 +2239,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
): ):
current_root = current_root / relative_parts[part_index] current_root = current_root / relative_parts[part_index]
shared_roots.add(current_root.as_posix()) shared_roots.add(current_root.as_posix())
media_type = relative_parts[part_index]
part_index += 1 part_index += 1
if ( if (
@@ -2242,8 +2247,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
and dir_info.download_category_folder and dir_info.download_category_folder
and len(relative_parts) > part_index and len(relative_parts) > part_index
): ):
current_root = current_root / relative_parts[part_index] category_root = current_root / relative_parts[part_index]
shared_roots.add(current_root.as_posix()) shared_roots.add(category_root.as_posix())
if media_categories is None:
media_categories = MediaChain().media_category() or {}
if media_type:
category_names = media_categories.get(media_type, [])
else:
category_names = {
category
for categories in media_categories.values()
for category in categories
}
category_paths = sorted(
(Path(category).parts for category in category_names if category),
key=len,
)
for category_parts in category_paths:
relative_category_parts = tuple(
relative_parts[part_index:part_index + len(category_parts)]
)
if relative_category_parts != category_parts:
continue
category_root = current_root
for category_part in category_parts:
category_root = category_root / category_part
shared_roots.add(category_root.as_posix())
return shared_roots return shared_roots
+258 -163
View File
@@ -1,213 +1,308 @@
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
class FakeDownloadHistoryOper: class FakeDownloadHistoryOper:
"""提供下载历史回查测试所需的内存桩。"""
def __init__( def __init__(
self, self,
histories_by_hash=None, histories_by_hash=None,
histories_by_path=None, histories_by_path=None,
files_by_fullpath=None, files_by_fullpath=None,
files_by_savepath=None, files_by_savepath=None,
): ):
"""初始化各查询维度的测试数据。"""
self.histories_by_hash = histories_by_hash or {} self.histories_by_hash = histories_by_hash or {}
self.histories_by_path = histories_by_path or {} self.histories_by_path = histories_by_path or {}
self.files_by_fullpath = files_by_fullpath or {} self.files_by_fullpath = files_by_fullpath or {}
self.files_by_savepath = files_by_savepath or {} self.files_by_savepath = files_by_savepath or {}
def get_by_hash(self, download_hash: str): def get_by_hash(self, download_hash: str):
"""按下载哈希返回历史。"""
return self.histories_by_hash.get(download_hash) return self.histories_by_hash.get(download_hash)
def get_by_path(self, path: str): def get_by_path(self, path: str):
"""按下载路径返回历史。"""
return self.histories_by_path.get(path) return self.histories_by_path.get(path)
def get_file_by_fullpath(self, fullpath: str): def get_file_by_fullpath(self, fullpath: str):
"""按完整文件路径返回下载文件记录。"""
return self.files_by_fullpath.get(fullpath) return self.files_by_fullpath.get(fullpath)
def get_files_by_savepath(self, savepath: str): def get_files_by_savepath(self, savepath: str):
"""按保存路径返回下载文件记录。"""
return self.files_by_savepath.get(savepath, []) return self.files_by_savepath.get(savepath, [])
class TransferDownloadHistoryLookupTest(unittest.TestCase): def _make_chain() -> TransferChain:
def setUp(self): """构造不启动后台线程的整理链实例。"""
self.chain = object.__new__(TransferChain) return object.__new__(TransferChain)
def test_resolve_download_history_falls_back_to_parent_download_path(self):
expected = SimpleNamespace(download_hash="hash1", downloader="qb")
oper = FakeDownloadHistoryOper(
histories_by_hash={"hash1": expected},
histories_by_path={"/downloads/season-pack": expected},
)
history = self.chain._resolve_download_history( def _download_dir(**overrides):
downloadhis=oper, """构造下载目录配置桩。"""
file_path=Path("/downloads/season-pack/Test.Show.S01E01.mkv"), values = {
) "download_path": "/downloads",
"media_type": None,
"download_type_folder": False,
"media_category": None,
"download_category_folder": False,
}
values.update(overrides)
return SimpleNamespace(**values)
self.assertIs(history, expected)
def test_resolve_download_history_falls_back_to_unique_savepath_hash(self): def test_resolve_download_history_falls_back_to_parent_download_path():
expected = SimpleNamespace(download_hash="hash1", downloader="qb") """文件记录缺失时应按种子父目录回查下载历史。"""
oper = FakeDownloadHistoryOper( expected = SimpleNamespace(download_hash="hash1", downloader="qb")
histories_by_hash={"hash1": expected}, oper = FakeDownloadHistoryOper(
files_by_savepath={ histories_by_hash={"hash1": expected},
"/downloads/season-pack": [ histories_by_path={"/downloads/season-pack": expected},
SimpleNamespace(download_hash="hash1"), )
SimpleNamespace(download_hash="hash1"),
]
},
)
history = self.chain._resolve_download_history( history = _make_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/Test.Show.S01E01.mkv"),
) )
self.assertIs(history, expected) assert history is expected
def test_resolve_download_history_skips_ambiguous_savepath_hashes(self):
oper = FakeDownloadHistoryOper(
histories_by_hash={
"hash1": SimpleNamespace(download_hash="hash1", downloader="qb"),
"hash2": SimpleNamespace(download_hash="hash2", downloader="tr"),
},
files_by_savepath={
"/downloads/shared": [
SimpleNamespace(download_hash="hash1"),
SimpleNamespace(download_hash="hash2"),
]
},
)
history = self.chain._resolve_download_history( def test_resolve_download_history_falls_back_to_unique_savepath_hash():
downloadhis=oper, """父目录只有一个下载哈希时应返回对应历史。"""
file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"), expected = SimpleNamespace(download_hash="hash1", downloader="qb")
) oper = FakeDownloadHistoryOper(
histories_by_hash={"hash1": expected},
files_by_savepath={
"/downloads/season-pack": [
SimpleNamespace(download_hash="hash1"),
SimpleNamespace(download_hash="hash1"),
]
},
)
self.assertIsNone(history) history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/season-pack/subs/Test.Show.S01E01.zh.ass"),
)
def test_resolve_download_history_stops_at_shared_download_root_path(self): assert history is expected
oper = FakeDownloadHistoryOper(
histories_by_path={
"/downloads": SimpleNamespace(download_hash="hash1", downloader="qb")
}
)
with patch(
"app.chain.transfer.DirectoryHelper.get_download_dirs", def test_resolve_download_history_skips_ambiguous_savepath_hashes():
return_value=[ """父目录关联多个下载哈希时不应猜测下载历史。"""
oper = FakeDownloadHistoryOper(
histories_by_hash={
"hash1": SimpleNamespace(download_hash="hash1", downloader="qb"),
"hash2": SimpleNamespace(download_hash="hash2", downloader="tr"),
},
files_by_savepath={
"/downloads/shared": [
SimpleNamespace(download_hash="hash1"),
SimpleNamespace(download_hash="hash2"),
]
},
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"),
)
assert history is None
def test_resolve_download_history_stops_at_shared_download_root_path(monkeypatch):
"""共享下载根目录上的路径历史不应污染同级文件。"""
oper = FakeDownloadHistoryOper(
histories_by_path={
"/downloads": SimpleNamespace(download_hash="hash1", downloader="qb")
}
)
monkeypatch.setattr(
"app.chain.transfer.DirectoryHelper.get_download_dirs",
lambda _: [_download_dir()],
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
assert history is None
def test_resolve_download_history_stops_at_shared_download_root_savepath(monkeypatch):
"""共享下载根目录上的其它文件记录不应污染当前文件。"""
expected = SimpleNamespace(download_hash="hash1", downloader="qb")
oper = FakeDownloadHistoryOper(
histories_by_hash={"hash1": expected},
files_by_savepath={
"/downloads": [
SimpleNamespace( SimpleNamespace(
download_path="/downloads", download_hash="hash1",
media_type=None, fullpath="/downloads/Other.Show.mkv",
download_type_folder=False, filepath="Other.Show.mkv",
media_category=None, ),
download_category_folder=False, ]
) },
], )
): monkeypatch.setattr(
history = self.chain._resolve_download_history( "app.chain.transfer.DirectoryHelper.get_download_dirs",
downloadhis=oper, lambda _: [_download_dir()],
file_path=Path("/downloads/Ghost.Concert.mkv"), )
)
self.assertIsNone(history) history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
def test_resolve_download_history_stops_at_shared_download_root_savepath(self): assert history is None
expected = SimpleNamespace(download_hash="hash1", downloader="qb")
oper = FakeDownloadHistoryOper(
histories_by_hash={"hash1": expected},
files_by_savepath={
"/downloads": [
SimpleNamespace(
download_hash="hash1",
fullpath="/downloads/Other.Show.mkv",
filepath="Other.Show.mkv",
),
]
},
)
with patch(
"app.chain.transfer.DirectoryHelper.get_download_dirs", def test_resolve_download_history_accepts_shared_root_savepath_for_exact_file(monkeypatch):
return_value=[ """共享根目录存在当前文件的明确记录时应允许命中。"""
expected = SimpleNamespace(download_hash="hash1", downloader="qb")
oper = FakeDownloadHistoryOper(
histories_by_hash={"hash1": expected},
files_by_savepath={
"/downloads": [
SimpleNamespace( SimpleNamespace(
download_path="/downloads", download_hash="hash1",
media_type=None, fullpath="/downloads/Ghost.Concert.mkv",
download_type_folder=False, filepath="Ghost.Concert.mkv",
media_category=None, ),
download_category_folder=False, ]
) },
], )
): monkeypatch.setattr(
history = self.chain._resolve_download_history( "app.chain.transfer.DirectoryHelper.get_download_dirs",
downloadhis=oper, lambda _: [_download_dir()],
file_path=Path("/downloads/Ghost.Concert.mkv"), )
history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
assert history is expected
def test_resolve_download_history_stops_at_type_category_download_root(monkeypatch):
"""按类型和类别生成的共享目录不应回查该目录自身的历史。"""
oper = FakeDownloadHistoryOper(
histories_by_path={
"/downloads/电视剧/动漫": SimpleNamespace(
download_hash="hash1", downloader="qb"
) )
}
self.assertIsNone(history) )
monkeypatch.setattr(
def test_resolve_download_history_accepts_shared_root_savepath_for_exact_file(self): "app.chain.transfer.DirectoryHelper.get_download_dirs",
expected = SimpleNamespace(download_hash="hash1", downloader="qb") lambda _: [
oper = FakeDownloadHistoryOper( _download_dir(
histories_by_hash={"hash1": expected}, download_type_folder=True,
files_by_savepath={ download_category_folder=True,
"/downloads": [
SimpleNamespace(
download_hash="hash1",
fullpath="/downloads/Ghost.Concert.mkv",
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"),
) )
],
)
monkeypatch.setattr(
"app.chain.transfer.MediaChain.media_category",
lambda _: {"电影": [], "电视剧": ["动漫"]},
)
self.assertIs(history, expected) history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/电视剧/动漫/Ghost.Concert.mkv"),
)
def test_resolve_download_history_stops_at_type_category_download_root(self): assert history is None
oper = FakeDownloadHistoryOper(
histories_by_path={
"/downloads/电视剧/动漫": SimpleNamespace(
download_hash="hash1", downloader="qb"
)
}
)
with patch(
"app.chain.transfer.DirectoryHelper.get_download_dirs", def test_get_shared_download_roots_includes_nested_category(monkeypatch):
return_value=[ """多级分类的每一级目录都应成为共享下载边界。"""
SimpleNamespace( monkeypatch.setattr(
download_path="/downloads", "app.chain.transfer.DirectoryHelper.get_download_dirs",
media_type=None, lambda _: [_download_dir(download_category_folder=True)],
download_type_folder=True, )
media_category=None, monkeypatch.setattr(
download_category_folder=True, "app.chain.transfer.MediaChain.media_category",
) lambda _: {"电影": [], "电视剧": ["动漫/日本/季度新番"]},
], )
):
history = self.chain._resolve_download_history( roots = TransferChain._get_shared_download_roots(
downloadhis=oper, Path("/downloads/动漫/日本/季度新番/Show.S01E01.mkv")
file_path=Path("/downloads/电视剧/动漫/Ghost.Concert.mkv"), )
assert roots == {
"/downloads",
"/downloads/动漫",
"/downloads/动漫/日本",
"/downloads/动漫/日本/季度新番",
}
def test_get_shared_download_roots_excludes_torrent_subdirectory(monkeypatch):
"""分类目录下由种子创建的子目录不应成为共享下载边界。"""
monkeypatch.setattr(
"app.chain.transfer.DirectoryHelper.get_download_dirs",
lambda _: [_download_dir(download_category_folder=True)],
)
monkeypatch.setattr(
"app.chain.transfer.MediaChain.media_category",
lambda _: {"电影": [], "电视剧": ["动漫/日本番剧"]},
)
roots = TransferChain._get_shared_download_roots(
Path("/downloads/动漫/日本番剧/Torrent.Name/Show.S01E01.mkv")
)
assert "/downloads/动漫/日本番剧" in roots
assert "/downloads/动漫/日本番剧/Torrent.Name" not in roots
def test_get_shared_download_roots_keeps_first_level_without_category_config(monkeypatch):
"""分类配置不可用时应保留原有的一级共享边界保护。"""
monkeypatch.setattr(
"app.chain.transfer.DirectoryHelper.get_download_dirs",
lambda _: [_download_dir(download_category_folder=True)],
)
monkeypatch.setattr(
"app.chain.transfer.MediaChain.media_category",
lambda _: None,
)
roots = TransferChain._get_shared_download_roots(
Path("/downloads/动漫/Torrent.Name/Show.S01E01.mkv")
)
assert roots == {"/downloads", "/downloads/动漫"}
def test_resolve_download_history_stops_at_nested_category_root(monkeypatch):
"""多级分类叶子目录中的其它任务历史不应污染当前文件。"""
oper = FakeDownloadHistoryOper(
histories_by_path={
"/downloads/动漫/日本番剧": SimpleNamespace(
download_hash="other-hash", downloader="qb"
) )
}
)
monkeypatch.setattr(
"app.chain.transfer.DirectoryHelper.get_download_dirs",
lambda _: [_download_dir(download_category_folder=True)],
)
monkeypatch.setattr(
"app.chain.transfer.MediaChain.media_category",
lambda _: {"电影": [], "电视剧": ["动漫/日本番剧"]},
)
self.assertIsNone(history) history = _make_chain()._resolve_download_history(
downloadhis=oper,
file_path=Path("/downloads/动漫/日本番剧/Ghost.Concert.mkv"),
)
assert history is None