mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
修复手动整理按集数定位模板过滤 (#6043)
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user