fix: build complete transfer result at source

This commit is contained in:
jxxghp
2026-05-20 22:09:57 +08:00
parent 014dc2884c
commit 617692616c
5 changed files with 190 additions and 259 deletions

View File

@@ -871,9 +871,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
整理完成后处理
"""
if transferinfo:
transferinfo.ensure_target_items()
# 状态
ret_status = True
# 错误信息
@@ -1162,7 +1159,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
"""
if not transferinfo:
return None
transferinfo.ensure_target_items()
if transferinfo.target_diritem and transferinfo.target_diritem.path:
return transferinfo.target_diritem.path
if transferinfo.target_item and transferinfo.target_item.path:
@@ -1171,30 +1167,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return Path(transferinfo.file_list_new[0]).parent.as_posix()
return None
def __build_transfer_target_diritem(
self, transferinfo: Optional[TransferInfo]
) -> Optional[FileItem]:
"""
构建整理目标目录项,避免成功结果缺少 target_diritem 时阻断后续流程。
"""
if not transferinfo:
return None
transferinfo.ensure_target_items()
if transferinfo.target_diritem:
return transferinfo.target_diritem
target_dir_path = self.__get_transfer_target_dir_path(transferinfo)
if not target_dir_path:
return None
target_path = Path(target_dir_path)
storage = transferinfo.target_item.storage if transferinfo.target_item else "local"
return FileItem(
storage=storage,
path=target_dir_path,
type="dir",
name=target_path.name,
basename=target_path.stem,
)
def put_to_queue(self, task: TransferTask) -> bool:
"""
添加到待整理队列
@@ -1248,7 +1220,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
):
return
target_diritem = self.__build_transfer_target_diritem(transferinfo)
target_diritem = transferinfo.target_diritem
if not target_diritem:
return
@@ -1311,7 +1283,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
):
return
target_diritem = self.__build_transfer_target_diritem(transferinfo)
target_diritem = transferinfo.target_diritem
if not target_diritem:
return

View File

@@ -127,6 +127,41 @@ class TransHandler:
size=size if item_type == "file" else None,
)
@staticmethod
def __build_transfer_target_diritem(
target_storage: str, target_path: Path
) -> FileItem:
"""
按已确认的目标目录路径构造整理结果目录项。
"""
return FileItem(
storage=target_storage,
path=TransHandler.__format_dir_path(target_path),
name=target_path.name,
basename=target_path.stem,
type="dir",
)
@staticmethod
def __format_dir_path(path: Path) -> str:
"""
按 FileItem 目录路径约定返回以斜杠结尾的路径。
"""
path_str = path.as_posix()
if path_str != "/" and not path_str.endswith("/"):
return f"{path_str}/"
return path_str
@staticmethod
def __normalize_dir_path(path: Optional[str]) -> str:
"""
比较目录路径时忽略尾部斜杠,兼容不同存储返回的目录项格式。
"""
if not path:
return ""
path_str = Path(str(path)).as_posix().rstrip("/")
return path_str or "/"
def transfer_media(
self,
fileitem: FileItem,
@@ -281,6 +316,10 @@ class TransHandler:
return result
logger.info(f"文件夹 {fileitem.path} 整理成功")
if self.__normalize_dir_path(new_diritem.path) != self.__normalize_dir_path(new_path.as_posix()):
new_diritem = self.__build_transfer_target_diritem(
target_storage=target_storage, target_path=new_path
)
# 返回整理后的路径
self.__update_result(
result=result,
@@ -401,6 +440,11 @@ class TransHandler:
need_notify=need_notify,
)
return result
# 目标目录已创建成功但元数据可能短暂缺字段,整理结果直接按目标路径补全。
if self.__normalize_dir_path(target_diritem.path) != self.__normalize_dir_path(folder_path.as_posix()):
target_diritem = self.__build_transfer_target_diritem(
target_storage=target_storage, target_path=folder_path
)
# 判断是否要覆盖,附加文件强制覆盖
overflag = False

View File

@@ -134,87 +134,6 @@ class TransferInfo(BaseModel):
# 是否需要通知
need_notify: Optional[bool] = False
def ensure_target_items(self):
"""
补齐整理目标文件项和目录项,兼容 OpenList 等成功后元数据延迟可见的存储。
"""
if not self.target_item or not self.target_item.path:
self.target_item = self.__build_target_item()
if not self.target_diritem or not self.target_diritem.path:
self.target_diritem = self.__build_target_diritem()
return self
def __build_target_item(self) -> Optional[FileItem]:
"""
根据目标文件清单构造整理目标文件项。
"""
if self.target_item and self.target_item.path:
return self.target_item
if not self.file_list_new:
return self.target_item
target_path = self.file_list_new[0]
if not target_path:
return self.target_item
path = Path(str(target_path))
source_item = self.fileitem
return FileItem(
storage=self.__get_target_storage(),
path=path.as_posix(),
type=source_item.type if source_item and source_item.type else "file",
name=path.name,
basename=path.stem,
extension=path.suffix.lstrip("."),
size=source_item.size if source_item else None,
modify_time=source_item.modify_time if source_item else None,
thumbnail=source_item.thumbnail if source_item else None,
)
def __build_target_diritem(self) -> Optional[FileItem]:
"""
根据目标文件项或目标文件清单构造整理目标目录项。
"""
if self.target_diritem and self.target_diritem.path:
return self.target_diritem
target_dir_path = self.__get_target_dir_path()
if not target_dir_path:
return self.target_diritem
path = Path(str(target_dir_path))
return FileItem(
storage=self.__get_target_storage(),
path=path.as_posix(),
type="dir",
name=path.name,
basename=path.stem,
)
def __get_target_dir_path(self) -> Optional[str]:
"""
从已知整理结果中推导媒体目标目录路径。
"""
if self.target_item and self.target_item.path:
if self.target_item.type == "dir":
return self.target_item.path
return Path(str(self.target_item.path)).parent.as_posix()
if self.file_list_new:
return Path(str(self.file_list_new[0])).parent.as_posix()
return None
def __get_target_storage(self) -> str:
"""
从目标项、目标目录项或源文件项中推导目标存储标识。
"""
if self.target_item and self.target_item.storage:
return self.target_item.storage
if self.target_diritem and self.target_diritem.storage:
return self.target_diritem.storage
if self.fileitem and self.fileitem.storage:
return self.fileitem.storage
return "local"
def to_dict(self):
"""
返回字典

View File

@@ -1,97 +0,0 @@
import unittest
from app.schemas import FileItem, TransferInfo
class TransferInfoTest(unittest.TestCase):
def test_ensure_target_items_fills_missing_target_items_from_target_path(self):
"""
整理结果只有目标路径清单时,应补齐目标文件项和目录项。
"""
transferinfo = TransferInfo(
success=True,
fileitem=FileItem(
storage="alist",
path="/downloads/Test.Show.S01E01.mkv",
type="file",
name="Test.Show.S01E01.mkv",
size=1024,
),
file_list_new=[
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
],
transfer_type="move",
)
transferinfo.ensure_target_items()
self.assertIsNotNone(transferinfo.target_item)
self.assertIsNotNone(transferinfo.target_diritem)
self.assertEqual(
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
transferinfo.target_item.path,
)
self.assertEqual(
"/library/Test Show (2026)/Season 1",
transferinfo.target_diritem.path,
)
self.assertEqual("alist", transferinfo.target_item.storage)
self.assertEqual("alist", transferinfo.target_diritem.storage)
def test_ensure_target_items_keeps_new_model_initial_state(self):
"""
新建整理结果模型不应立即改写目标项,避免影响失败记录等非事件流程。
"""
transferinfo = TransferInfo(
success=True,
fileitem=FileItem(
storage="alist",
path="/downloads/Test.Show.S01E01.mkv",
type="file",
name="Test.Show.S01E01.mkv",
),
file_list_new=[
"/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv"
],
transfer_type="move",
)
self.assertIsNone(transferinfo.target_item)
self.assertIsNone(transferinfo.target_diritem)
def test_ensure_target_items_fills_missing_target_diritem_from_target_item(self):
"""
目标文件项已存在但目录项缺失时,应从目标文件路径推导目录项。
"""
transferinfo = TransferInfo(
success=True,
fileitem=FileItem(
storage="alist",
path="/downloads/Test.Show.S01E02.mkv",
type="file",
name="Test.Show.S01E02.mkv",
),
target_item=FileItem(
storage="alist",
path="/library/Test Show (2026)/Season 1/Test.Show.S01E02.mkv",
type="file",
name="Test.Show.S01E02.mkv",
),
file_list_new=[
"/library/Test Show (2026)/Season 1/Test.Show.S01E02.mkv"
],
transfer_type="move",
)
transferinfo.ensure_target_items()
self.assertIsNotNone(transferinfo.target_diritem)
self.assertEqual(
"/library/Test Show (2026)/Season 1",
transferinfo.target_diritem.path,
)
self.assertEqual("alist", transferinfo.target_diritem.storage)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,6 +4,8 @@ from types import SimpleNamespace
from unittest.mock import patch, MagicMock
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaVideo
from app.chain.transfer import JobManager, TransferChain
from app.modules.filemanager.transhandler import TransHandler
from app.schemas import EpisodeFormat, FileItem, TransferInfo, TransferTask
@@ -73,6 +75,20 @@ class FakeMedia:
}
def make_media_info() -> MediaInfo:
media = MediaInfo()
media.type = MediaType.TV
media.title = "Test Show"
media.title_year = "Test Show (2026)"
media.year = "2026"
media.tmdb_id = 12345
media.category = ""
media.actors = []
media.season_years = {}
media.vote_average = 0
return media
def make_task(episode: int, season: int = 1) -> TransferTask:
name = f"Test.Show.S{season:02d}E{episode:02d}.mkv"
return TransferTask(
@@ -176,6 +192,134 @@ class TransferJobManagerTest(unittest.TestCase):
self.assertEqual("file", new_item.type)
self.assertEqual(1024, new_item.size)
def test_transfer_media_passes_complete_result_when_target_metadata_is_delayed(self):
"""
整理成功时应在结果源头带上完整目标项,回调和事件不再二次拼装。
"""
handler = TransHandler()
source_item = FileItem(
storage="alist",
path="/downloads/Test.Show.S01E01.mkv",
type="file",
name="Test.Show.S01E01.mkv",
basename="Test.Show.S01E01",
extension="mkv",
size=1024,
modify_time=1715939275.0,
)
target_path = Path("/library")
target_file = Path(
"/library/Test.Show.S01E01.mkv"
)
target_folder = FileItem(
storage="alist",
type="dir",
)
target_item = FileItem(
storage="alist",
path=target_file.as_posix(),
type="file",
name=target_file.name,
basename=target_file.stem,
extension="mkv",
size=1024,
)
source_oper = SimpleNamespace(
is_support_transtype=lambda transfer_type: True,
move=lambda fileitem, path, name: True,
)
target_oper = SimpleNamespace(
get_folder=lambda path: target_folder,
get_item=lambda path: None,
)
with patch.object(
TransHandler, "get_rename_path", return_value=target_file
), patch(
"app.modules.filemanager.transhandler.DirectoryHelper.get_media_root_path",
return_value=Path("/library"),
), patch.object(
TransHandler,
"_TransHandler__transfer_command",
return_value=(target_item, ""),
), patch("app.modules.filemanager.transhandler.eventmanager") as eventmanager_mock:
eventmanager_mock.send_event.return_value = None
transferinfo = handler.transfer_media(
fileitem=source_item,
in_meta=MetaVideo("Test.Show.S01E01"),
mediainfo=make_media_info(),
target_storage="alist",
target_path=target_path,
transfer_type="move",
source_oper=source_oper,
target_oper=target_oper,
need_scrape=True,
need_notify=True,
)
self.assertTrue(transferinfo.success)
self.assertEqual(target_item, transferinfo.target_item)
self.assertIsNotNone(transferinfo.target_diritem)
self.assertEqual("/library/", transferinfo.target_diritem.path)
self.assertEqual("alist", transferinfo.target_diritem.storage)
self.assertEqual("dir", transferinfo.target_diritem.type)
def test_success_callback_uses_transfer_result_target_diritem(self):
"""
回调发送刮削事件时应直接使用整理结果里的目标目录项。
"""
chain = make_transfer_chain()
chain.eventmanager = MagicMock()
chain.transfer_completed = lambda *args, **kwargs: None
task = make_task(1)
task.mediainfo = FakeMedia()
task.background = False
task.manual = True
self.assertTrue(chain._TransferChain__put_to_jobview(task))
target_diritem = FileItem(
storage="alist",
path="/library/Test Show (2026)/Season 1/",
type="dir",
name="Season 1",
)
target_item = FileItem(
storage="alist",
path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
type="file",
name="Test.Show.S01E01.mkv",
extension="mkv",
)
transferinfo = TransferInfo(
success=True,
fileitem=task.fileitem,
target_item=target_item,
target_diritem=target_diritem,
file_list_new=[target_item.path],
transfer_type="copy",
need_scrape=True,
need_notify=False,
)
with patch(
"app.chain.transfer.TransferHistoryOper",
return_value=SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)),
):
state, errmsg = chain._TransferChain__default_callback(task, transferinfo)
self.assertTrue(state)
self.assertEqual("", errmsg)
metadata_calls = [
call
for call in chain.eventmanager.send_event.call_args_list
if call.args[0] == EventType.MetadataScrape
]
self.assertEqual(1, len(metadata_calls))
event_data = metadata_calls[0].args[1]
self.assertEqual(target_diritem, event_data["fileitem"])
self.assertEqual([target_item.path], event_data["file_list"])
def test_manual_episode_offset_applies_once(self):
chain = make_transfer_chain()
source_fileitem = make_fileitem("/downloads/Test.Show.2026.S01E14.mkv")
@@ -791,56 +935,5 @@ class TransferJobManagerTest(unittest.TestCase):
event_data["file_list"],
)
def test_success_callback_handles_missing_target_diritem(self):
"""
成功结果缺少目标目录项时,回调不应把已入库任务误判为失败。
"""
chain = make_transfer_chain()
chain.eventmanager = MagicMock()
chain.transfer_completed = lambda *args, **kwargs: None
task = make_task(1)
task.mediainfo = FakeMedia()
task.background = False
task.manual = True
self.assertTrue(chain._TransferChain__put_to_jobview(task))
target_item = FileItem(
storage="alist",
path="/library/Test Show (2026)/Season 1/Test.Show.S01E01.mkv",
type="file",
name="Test.Show.S01E01.mkv",
extension="mkv",
)
transferinfo = TransferInfo(
success=True,
fileitem=task.fileitem,
target_item=target_item,
file_list_new=[target_item.path],
transfer_type="copy",
need_scrape=True,
need_notify=False,
)
with patch(
"app.chain.transfer.TransferHistoryOper",
return_value=SimpleNamespace(add_success=lambda **kwargs: SimpleNamespace(id=1)),
):
state, errmsg = chain._TransferChain__default_callback(task, transferinfo)
self.assertTrue(state)
self.assertEqual("", errmsg)
metadata_calls = [
call
for call in chain.eventmanager.send_event.call_args_list
if call.args[0] == EventType.MetadataScrape
]
self.assertEqual(1, len(metadata_calls))
event_data = metadata_calls[0].args[1]
self.assertEqual("alist", event_data["fileitem"].storage)
self.assertEqual("/library/Test Show (2026)/Season 1", event_data["fileitem"].path)
self.assertEqual([target_item.path], event_data["file_list"])
if __name__ == "__main__":
unittest.main()