diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 8b8742d1..7ca1b9fb 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -240,6 +240,40 @@ def match_manual_transfer_target_path( ) +@router.post( + "/manual/history", + summary="查询手动转移成功历史", + response_model=schemas.Response, +) +def query_manual_transfer_history( + transer_item: ManualTransferItem, + db: Session = Depends(get_db), + _: User = Depends(get_current_active_manage_user), +) -> Any: + """ + 查询文件或目录命中的成功整理记录。 + + :param transer_item: 手工整理项 + :param db: 数据库 + :param _: Token校验 + """ + src_fileitems, error_message = _resolve_manual_transfer_source_fileitems( + transer_item=transer_item, + db=db, + ) + if error_message: + return schemas.Response(success=False, message=error_message) + + histories = TransferChain().get_manual_transfer_histories( + _deduplicate_fileitems(src_fileitems) + ) + history_info = schemas.ManualTransferHistoryInfo( + reorganize=bool(histories), + history_count=len(histories), + ) + return schemas.Response(success=True, data=history_info.model_dump()) + + @router.post("/manual", summary="手动转移", response_model=schemas.Response) def manual_transfer( transer_item: ManualTransferItem, @@ -278,7 +312,11 @@ def manual_transfer( else: # 源路径 src_fileitems = [FileItem(**history.src_fileitem)] - if history.dest_fileitem and not transer_item.preview: + if ( + history.dest_fileitem + and not transer_item.preview + and not transer_item.reorganize + ): cleanup_dest_fileitem = FileItem(**history.dest_fileitem) # 从历史数据获取信息 @@ -435,6 +473,7 @@ def manual_transfer( downloader=downloader, download_hash=download_hash, preview=transer_item.preview, + reorganize=transer_item.reorganize, sync_extra_files=False, cleanup_dest_fileitem=cleanup_dest_fileitem, ) @@ -521,6 +560,7 @@ def manual_transfer( downloader=downloader, download_hash=download_hash, preview=transer_item.preview, + reorganize=transer_item.reorganize, sync_extra_files=True, cleanup_dest_fileitem=cleanup_dest_fileitem, ) diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 7c73babf..ad0568dd 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -2636,6 +2636,97 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): else None ) + @staticmethod + def _is_successful_move_history(history: Optional[TransferHistory]) -> bool: + """判断历史记录是否为已成功完成的移动类整理。""" + return bool( + history + and history.status + and history.mode + and "move" in history.mode + ) + + def _get_manual_transfer_history( + self, + fileitem: FileItem, + transfer_history_oper: TransferHistoryOper, + include_move_dest: bool = False, + ) -> Optional[TransferHistory]: + """查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。""" + history = transfer_history_oper.get_by_src( + fileitem.path, + storage=fileitem.storage, + ) + if history or not include_move_dest: + return history + + history = transfer_history_oper.get_by_dest( + fileitem.path, + storage=fileitem.storage, + ) + return history if self._is_successful_move_history(history) else None + + def get_manual_transfer_histories( + self, + fileitems: List[FileItem], + ) -> List[TransferHistory]: + """ + 查询文件或目录命中的成功整理记录,供手动整理界面显示重整状态。 + + :param fileitems: 待查询的文件或目录项 + :return: 去重后的成功整理记录 + """ + transfer_history_oper = TransferHistoryOper() + histories: Dict[int, TransferHistory] = {} + for fileitem in fileitems or []: + if not fileitem or not fileitem.path: + continue + storage = fileitem.storage or "local" + if fileitem.type == "dir": + matched_histories = transfer_history_oper.list_success_by_src( + fileitem.path, + storage=storage, + recursive=True, + ) + matched_histories.extend( + transfer_history_oper.list_success_move_by_dest( + fileitem.path, + storage=storage, + recursive=True, + ) + ) + else: + history = self._get_manual_transfer_history( + fileitem=fileitem, + transfer_history_oper=transfer_history_oper, + include_move_dest=True, + ) + matched_histories = [history] if history and history.status else [] + + for history in matched_histories: + histories[history.id] = history + return list(histories.values()) + + @staticmethod + def _delete_manual_transfer_history( + history: TransferHistory, + transfer_history_oper: TransferHistoryOper, + ) -> Tuple[bool, str]: + """删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。""" + if ( + history.dest_fileitem + and not TransferChain._is_successful_move_history(history) + ): + dest_fileitem = FileItem(**history.dest_fileitem) + storage_chain = StorageChain() + if ( + storage_chain.exists(dest_fileitem) + and not storage_chain.delete_media_file(dest_fileitem) + ): + return False, f"{dest_fileitem.path} 删除失败" + transfer_history_oper.delete(history.id) + return True, "" + def do_transfer( self, fileitem: FileItem, @@ -2661,6 +2752,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): sync_extra_files: Optional[bool] = False, cleanup_dest_fileitem: Optional[FileItem] = None, continue_callback: Callable = None, + reorganize: Optional[bool] = False, ) -> Tuple[bool, Union[str, dict]]: """ 执行一个复杂目录的整理操作 @@ -2684,6 +2776,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param background: 是否后台运行 :param manual: 是否手动整理 :param preview: 是否仅预览 + :param reorganize: 是否清理已有成功记录后重新整理 :param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件 :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 :param continue_callback: 继续处理回调 @@ -3114,11 +3207,33 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): raise OperationInterrupted() file_path = Path(file_item.path) - # 整理成功的不再处理 - if not force and not preview: - transferd = TransferHistoryOper().get_by_src( - file_item.path, storage=file_item.storage + # 自动整理继续按全部历史去重;手动整理可清理失败记录,或按用户确认清理成功记录。 + if (not force or reorganize) and not preview: + transfer_history_oper = TransferHistoryOper() + transferd = self._get_manual_transfer_history( + fileitem=file_item, + transfer_history_oper=transfer_history_oper, + include_move_dest=manual and reorganize, ) + if transferd: + should_reorganize = manual and ( + reorganize or not transferd.status + ) + if should_reorganize: + state, message = self._delete_manual_transfer_history( + history=transferd, + transfer_history_oper=transfer_history_oper, + ) + if not state: + all_success = False + logger.error(message) + err_msgs.append(message) + continue + logger.info( + f"{file_item.path} 已清理旧整理记录,继续重新整理。" + ) + transferd = None + if transferd: skipped_history_count += 1 if not transferd.status: @@ -3575,6 +3690,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): cleanup_dest_fileitem: Optional[FileItem] = None, bangumiid: Optional[int] = None, anilistid: Optional[int] = None, + reorganize: Optional[bool] = False, ) -> Tuple[bool, Union[str, dict]]: """ 手动整理,支持复杂条件,带进度显示 @@ -3601,6 +3717,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param downloader: 下载器名称 :param download_hash: 下载任务哈希 :param preview: 是否仅预览 + :param reorganize: 是否清理已有成功记录后重新整理 :param sync_extra_files: 是否同步整理同媒体附加文件 :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 """ @@ -3651,6 +3768,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): downloader=downloader, download_hash=download_hash, preview=preview, + reorganize=reorganize, sync_extra_files=sync_extra_files, cleanup_dest_fileitem=cleanup_dest_fileitem, ) @@ -3679,6 +3797,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): downloader=downloader, download_hash=download_hash, preview=preview, + reorganize=reorganize, sync_extra_files=sync_extra_files, cleanup_dest_fileitem=cleanup_dest_fileitem, ) diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 339e61ac..136aca75 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -1,6 +1,7 @@ import re import time -from typing import Optional +from pathlib import Path +from typing import List, Optional from sqlalchemy import Boolean, Column, Index, Integer, JSON, String, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -184,7 +185,17 @@ class TransferHistory(Base): @classmethod @db_query - def get_by_src(cls, db: Session, src: str, storage: Optional[str] = None): + def get_by_src( + cls, db: Session, src: str, storage: Optional[str] = None + ) -> Optional["TransferHistory"]: + """ + 按源路径和存储查询单条整理记录。 + + :param db: 数据库会话 + :param src: 源路径 + :param storage: 源存储类型 + :return: 命中的整理记录,未命中时返回 None + """ if storage: return db.query(cls).filter(cls.src == src, cls.src_storage == storage).first() @@ -193,8 +204,104 @@ class TransferHistory(Base): @classmethod @db_query - def get_by_dest(cls, db: Session, dest: str): - return db.query(cls).filter(cls.dest == dest).first() + def get_by_dest( + cls, db: Session, dest: str, storage: Optional[str] = None + ) -> Optional["TransferHistory"]: + """ + 按目标路径和存储查询单条整理记录。 + + :param db: 数据库会话 + :param dest: 目标路径 + :param storage: 目标存储类型 + :return: 命中的整理记录,未命中时返回 None + """ + query = db.query(cls).filter(cls.dest == dest) + if storage: + query = query.filter(cls.dest_storage == storage) + return query.first() + + @classmethod + @db_query + def list_success_by_src( + cls, + db: Session, + src: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> List["TransferHistory"]: + """ + 按源路径查询成功整理记录,目录模式仅匹配其直接或间接子项。 + + :param db: 数据库会话 + :param src: 源路径 + :param storage: 源存储类型 + :param recursive: 是否递归匹配目录子项 + :return: 命中的成功整理记录 + """ + normalized_src = ( + Path(str(src).replace("\\", "/")).as_posix().rstrip("/") or "/" + ) + query = db.query(cls).filter(cls.status.is_(True)) + if recursive: + escaped_src = ( + normalized_src.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + query = query.filter( + or_( + cls.src == normalized_src, + cls.src.like(f"{escaped_src.rstrip('/')}/%", escape="\\"), + ) + ) + else: + query = query.filter(cls.src == normalized_src) + if storage: + query = query.filter(cls.src_storage == storage) + return query.all() + + @classmethod + @db_query + def list_success_move_by_dest( + cls, + db: Session, + dest: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> List["TransferHistory"]: + """ + 按目标路径查询成功移动记录,供从媒体库现址发起重新整理时识别历史。 + + :param db: 数据库会话 + :param dest: 目标路径 + :param storage: 目标存储类型 + :param recursive: 是否递归匹配目录子项 + :return: 命中的成功移动记录 + """ + normalized_dest = ( + Path(str(dest).replace("\\", "/")).as_posix().rstrip("/") or "/" + ) + query = db.query(cls).filter( + cls.status.is_(True), + cls.mode.contains("move"), + ) + if recursive: + escaped_dest = ( + normalized_dest.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + query = query.filter( + or_( + cls.dest == normalized_dest, + cls.dest.like(f"{escaped_dest.rstrip('/')}/%", escape="\\"), + ) + ) + else: + query = query.filter(cls.dest == normalized_dest) + if storage: + query = query.filter(cls.dest_storage == storage) + return query.all() @classmethod @db_query diff --git a/app/db/transferhistory_oper.py b/app/db/transferhistory_oper.py index cd5a4007..4ae568aa 100644 --- a/app/db/transferhistory_oper.py +++ b/app/db/transferhistory_oper.py @@ -78,20 +78,68 @@ class TransferHistoryOper(DbOper): """ return TransferHistory.list_by_title(self._db, title) - def get_by_src(self, src: str, storage: Optional[str] = None) -> TransferHistory: + def get_by_src( + self, src: str, storage: Optional[str] = None + ) -> Optional[TransferHistory]: """ 按源查询转移记录 :param src: 数据key :param storage: 存储类型 + :return: 命中的整理记录,未命中时返回 None """ return TransferHistory.get_by_src(self._db, src, storage) - def get_by_dest(self, dest: str) -> TransferHistory: + def get_by_dest( + self, dest: str, storage: Optional[str] = None + ) -> Optional[TransferHistory]: """ 按转移路径查询转移记录 :param dest: 数据key + :param storage: 存储类型 """ - return TransferHistory.get_by_dest(self._db, dest) + return TransferHistory.get_by_dest(self._db, dest, storage) + + def list_success_by_src( + self, + src: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> List[TransferHistory]: + """ + 按源路径查询成功整理记录。 + + :param src: 源路径 + :param storage: 源存储类型 + :param recursive: 是否递归匹配目录子项 + :return: 命中的成功整理记录 + """ + return TransferHistory.list_success_by_src( + self._db, + src=src, + storage=storage, + recursive=recursive, + ) + + def list_success_move_by_dest( + self, + dest: str, + storage: Optional[str] = None, + recursive: bool = False, + ) -> List[TransferHistory]: + """ + 按目标路径查询成功移动记录。 + + :param dest: 目标路径 + :param storage: 目标存储类型 + :param recursive: 是否递归匹配目录子项 + :return: 命中的成功移动记录 + """ + return TransferHistory.list_success_move_by_dest( + self._db, + dest=dest, + storage=storage, + recursive=recursive, + ) def list_by_hash(self, download_hash: str) -> List[TransferHistory]: """ diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index 1c12d0bb..fc852cf8 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -249,6 +249,19 @@ class ManualTransferItem(BaseModel): episode_group: Optional[str] = None # 仅预览,不执行整理 preview: Optional[bool] = False + # 重新整理,清理命中的成功历史及其旧目标 + reorganize: Optional[bool] = False + + +class ManualTransferHistoryInfo(BaseModel): + """ + 手动整理命中的成功历史摘要 + """ + + # 是否应显示重新整理操作 + reorganize: bool = False + # 命中的成功历史数量 + history_count: int = 0 class ManualTransferTargetPath(BaseModel): diff --git a/docs/mcp-api.md b/docs/mcp-api.md index a2020083..09ade66b 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -124,7 +124,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 | GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 及插件自定义来源前缀 | | POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧)可指定本次刮削媒体 | | POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID | -| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid`、`bangumiid`、`anilistid` | +| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid`、`bangumiid`、`anilistid`;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | #### 搜索 / 种子 / 字幕 diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 66d83e01..9c8e2030 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 5 +version: 6 description: >- Use this skill when you need to call MoviePilot REST API endpoints directly with the bundled Python client. Covers MoviePilot HTTP endpoints across media @@ -288,7 +288,7 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | POST | `/api/v1/storage/save/{name}` | Save storage config. Body: JSON object | | GET | `/api/v1/storage/reset/{name}` | Reset storage config | -### Transfer (6 endpoints) +### Transfer (7 endpoints) | Method | Path | Description | |--------|------|-------------| @@ -296,7 +296,8 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | GET | `/api/v1/transfer/queue` | Transfer queue | | DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON | | POST | `/api/v1/transfer/manual/target-path` | Match manual transfer target path. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select the recognition source | -| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source | +| POST | `/api/v1/transfer/manual/history` | Query successful transfer-history summary for selected files or directories. Body: ManualTransferItem JSON | +| POST | `/api/v1/transfer/manual` | Manual transfer. Params: `background`. Body: ManualTransferItem JSON; optional `media_source` + `media_id` select recognition and scraping source; matching failed history is cleared automatically, while `reorganize=true` removes matched successful history and old non-move targets before retrying | | GET | `/api/v1/transfer/now` | Run immediate transfer | ### Dashboard (19 endpoints) diff --git a/skills/transfer-failed-retry/SKILL.md b/skills/transfer-failed-retry/SKILL.md index 475ae929..9334e8af 100644 --- a/skills/transfer-failed-retry/SKILL.md +++ b/skills/transfer-failed-retry/SKILL.md @@ -1,6 +1,6 @@ --- name: transfer-failed-retry -version: 1 +version: 2 description: Use this skill when you need to retry failed file transfers/organizations. Given one or more failed transfer history record IDs, this skill guides you through querying the failure details, deleting the old records, and re-identifying and re-organizing the files. Supports batch processing of multiple files from the same media (e.g., multiple episodes of a TV show). This skill is automatically triggered when the system detects transfer failures and the AI agent retry feature is enabled. allowed-tools: query_transfer_history delete_transfer_history recognize_media transfer_file search_media --- @@ -56,7 +56,7 @@ Common failure reasons and how to handle them: ### Step 3: Delete the Failed History Record(s) -Before retrying, you **must** delete the old failed history record(s). The system skips files that already have a transfer history entry (even failed ones). +Before an agent-driven retry, delete the exact failed history record(s) so the cleanup is explicit and auditable. The interactive manual-transfer flow now clears matching failed records automatically, but agent retries retain this confirmation step. ``` delete_transfer_history(history_id=) @@ -153,7 +153,7 @@ transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", tmdbid=789, med ## Important Notes -- **Always delete the old history record first** before retrying. The system will skip files with existing history. +- **Always delete the old history record first** in this agent workflow so the destructive cleanup remains explicit, even though the interactive manual-transfer flow can clear failed history automatically. - **Do not retry** if the source file no longer exists (源目录不存在). - **Do not retry** if the error is about missing directory configuration - this requires user intervention. - **For unrecognized media**, always try `recognize_media` with the file path first before falling back to `search_media`. diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index ccbab9c0..6cbbcbfb 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -22,9 +22,10 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: """本次修改过的内置技能必须递增版本,确保用户端同步更新。""" expected_versions = { "database-operation": "3", - "moviepilot-api": "5", + "moviepilot-api": "6", "moviepilot-cli": "6", "moviepilot-update": "3", + "transfer-failed-retry": "2", } for skill_name, expected_version in expected_versions.items(): diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py new file mode 100644 index 00000000..1dbcacf3 --- /dev/null +++ b/tests/test_manual_transfer_history.py @@ -0,0 +1,510 @@ +from types import SimpleNamespace + +from app.api.endpoints.transfer import ( + manual_transfer as manual_transfer_endpoint, + query_manual_transfer_history, +) +from app.chain.transfer import TransferChain +from app.db.transferhistory_oper import TransferHistoryOper +from app.schemas import FileItem, ManualTransferItem +from tests.test_transfer_sync_extra_files import ( + FakeMeta, + make_fileitem, + make_transfer_chain, +) + + +def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted): + """隔离整理规划依赖,并记录历史及旧目标清理动作。""" + monkeypatch.setattr( + chain, + "_TransferChain__get_trans_fileitems", + lambda current_fileitem, predicate: [(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) + + history_oper = SimpleNamespace( + get_by_src=lambda src, storage=None: history, + get_by_dest=lambda dest, storage=None: None, + delete=lambda history_id: deleted.append(("history", history_id)), + ) + monkeypatch.setattr( + "app.chain.transfer.TransferHistoryOper", + lambda: history_oper, + ) + 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( + exists=lambda current_fileitem: True, + delete_media_file=lambda current_fileitem: deleted.append( + ("target", current_fileitem.path) + ) + or True, + ), + ) + monkeypatch.setattr( + "app.chain.transfer.MetaInfoPath", + lambda path, custom_words=None: FakeMeta(1), + ) + + +def test_query_manual_transfer_history_returns_success_summary(monkeypatch): + """手动整理初始化接口应只返回成功历史摘要供前端切换重整状态。""" + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + captured = [] + + class _FakeTransferChain: + """记录历史查询收到的文件项。""" + + def get_manual_transfer_histories(self, fileitems): + """返回两条成功历史记录。""" + captured.extend(fileitems) + return [SimpleNamespace(id=1), SimpleNamespace(id=2)] + + monkeypatch.setattr( + "app.api.endpoints.transfer.TransferChain", + _FakeTransferChain, + ) + + response = query_manual_transfer_history( + transer_item=ManualTransferItem(fileitem=fileitem), + db=object(), + _="token", + ) + + assert response.success is True + assert response.data == {"reorganize": True, "history_count": 2} + assert captured == [fileitem] + + +def test_manual_transfer_endpoint_passes_reorganize_confirmation(monkeypatch): + """手动整理接口应把前端重整确认传入整理链。""" + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + captured = {} + + class _FakeTransferChain: + """记录手动整理调用参数。""" + + def manual_transfer(self, **kwargs): + """保存整理参数并返回成功。""" + captured.update(kwargs) + return True, "" + + monkeypatch.setattr( + "app.api.endpoints.transfer.TransferChain", + _FakeTransferChain, + ) + + response = manual_transfer_endpoint( + transer_item=ManualTransferItem( + fileitem=fileitem, + reorganize=True, + ), + background=False, + db=object(), + _="token", + ) + + assert response.success is True + assert captured["reorganize"] is True + + +def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch): + """历史 ID 重整应交由整理链清理历史,不能再传入旧目标重复删除。""" + src_fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + dest_fileitem = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=21, + status=True, + mode="copy", + src_fileitem=src_fileitem.model_dump(), + dest_fileitem=dest_fileitem.model_dump(), + ) + captured = {} + + class _FakeTransferChain: + """记录历史 ID 重整调用参数。""" + + def manual_transfer(self, **kwargs): + """保存整理参数并返回成功。""" + captured.update(kwargs) + return True, "" + + monkeypatch.setattr( + "app.api.endpoints.transfer.TransferHistory.get", + lambda db, logid: history, + ) + monkeypatch.setattr( + "app.api.endpoints.transfer.TransferChain", + _FakeTransferChain, + ) + + response = manual_transfer_endpoint( + transer_item=ManualTransferItem( + logid=history.id, + reorganize=True, + ), + background=False, + db=object(), + _="token", + ) + + assert response.success is True + assert captured["force"] is True + assert captured["reorganize"] is True + assert captured["cleanup_dest_fileitem"] is None + + +def test_success_history_directory_query_excludes_failed_and_siblings(): + """目录历史查询应限定路径边界,并且只返回成功记录。""" + transfer_history_oper = TransferHistoryOper() + created_histories = [ + transfer_history_oper.add_force( + src="/issue-6191/show/episode-1.mkv", + src_storage="local", + status=True, + ), + transfer_history_oper.add_force( + src="/issue-6191/show/episode-2.mkv", + src_storage="local", + status=False, + ), + transfer_history_oper.add_force( + src="/issue-6191/show-other/episode-3.mkv", + src_storage="local", + status=True, + ), + ] + try: + histories = transfer_history_oper.list_success_by_src( + "/issue-6191/show", + storage="local", + recursive=True, + ) + + assert [history.src for history in histories] == [ + "/issue-6191/show/episode-1.mkv" + ] + finally: + for history in created_histories: + transfer_history_oper.delete(history.id) + + +def test_success_history_directory_query_escapes_sql_wildcards(): + """目录名中的 SQL 通配符应按普通字符匹配,不能扩大查询范围。""" + transfer_history_oper = TransferHistoryOper() + created_histories = [ + transfer_history_oper.add_force( + src="/issue-6191/show_100%/episode-1.mkv", + src_storage="local", + status=True, + ), + transfer_history_oper.add_force( + src="/issue-6191/showA100B/episode-2.mkv", + src_storage="local", + status=True, + ), + ] + try: + histories = transfer_history_oper.list_success_by_src( + "/issue-6191/show_100%", + storage="local", + recursive=True, + ) + + assert [history.src for history in histories] == [ + "/issue-6191/show_100%/episode-1.mkv" + ] + finally: + for history in created_histories: + transfer_history_oper.delete(history.id) + + +def test_successful_move_history_is_found_by_current_destination(): + """成功移动后从媒体库现址打开整理界面时,应识别原整理历史。""" + transfer_history_oper = TransferHistoryOper() + destination = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") + history = transfer_history_oper.add_force( + src="/downloads/Test.Show.S01E01.mkv", + src_storage="local", + dest=destination.path, + dest_storage="local", + dest_fileitem=destination.model_dump(), + mode="move", + status=True, + ) + try: + histories = TransferChain.get_manual_transfer_histories( + make_transfer_chain(), + [destination], + ) + + assert [current.id for current in histories] == [history.id] + finally: + transfer_history_oper.delete(history.id) + + +def test_manual_transfer_removes_failed_history_before_retry(monkeypatch): + """失败历史不应阻塞手动整理,并应在重试前清理旧目标和记录。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + old_dest = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=7, + status=False, + mode="copy", + dest_fileitem=old_dest.model_dump(), + download_hash=None, + downloader=None, + ) + planned = [] + deleted = [] + _patch_transfer_planning( + monkeypatch, + chain, + fileitem, + history, + planned, + deleted, + ) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitem, + background=False, + manual=True, + ) + + assert state is True + assert message == "" + assert deleted == [ + ("target", old_dest.path), + ("history", history.id), + ] + assert planned == [fileitem.path] + + +def test_automatic_transfer_keeps_failed_history(monkeypatch): + """自动整理仍应保留失败历史并跳过,避免失败任务自动循环。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=12, + status=False, + mode="copy", + dest_fileitem=make_fileitem( + "/library/Test Show/Test.Show.S01E01.mkv" + ).model_dump(), + download_hash=None, + downloader=None, + ) + planned = [] + deleted = [] + _patch_transfer_planning( + monkeypatch, + chain, + fileitem, + history, + planned, + deleted, + ) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitem, + background=False, + manual=False, + ) + + assert state is False + assert message == f"{fileitem.name} 已整理过" + assert deleted == [] + assert planned == [] + + +def test_manual_transfer_keeps_success_history_without_confirmation(monkeypatch): + """未携带重整标志时,成功历史仍应阻止普通手动整理。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=8, + status=True, + mode="copy", + dest_fileitem=make_fileitem( + "/library/Test Show/Test.Show.S01E01.mkv" + ).model_dump(), + download_hash=None, + downloader=None, + ) + planned = [] + deleted = [] + _patch_transfer_planning( + monkeypatch, + chain, + fileitem, + history, + planned, + deleted, + ) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitem, + background=False, + manual=True, + ) + + assert state is True + assert message == f"{fileitem.name} 已整理过" + assert deleted == [] + assert planned == [] + + +def test_manual_reorganize_removes_success_history_and_old_target(monkeypatch): + """用户确认重新整理后,应清理成功历史及非移动模式旧目标再执行。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + old_dest = make_fileitem("/library/Wrong Show/Wrong.Show.S02E01.mkv") + history = SimpleNamespace( + id=9, + status=True, + mode="copy", + dest_fileitem=old_dest.model_dump(), + download_hash=None, + downloader=None, + ) + planned = [] + deleted = [] + _patch_transfer_planning( + monkeypatch, + chain, + fileitem, + history, + planned, + deleted, + ) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitem, + background=False, + manual=True, + reorganize=True, + ) + + assert state is True + assert message == "" + assert deleted == [ + ("target", old_dest.path), + ("history", history.id), + ] + assert planned == [fileitem.path] + + +def test_manual_reorganize_keeps_successful_move_target_as_source(monkeypatch): + """成功移动后的目标是当前重整源,只能删历史记录,不能先删除文件。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=10, + status=True, + mode="move", + dest_fileitem=fileitem.model_dump(), + download_hash=None, + downloader=None, + ) + planned = [] + deleted = [] + _patch_transfer_planning( + monkeypatch, + chain, + fileitem, + history, + planned, + deleted, + ) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitem, + background=False, + manual=True, + reorganize=True, + ) + + assert state is True + assert message == "" + assert deleted == [("history", history.id)] + assert planned == [fileitem.path] + + +def test_forced_manual_reorganize_still_removes_history(monkeypatch): + """从历史入口强制重新整理时,仍应按重整确认清理旧目标和记录。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + old_dest = make_fileitem("/library/Wrong Show/Wrong.Show.S02E01.mkv") + history = SimpleNamespace( + id=11, + status=True, + mode="copy", + dest_fileitem=old_dest.model_dump(), + download_hash=None, + downloader=None, + ) + planned = [] + deleted = [] + _patch_transfer_planning( + monkeypatch, + chain, + fileitem, + history, + planned, + deleted, + ) + + state, message = TransferChain.do_transfer( + chain, + fileitem=fileitem, + background=False, + manual=True, + force=True, + reorganize=True, + ) + + assert state is True + assert message == "" + assert deleted == [ + ("target", old_dest.path), + ("history", history.id), + ] + assert planned == [fileitem.path]