mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-26 16:41:35 +08:00
fix(transfer): 支持显式重新整理历史记录
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
|
||||
|
||||
#### 搜索 / 种子 / 字幕
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=<record_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`.
|
||||
|
||||
@@ -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():
|
||||
|
||||
510
tests/test_manual_transfer_history.py
Normal file
510
tests/test_manual_transfer_history.py
Normal 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]
|
||||
Reference in New Issue
Block a user