fix(transfer): 支持显式重新整理历史记录

This commit is contained in:
jxxghp
2026-07-26 08:59:57 +08:00
parent 3d020c8ceb
commit cae28d8c03
10 changed files with 860 additions and 20 deletions
+41 -1
View File
@@ -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) @router.post("/manual", summary="手动转移", response_model=schemas.Response)
def manual_transfer( def manual_transfer(
transer_item: ManualTransferItem, transer_item: ManualTransferItem,
@@ -278,7 +312,11 @@ 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
and not transer_item.reorganize
):
cleanup_dest_fileitem = FileItem(**history.dest_fileitem) cleanup_dest_fileitem = FileItem(**history.dest_fileitem)
# 从历史数据获取信息 # 从历史数据获取信息
@@ -435,6 +473,7 @@ def manual_transfer(
downloader=downloader, downloader=downloader,
download_hash=download_hash, download_hash=download_hash,
preview=transer_item.preview, preview=transer_item.preview,
reorganize=transer_item.reorganize,
sync_extra_files=False, sync_extra_files=False,
cleanup_dest_fileitem=cleanup_dest_fileitem, cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
@@ -521,6 +560,7 @@ def manual_transfer(
downloader=downloader, downloader=downloader,
download_hash=download_hash, download_hash=download_hash,
preview=transer_item.preview, preview=transer_item.preview,
reorganize=transer_item.reorganize,
sync_extra_files=True, sync_extra_files=True,
cleanup_dest_fileitem=cleanup_dest_fileitem, cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
+123 -4
View File
@@ -2636,6 +2636,97 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
else None 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( def do_transfer(
self, self,
fileitem: FileItem, fileitem: FileItem,
@@ -2661,6 +2752,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
sync_extra_files: Optional[bool] = False, sync_extra_files: Optional[bool] = False,
cleanup_dest_fileitem: Optional[FileItem] = None, cleanup_dest_fileitem: Optional[FileItem] = None,
continue_callback: Callable = None, continue_callback: Callable = None,
reorganize: Optional[bool] = False,
) -> Tuple[bool, Union[str, dict]]: ) -> Tuple[bool, Union[str, dict]]:
""" """
执行一个复杂目录的整理操作 执行一个复杂目录的整理操作
@@ -2684,6 +2776,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param background: 是否后台运行 :param background: 是否后台运行
:param manual: 是否手动整理 :param manual: 是否手动整理
:param preview: 是否仅预览 :param preview: 是否仅预览
:param reorganize: 是否清理已有成功记录后重新整理
:param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件 :param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
:param continue_callback: 继续处理回调 :param continue_callback: 继续处理回调
@@ -3114,11 +3207,33 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
raise OperationInterrupted() raise OperationInterrupted()
file_path = Path(file_item.path) file_path = Path(file_item.path)
# 整理成功的不再处理 # 自动整理继续按全部历史去重;手动整理可清理失败记录,或按用户确认清理成功记录。
if not force and not preview: if (not force or reorganize) and not preview:
transferd = TransferHistoryOper().get_by_src( transfer_history_oper = TransferHistoryOper()
file_item.path, storage=file_item.storage 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: if transferd:
skipped_history_count += 1 skipped_history_count += 1
if not transferd.status: if not transferd.status:
@@ -3575,6 +3690,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
cleanup_dest_fileitem: Optional[FileItem] = None, cleanup_dest_fileitem: Optional[FileItem] = None,
bangumiid: Optional[int] = None, bangumiid: Optional[int] = None,
anilistid: Optional[int] = None, anilistid: Optional[int] = None,
reorganize: Optional[bool] = False,
) -> Tuple[bool, Union[str, dict]]: ) -> Tuple[bool, Union[str, dict]]:
""" """
手动整理,支持复杂条件,带进度显示 手动整理,支持复杂条件,带进度显示
@@ -3601,6 +3717,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param downloader: 下载器名称 :param downloader: 下载器名称
:param download_hash: 下载任务哈希 :param download_hash: 下载任务哈希
:param preview: 是否仅预览 :param preview: 是否仅预览
:param reorganize: 是否清理已有成功记录后重新整理
:param sync_extra_files: 是否同步整理同媒体附加文件 :param sync_extra_files: 是否同步整理同媒体附加文件
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件 :param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
""" """
@@ -3651,6 +3768,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader=downloader, downloader=downloader,
download_hash=download_hash, download_hash=download_hash,
preview=preview, preview=preview,
reorganize=reorganize,
sync_extra_files=sync_extra_files, sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem, cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
@@ -3679,6 +3797,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
downloader=downloader, downloader=downloader,
download_hash=download_hash, download_hash=download_hash,
preview=preview, preview=preview,
reorganize=reorganize,
sync_extra_files=sync_extra_files, sync_extra_files=sync_extra_files,
cleanup_dest_fileitem=cleanup_dest_fileitem, cleanup_dest_fileitem=cleanup_dest_fileitem,
) )
+111 -4
View File
@@ -1,6 +1,7 @@
import re import re
import time 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 import Boolean, Column, Index, Integer, JSON, String, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -184,7 +185,17 @@ class TransferHistory(Base):
@classmethod @classmethod
@db_query @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: if storage:
return db.query(cls).filter(cls.src == src, return db.query(cls).filter(cls.src == src,
cls.src_storage == storage).first() cls.src_storage == storage).first()
@@ -193,8 +204,104 @@ class TransferHistory(Base):
@classmethod @classmethod
@db_query @db_query
def get_by_dest(cls, db: Session, dest: str): def get_by_dest(
return db.query(cls).filter(cls.dest == dest).first() 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 @classmethod
@db_query @db_query
+51 -3
View File
@@ -78,20 +78,68 @@ class TransferHistoryOper(DbOper):
""" """
return TransferHistory.list_by_title(self._db, title) 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 src: 数据key
:param storage: 存储类型 :param storage: 存储类型
:return: 命中的整理记录,未命中时返回 None
""" """
return TransferHistory.get_by_src(self._db, src, storage) 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 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]: def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
""" """
+13
View File
@@ -249,6 +249,19 @@ class ManualTransferItem(BaseModel):
episode_group: Optional[str] = None episode_group: Optional[str] = None
# 仅预览,不执行整理 # 仅预览,不执行整理
preview: Optional[bool] = False preview: Optional[bool] = False
# 重新整理,清理命中的成功历史及其旧目标
reorganize: Optional[bool] = False
class ManualTransferHistoryInfo(BaseModel):
"""
手动整理命中的成功历史摘要
"""
# 是否应显示重新整理操作
reorganize: bool = False
# 命中的成功历史数量
history_count: int = 0
class ManualTransferTargetPath(BaseModel): class ManualTransferTargetPath(BaseModel):
+2 -1
View File
@@ -124,7 +124,8 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:``douban:``bangumi:``anilist:` 及插件自定义来源前缀 | | 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/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/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` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
#### 搜索 / 种子 / 字幕 #### 搜索 / 种子 / 字幕
+4 -3
View File
@@ -1,6 +1,6 @@
--- ---
name: moviepilot-api name: moviepilot-api
version: 5 version: 6
description: >- description: >-
Use this skill when you need to call MoviePilot REST API endpoints directly Use this skill when you need to call MoviePilot REST API endpoints directly
with the bundled Python client. Covers MoviePilot HTTP endpoints across media 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 | | POST | `/api/v1/storage/save/{name}` | Save storage config. Body: JSON object |
| GET | `/api/v1/storage/reset/{name}` | Reset storage config | | GET | `/api/v1/storage/reset/{name}` | Reset storage config |
### Transfer (6 endpoints) ### Transfer (7 endpoints)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
@@ -296,7 +296,8 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
| GET | `/api/v1/transfer/queue` | Transfer queue | | GET | `/api/v1/transfer/queue` | Transfer queue |
| DELETE | `/api/v1/transfer/queue` | Remove from transfer queue. Body: FileItem JSON | | 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/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 | | GET | `/api/v1/transfer/now` | Run immediate transfer |
### Dashboard (19 endpoints) ### Dashboard (19 endpoints)
+3 -3
View File
@@ -1,6 +1,6 @@
--- ---
name: transfer-failed-retry 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. 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 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) ### 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=<record_id>) delete_transfer_history(history_id=<record_id>)
@@ -153,7 +153,7 @@ transfer_file(file_path="/downloads/Show.Name.S01E04.1080p.mkv", tmdbid=789, med
## Important Notes ## 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 source file no longer exists (源目录不存在).
- **Do not retry** if the error is about missing directory configuration - this requires user intervention. - **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`. - **For unrecognized media**, always try `recognize_media` with the file path first before falling back to `search_media`.
+2 -1
View File
@@ -22,9 +22,10 @@ def test_modified_builtin_skills_have_incremented_versions() -> None:
"""本次修改过的内置技能必须递增版本,确保用户端同步更新。""" """本次修改过的内置技能必须递增版本,确保用户端同步更新。"""
expected_versions = { expected_versions = {
"database-operation": "3", "database-operation": "3",
"moviepilot-api": "5", "moviepilot-api": "6",
"moviepilot-cli": "6", "moviepilot-cli": "6",
"moviepilot-update": "3", "moviepilot-update": "3",
"transfer-failed-retry": "2",
} }
for skill_name, expected_version in expected_versions.items(): for skill_name, expected_version in expected_versions.items():
+510
View File
@@ -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]