修复手动整理按集数定位模板过滤 (#6043)

This commit is contained in:
Album
2026-07-03 07:55:59 +08:00
committed by GitHub
parent f3e5be37fd
commit 6c3c5e042d
5 changed files with 630 additions and 36 deletions
+4 -9
View File
@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
from app import schemas from app import schemas
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain from app.chain.transfer import TransferChain
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
from app.core.security import verify_token, verify_apitoken from app.core.security import verify_token, verify_apitoken
@@ -256,6 +255,7 @@ def manual_transfer(
downloader = None downloader = None
download_hash = None download_hash = None
src_fileitems: List[FileItem] = [] src_fileitems: List[FileItem] = []
cleanup_dest_fileitem: Optional[FileItem] = None
target_path = Path(transer_item.target_path) if transer_item.target_path else None target_path = Path(transer_item.target_path) if transer_item.target_path else None
if transer_item.logid: if transer_item.logid:
# 查询历史记录 # 查询历史记录
@@ -274,15 +274,8 @@ def manual_transfer(
else: else:
# 源路径 # 源路径
src_fileitems = [FileItem(**history.src_fileitem)] src_fileitems = [FileItem(**history.src_fileitem)]
# 目的路径
if history.dest_fileitem and not transer_item.preview: if history.dest_fileitem and not transer_item.preview:
# 删除旧的已整理文件 cleanup_dest_fileitem = FileItem(**history.dest_fileitem)
dest_fileitem = FileItem(**history.dest_fileitem)
state = StorageChain().delete_media_file(dest_fileitem)
if not state:
return schemas.Response(
success=False, message=f"{dest_fileitem.path} 删除失败"
)
# 从历史数据获取信息 # 从历史数据获取信息
if transer_item.from_history: if transer_item.from_history:
@@ -427,6 +420,7 @@ def manual_transfer(
download_hash=download_hash, download_hash=download_hash,
preview=transer_item.preview, preview=transer_item.preview,
sync_extra_files=False, sync_extra_files=False,
cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
if transer_item.preview: if transer_item.preview:
if isinstance(errormsg, dict): if isinstance(errormsg, dict):
@@ -508,6 +502,7 @@ def manual_transfer(
download_hash=download_hash, download_hash=download_hash,
preview=transer_item.preview, preview=transer_item.preview,
sync_extra_files=True, sync_extra_files=True,
cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
# 失败 # 失败
if not state: if not state:
+61 -21
View File
@@ -2530,6 +2530,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
manual: Optional[bool] = False, manual: Optional[bool] = False,
preview: Optional[bool] = False, preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = False, sync_extra_files: Optional[bool] = False,
cleanup_dest_fileitem: Optional[FileItem] = None,
continue_callback: Callable = None, continue_callback: Callable = None,
) -> Tuple[bool, Union[str, dict]]: ) -> Tuple[bool, Union[str, dict]]:
""" """
@@ -2554,6 +2555,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param manual: 是否手动整理 :param manual: 是否手动整理
:param preview: 是否仅预览 :param preview: 是否仅预览
:param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件 :param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
:param continue_callback: 继续处理回调 :param continue_callback: 继续处理回调
返回:成功标识,错误信息 返回:成功标识,错误信息
""" """
@@ -2563,9 +2565,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
if preview: if preview:
# 预览模式始终同步执行,避免进入异步队列 # 预览模式始终同步执行,避免进入异步队列
background = False background = False
manual_single_file = bool(manual and fileitem and fileitem.type == "file")
# 自定义格式 # 自定义格式
has_episode_format_template = bool(epformat and epformat.format)
formaterHandler = ( formaterHandler = (
FormatParser( FormatParser(
eformat=epformat.format, eformat=epformat.format,
@@ -2583,6 +2584,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
) )
# 汇总错误信息 # 汇总错误信息
err_msgs: List[str] = [] err_msgs: List[str] = []
matched_episode_format_template = False
def _build_file_meta( def _build_file_meta(
source_path: Path, source_path: Path,
@@ -2641,29 +2643,20 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return current_meta return current_meta
def _filter(item: FileItem, is_bluray_dir: bool) -> bool: def _is_allowed_transfer_item(item: FileItem, is_bluray_dir: bool) -> bool:
""" """
过滤文件项 判断候选文件项是否允许进入整理规划。
:return: True 表示保留,False 表示排除 :return: True 表示保留,False 表示排除
""" """
nonlocal matched_episode_format_template
if continue_callback and not continue_callback(): if continue_callback and not continue_callback():
raise OperationInterrupted() raise OperationInterrupted()
is_extra_file = self.__is_subtitle_file(item) or self.__is_audio_file(item) # 存在集数定位模板时,模板匹配结果作为手动整理的硬过滤条件。
# 手动单文件整理时,前端可能把同目录文件拆成多个根文件提交; if has_episode_format_template and formaterHandler:
# 此时应优先信任用户显式选择的根文件,并允许附加文件进入后续同媒体匹配流程, if not formaterHandler.match(item.name):
# 避免仅因模板未覆盖字幕/音轨后缀而被提前过滤。
should_bypass_epformat_match = (
(manual_single_file and item.path == fileitem.path)
or (sync_extra_files and is_extra_file)
)
# 有集自定义格式,过滤文件
if (
formaterHandler
and not should_bypass_epformat_match
and not formaterHandler.match(item.name)
):
return False return False
matched_episode_format_template = True
# 过滤后缀和大小(蓝光目录、附加文件不过滤) # 过滤后缀和大小(蓝光目录、附加文件不过滤)
if ( if (
not is_bluray_dir not is_bluray_dir
@@ -2690,6 +2683,32 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False return False
return True return True
def _keep_candidate_item(item: FileItem, is_bluray_dir: bool) -> bool:
"""
收集候选文件时仅检查中断状态,不套用整理业务过滤。
"""
if continue_callback and not continue_callback():
raise OperationInterrupted()
return True
def _collect_candidate_file_items() -> List[Tuple[FileItem, bool]]:
"""
收集来源下的候选文件项,不在此阶段套用整理业务过滤。
"""
return self.__get_trans_fileitems(fileitem, predicate=_keep_candidate_item)
def _filter_allowed_file_items(
candidates: List[Tuple[FileItem, bool]]
) -> List[Tuple[FileItem, bool]]:
"""
将候选文件项筛选为本轮允许整理的文件项。
"""
return [
(candidate_item, candidate_bluray_dir)
for candidate_item, candidate_bluray_dir in candidates
if _is_allowed_transfer_item(candidate_item, candidate_bluray_dir)
]
def _build_main_meta( def _build_main_meta(
main_fileitem: FileItem, main_fileitem: FileItem,
main_bluray_dir: bool, main_bluray_dir: bool,
@@ -2771,7 +2790,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
continue continue
if not (self.__is_subtitle_file(item) or self.__is_audio_file(item)): if not (self.__is_subtitle_file(item) or self.__is_audio_file(item)):
continue continue
if not _filter(item, False): if not _is_allowed_transfer_item(item, False):
continue continue
extra_items.append((item, False)) extra_items.append((item, False))
return main_fileitems, extra_items return main_fileitems, extra_items
@@ -2918,19 +2937,36 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return planned_items, inherited_map return planned_items, inherited_map
candidate_file_items: List[Tuple[FileItem, bool]] = []
try: try:
# 获取经过筛选后的待整理文件项列表 candidate_file_items = _collect_candidate_file_items()
file_items = self.__get_trans_fileitems(fileitem, predicate=_filter) file_items = _filter_allowed_file_items(candidate_file_items)
except OperationInterrupted: except OperationInterrupted:
return False, f"{fileitem.name} 已取消" return False, f"{fileitem.name} 已取消"
finally:
candidate_file_items.clear()
if not file_items: if not file_items:
if has_episode_format_template and not matched_episode_format_template:
logger.info(f"{fileitem.path} 未匹配到集数定位模板,跳过整理")
if preview:
return True, {
"summary": {"total": 0, "success": 0, "failed": 0},
"items": [],
"message": "",
}
return True, ""
logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件") logger.warn(f"{fileitem.path} 没有找到可整理的媒体文件")
return False, f"{fileitem.name} 没有找到可整理的媒体文件" return False, f"{fileitem.name} 没有找到可整理的媒体文件"
file_items, inherited_meta_map = _plan_file_items(file_items) file_items, inherited_meta_map = _plan_file_items(file_items)
planned_file_count = len(file_items) planned_file_count = len(file_items)
if cleanup_dest_fileitem and planned_file_count and not preview:
state = StorageChain().delete_media_file(cleanup_dest_fileitem)
if not state:
return False, f"{cleanup_dest_fileitem.path} 删除失败"
if preview: if preview:
logger.info(f"正在预览 {planned_file_count} 个文件的整理路径...") logger.info(f"正在预览 {planned_file_count} 个文件的整理路径...")
else: else:
@@ -3394,6 +3430,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash: Optional[str] = None, download_hash: Optional[str] = None,
preview: Optional[bool] = False, preview: Optional[bool] = False,
sync_extra_files: Optional[bool] = True, sync_extra_files: Optional[bool] = True,
cleanup_dest_fileitem: Optional[FileItem] = None,
) -> Tuple[bool, Union[str, dict]]: ) -> Tuple[bool, Union[str, dict]]:
""" """
手动整理,支持复杂条件,带进度显示 手动整理,支持复杂条件,带进度显示
@@ -3417,6 +3454,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param download_hash: 下载任务哈希 :param download_hash: 下载任务哈希
:param preview: 是否仅预览 :param preview: 是否仅预览
:param sync_extra_files: 是否同步整理同媒体附加文件 :param sync_extra_files: 是否同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
""" """
logger.info(f"手动整理:{fileitem.path} ...") logger.info(f"手动整理:{fileitem.path} ...")
if tmdbid or doubanid: if tmdbid or doubanid:
@@ -3457,6 +3495,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash=download_hash, download_hash=download_hash,
preview=preview, preview=preview,
sync_extra_files=sync_extra_files, sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
if not state: if not state:
return False, errmsg return False, errmsg
@@ -3483,6 +3522,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash=download_hash, download_hash=download_hash,
preview=preview, preview=preview,
sync_extra_files=sync_extra_files, sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
return state, errmsg return state, errmsg
+105
View File
@@ -53,6 +53,111 @@ def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
assert captured["season"] == 1 assert captured["season"] == 1
def test_manual_transfer_from_history_passes_old_dest_cleanup_to_chain(monkeypatch):
history = SimpleNamespace(
status=0,
mode="copy",
src_fileitem={
"storage": "local",
"path": "/downloads/test.mkv",
"name": "test.mkv",
"type": "file",
},
dest_fileitem={
"storage": "local",
"path": "/library/test.mkv",
"name": "test.mkv",
"type": "file",
},
downloader="qbittorrent",
download_hash="abc123",
type=None,
tmdbid=None,
doubanid=None,
seasons=None,
episodes=None,
episode_group=None,
)
captured = {}
def fake_get(_db, logid):
assert logid == 1
return history
class FakeTransferChain:
def manual_transfer(self, **kwargs):
captured.update(kwargs)
return True, ""
monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get)
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
resp = manual_transfer(
transer_item=ManualTransferItem(logid=1),
background=False,
db=object(),
_="token",
)
assert resp.success is True
assert captured["fileitem"].path == "/downloads/test.mkv"
assert captured["cleanup_dest_fileitem"].path == "/library/test.mkv"
def test_manual_transfer_from_history_preview_does_not_cleanup_old_dest(monkeypatch):
history = SimpleNamespace(
status=0,
mode="copy",
src_fileitem={
"storage": "local",
"path": "/downloads/test.mkv",
"name": "test.mkv",
"type": "file",
},
dest_fileitem={
"storage": "local",
"path": "/library/test.mkv",
"name": "test.mkv",
"type": "file",
},
downloader="qbittorrent",
download_hash="abc123",
type=None,
tmdbid=None,
doubanid=None,
seasons=None,
episodes=None,
episode_group=None,
)
captured = {}
def fake_get(_db, logid):
assert logid == 1
return history
class FakeTransferChain:
def manual_transfer(self, **kwargs):
captured.update(kwargs)
return True, {
"summary": {"total": 0, "success": 0, "failed": 0},
"items": [],
"message": "",
}
monkeypatch.setattr("app.api.endpoints.transfer.TransferHistory.get", fake_get)
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
resp = manual_transfer(
transer_item=ManualTransferItem(logid=1, preview=True),
background=False,
db=object(),
_="token",
)
assert resp.success is True
assert captured["cleanup_dest_fileitem"] is None
def test_manual_transfer_preview_uses_explicit_fileitems_instead_of_directory(monkeypatch): def test_manual_transfer_preview_uses_explicit_fileitems_instead_of_directory(monkeypatch):
dir_item = { dir_item = {
"storage": "local", "storage": "local",
+11 -4
View File
@@ -807,7 +807,7 @@ class TransferJobManagerTest(unittest.TestCase):
self.assertEqual("", errmsg) self.assertEqual("", errmsg)
self.assertFalse(captured["sync_extra_files"]) self.assertFalse(captured["sync_extra_files"])
def test_do_transfer_keeps_manual_single_extra_file_when_epformat_misses(self): def test_do_transfer_skips_manual_single_file_when_epformat_misses(self):
chain = make_transfer_chain() chain = make_transfer_chain()
planned = [] planned = []
subtitle_fileitem = make_fileitem( subtitle_fileitem = make_fileitem(
@@ -854,13 +854,21 @@ class TransferJobManagerTest(unittest.TestCase):
fileitem=subtitle_fileitem, fileitem=subtitle_fileitem,
background=False, background=False,
manual=True, manual=True,
preview=True,
sync_extra_files=True, sync_extra_files=True,
epformat=EpisodeFormat(format="Show - {ep}.mkv"), epformat=EpisodeFormat(format="Show - {ep}.mkv"),
) )
self.assertTrue(state) self.assertTrue(state)
self.assertEqual("", errmsg) self.assertEqual(
self.assertEqual([(subtitle_fileitem.path, 1)], planned) {
"summary": {"total": 0, "success": 0, "failed": 0},
"items": [],
"message": "",
},
errmsg,
)
self.assertEqual([], planned)
def test_do_transfer_syncs_extra_files_when_epformat_only_matches_main_video(self): def test_do_transfer_syncs_extra_files_when_epformat_only_matches_main_video(self):
chain = make_transfer_chain() chain = make_transfer_chain()
@@ -936,7 +944,6 @@ class TransferJobManagerTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
[ [
(main_fileitem.path, 1), (main_fileitem.path, 1),
(subtitle_fileitem.path, 1),
], ],
planned, planned,
) )
+448 -1
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from app.chain.transfer import JobManager, TransferChain from app.chain.transfer import JobManager, TransferChain
from app.core.config import settings from app.core.config import settings
from app.schemas import FileItem from app.schemas import EpisodeFormat, FileItem
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -356,3 +356,450 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch
assert errmsg == "" assert errmsg == ""
assert planned == [main_fileitem.path, subtitle_fileitem.path] assert planned == [main_fileitem.path, subtitle_fileitem.path]
assert list_files_calls == [(parent_fileitem.path, False)] assert list_files_calls == [(parent_fileitem.path, False)]
def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch):
"""
存在集数定位模板时,不匹配模板的附加文件不应被主视频带入整理计划。
"""
chain = make_transfer_chain()
planned = []
main_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.mkv"
)
subtitle_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.sc.ass"
)
parent_fileitem = FileItem(
storage="local",
path="/downloads/Test Show (2026)/",
type="dir",
name="Test Show (2026)",
)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda fileitem, predicate: [
(main_fileitem, False),
(subtitle_fileitem, False),
],
)
monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True)
monkeypatch.setattr(
chain,
"_TransferChain__register_scrape_batch_task",
lambda task: None,
)
monkeypatch.setattr(
chain,
"_TransferChain__close_scrape_batch",
lambda batch_id: None,
)
def fake_handle_transfer(task, callback=None):
"""
记录进入整理执行阶段的文件。
"""
planned.append(task.fileitem.path)
return True, ""
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer)
monkeypatch.setattr(
"app.chain.transfer.TransferHistoryOper",
lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None),
)
monkeypatch.setattr(
"app.chain.transfer.DownloadHistoryOper",
lambda: SimpleNamespace(
get_by_hash=lambda download_hash: None,
get_file_by_fullpath=lambda fullpath: None,
get_files_by_savepath=lambda savepath: [],
get_by_path=lambda path: None,
),
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1))
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=parent_fileitem,
background=False,
sync_extra_files=True,
epformat=EpisodeFormat(format="Show - {ep}.mkv"),
)
assert state is True
assert errmsg == ""
assert planned == [main_fileitem.path]
def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch):
"""
附加文件自身匹配集数定位模板时,仍可跟随同名主视频整理。
"""
chain = make_transfer_chain()
planned = []
main_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.mkv"
)
subtitle_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.ass"
)
parent_fileitem = FileItem(
storage="local",
path="/downloads/Test Show (2026)/",
type="dir",
name="Test Show (2026)",
)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda fileitem, predicate: [
(main_fileitem, False),
(subtitle_fileitem, False),
],
)
monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True)
monkeypatch.setattr(
chain,
"_TransferChain__register_scrape_batch_task",
lambda task: None,
)
monkeypatch.setattr(
chain,
"_TransferChain__close_scrape_batch",
lambda batch_id: None,
)
def fake_handle_transfer(task, callback=None):
"""
记录进入整理执行阶段的文件和集数。
"""
planned.append((task.fileitem.path, task.meta.begin_episode))
return True, ""
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer)
monkeypatch.setattr(
"app.chain.transfer.TransferHistoryOper",
lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None),
)
monkeypatch.setattr(
"app.chain.transfer.DownloadHistoryOper",
lambda: SimpleNamespace(
get_by_hash=lambda download_hash: None,
get_file_by_fullpath=lambda fullpath: None,
get_files_by_savepath=lambda savepath: [],
get_by_path=lambda path: None,
),
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1))
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=parent_fileitem,
background=False,
sync_extra_files=True,
epformat=EpisodeFormat(format="Show - {ep}.{a}"),
)
assert state is True
assert errmsg == ""
assert planned == [
(main_fileitem.path, 1),
(subtitle_fileitem.path, 1),
]
def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypatch):
"""
单独整理匹配模板的字幕时,同名主视频只提供识别上下文,不会被额外加入整理计划。
"""
chain = make_transfer_chain()
planned = []
main_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 02.mkv"
)
subtitle_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 02.ass"
)
parent_fileitem = FileItem(
storage="local",
path="/downloads/Test Show (2026)/",
type="dir",
name="Test Show (2026)",
)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda fileitem, predicate: [(subtitle_fileitem, False)],
)
monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True)
monkeypatch.setattr(
chain,
"_TransferChain__register_scrape_batch_task",
lambda task: None,
)
monkeypatch.setattr(
chain,
"_TransferChain__close_scrape_batch",
lambda batch_id: None,
)
def fake_handle_transfer(task, callback=None):
"""
记录单独字幕整理时实际使用的集数。
"""
planned.append((task.fileitem.path, task.meta.begin_episode))
return True, ""
def fake_meta_info_path(path, custom_words=None):
"""
模拟字幕自身识别不准,但同名主视频可提供正确集数。
"""
file_name = Path(path).name
if file_name.endswith(".mkv"):
return FakeMeta(2)
return FakeMeta(1)
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer)
monkeypatch.setattr(
"app.chain.transfer.TransferHistoryOper",
lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None),
)
monkeypatch.setattr(
"app.chain.transfer.DownloadHistoryOper",
lambda: SimpleNamespace(
get_by_hash=lambda download_hash: None,
get_file_by_fullpath=lambda fullpath: None,
get_files_by_savepath=lambda savepath: [],
get_by_path=lambda path: None,
),
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr(
"app.chain.transfer.StorageChain",
lambda: SimpleNamespace(
get_parent_item=lambda fileitem: parent_fileitem,
list_files=lambda fileitem, recursion=False: [
main_fileitem,
subtitle_fileitem,
],
),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", fake_meta_info_path)
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=subtitle_fileitem,
background=False,
sync_extra_files=True,
epformat=EpisodeFormat(format="Show - {ep}.ass"),
)
assert state is True
assert errmsg == ""
assert planned == [(subtitle_fileitem.path, 2)]
def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeypatch):
"""
旧目标文件只应在模板筛选后确实存在待整理任务时清理。
"""
chain = make_transfer_chain()
delete_calls = []
planned = []
main_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.mkv"
)
old_dest_fileitem = make_fileitem(
"/library/Test Show/Show - 01.mkv"
)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda fileitem, predicate: [(main_fileitem, False)],
)
monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True)
monkeypatch.setattr(
chain,
"_TransferChain__register_scrape_batch_task",
lambda task: None,
)
monkeypatch.setattr(
chain,
"_TransferChain__close_scrape_batch",
lambda batch_id: None,
)
def fake_handle_transfer(task, callback=None):
"""
记录旧目标清理后的整理任务。
"""
planned.append(task.fileitem.path)
return True, ""
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer)
monkeypatch.setattr(
"app.chain.transfer.TransferHistoryOper",
lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None),
)
monkeypatch.setattr(
"app.chain.transfer.DownloadHistoryOper",
lambda: SimpleNamespace(
get_by_hash=lambda download_hash: None,
get_file_by_fullpath=lambda fullpath: None,
get_files_by_savepath=lambda savepath: [],
get_by_path=lambda path: None,
),
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr(
"app.chain.transfer.StorageChain",
lambda: SimpleNamespace(
delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True,
),
)
monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None: FakeMeta(1))
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=main_fileitem,
background=False,
epformat=EpisodeFormat(format="Show - {ep}.mkv"),
cleanup_dest_fileitem=old_dest_fileitem,
)
assert state is True
assert errmsg == ""
assert delete_calls == [old_dest_fileitem.path]
assert planned == [main_fileitem.path]
def test_cleanup_dest_fileitem_is_kept_when_episode_format_matches_nothing(monkeypatch):
"""
集数定位模板匹配不到文件时,不应清理历史记录中的旧目标文件。
"""
chain = make_transfer_chain()
delete_calls = []
source_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.sc.ass"
)
old_dest_fileitem = make_fileitem(
"/library/Test Show/Show - 01.sc.ass"
)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda fileitem, predicate: [(source_fileitem, False)],
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
monkeypatch.setattr(
"app.chain.transfer.StorageChain",
lambda: SimpleNamespace(
delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True,
),
)
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=source_fileitem,
background=False,
epformat=EpisodeFormat(format="Show - {ep}.mkv"),
cleanup_dest_fileitem=old_dest_fileitem,
)
assert state is True
assert errmsg == ""
assert delete_calls == []
def test_episode_format_matched_but_filtered_by_size_returns_failure(monkeypatch):
"""
文件名匹配集数定位模板但被大小过滤时,不应误报为模板无匹配的安全跳过。
"""
chain = make_transfer_chain()
source_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.mkv"
)
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
lambda fileitem, predicate: [(source_fileitem, False)],
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=source_fileitem,
background=False,
epformat=EpisodeFormat(format="Show - {ep}.mkv"),
min_filesize=2,
)
assert state is False
assert errmsg == f"{source_fileitem.name} 没有找到可整理的媒体文件"
def test_candidate_collection_checks_continue_callback(monkeypatch):
"""
候选文件收集阶段应响应取消,避免大目录或远程存储继续完整遍历。
"""
chain = make_transfer_chain()
source_fileitem = make_fileitem(
"/downloads/Test Show (2026)/Show - 01.mkv"
)
callback_calls = []
def fake_get_trans_fileitems(fileitem, predicate):
"""
模拟递归收集候选文件时调用 predicate。
"""
callback_calls.append("collect")
predicate(source_fileitem, False)
return [(source_fileitem, False)]
monkeypatch.setattr(
chain,
"_TransferChain__get_trans_fileitems",
fake_get_trans_fileitems,
)
monkeypatch.setattr(
"app.chain.transfer.SystemConfigOper",
lambda: SimpleNamespace(get=lambda key: None),
)
state, errmsg = TransferChain.do_transfer(
chain,
fileitem=source_fileitem,
background=False,
continue_callback=lambda: False,
)
assert state is False
assert errmsg == f"{source_fileitem.name} 已取消"
assert callback_calls == ["collect"]