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

This commit is contained in:
jxxghp
2026-07-26 08:57:18 +08:00
parent 3d020c8ceb
commit cae28d8c03
10 changed files with 860 additions and 20 deletions

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)
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,
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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]:
"""

View File

@@ -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):

View File

@@ -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` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
#### 搜索 / 种子 / 字幕

View File

@@ -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)

View File

@@ -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`.

View File

@@ -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():

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]