Files
MoviePilot/app/api/endpoints/history.py
T

549 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import time
from collections.abc import Coroutine
from typing import Any, Callable, List, Optional
from fastapi import Depends
from app.adapters.web.security.access import verify_token
from app.agent.contracts import ReplyMode
from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt,
build_manual_redo_prompt,
)
from app.api.context import (
get_api_runtime_config,
get_background_task_registry,
resolve_api_runtime_config,
resolve_background_task_registry,
)
from app.api.dependencies.auth import (
get_current_active_manage_user,
get_current_active_superuser,
)
from app.api.dependencies.history import (
get_download_history_mutation_command,
get_history_query_service,
get_transfer_history_mutation_command,
)
from app.api.response import ResponseAPIRouter
from app.application.agent import get_running_agent_manager
from app.application.chain.data import get_chain_transfer_execution_port
from app.application.configuration import ApiRuntimeConfig
from app.application.history import (
DownloadHistoryMutationCommand,
HistoryQueryService,
TransferHistoryMutationCommand,
)
from app.application.transfer.execution import (
TransferExecutionCommand,
TransferRetryRequestResult,
)
from app.runtime.config import global_vars
from app.runtime.log import logger
from app.runtime.progress import AsyncProgressHelper
from app.runtime.tasks import TaskRegistry
from app.schemas.common import BatchProgressKeyData as _SchemaBatchProgressKeyData
from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData
from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest
from app.schemas.history import DownloadHistory as _SchemaDownloadHistory
from app.schemas.history import TransferHistory as _SchemaTransferHistory
from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
router = ResponseAPIRouter()
def normalize_history_ids(history_ids: list[int]) -> list[int]:
"""对输入的历史记录 ID 列表进行规范化处理,去除重复项并保持原有顺序。"""
normalized_ids: list[int] = []
for history_id in history_ids:
if history_id not in normalized_ids:
normalized_ids.append(history_id)
return normalized_ids
def _request_durable_transfer_retry(
*,
history_id: int,
task_id: str,
requested_by: str,
) -> TransferRetryRequestResult:
"""把 durable 历史重试交给唯一持久调度器,不在请求线程执行整理。"""
return TransferExecutionCommand(
get_chain_transfer_execution_port()
).request_retry(
task_id=task_id,
reason=f"AI REST 请求重试整理历史 #{history_id}",
requested_by=requested_by,
)
def _format_retry_rejections(
rejections: list[tuple[int, TransferRetryRequestResult]],
) -> str:
"""把批量 durable 重试拒绝原因格式化为可审计的接口提示。"""
return "".join(
f"#{history_id} [{result.state.value}]: {result.message}"
for history_id, result in rejections
)
def _partition_durable_histories(
histories: list[_SchemaTransferHistory],
) -> tuple[list[_SchemaTransferHistory], list[_SchemaTransferHistory]]:
"""按是否绑定持久任务回执分离 durable 与旧整理历史。"""
return (
[history for history in histories if history.transfer_task_id],
[history for history in histories if not history.transfer_task_id],
)
async def _request_batch_durable_retries(
histories: list[_SchemaTransferHistory],
) -> tuple[int, list[tuple[int, TransferRetryRequestResult]]]:
"""逐任务登记 durable 重试并保留每条拒绝的稳定状态。"""
accepted_count = 0
rejections: list[tuple[int, TransferRetryRequestResult]] = []
for history in histories:
retry = await asyncio.to_thread(
_request_durable_transfer_retry,
history_id=history.id,
task_id=history.transfer_task_id or "",
requested_by="history_ai_redo_batch",
)
if retry.accepted:
accepted_count += 1
else:
rejections.append((history.id, retry))
return accepted_count, rejections
def _durable_retry_messages(
*,
accepted_count: int,
rejections: list[tuple[int, TransferRetryRequestResult]],
) -> list[str]:
"""构造 durable 批量登记结果消息,供纯 durable 和混合请求复用。"""
messages: list[str] = []
if accepted_count:
messages.append(f"已登记 {accepted_count} 个持久整理任务重试")
if rejections:
messages.append("以下任务未登记重试:" + _format_retry_rejections(rejections))
return messages
async def _complete_durable_retry_batch(
*,
histories: list[_SchemaTransferHistory],
messages: list[str],
rejections: list[tuple[int, TransferRetryRequestResult]],
) -> Any:
"""完成纯 durable 批量响应;存在拒绝时不伪造成功进度。"""
message = "".join(messages)
if rejections:
return _SchemaResponse(success=False, message=message)
progress_key = f"transfer_retry_batch_{int(time.time() * 1000)}"
history_ids = [history.id for history in histories]
await _complete_durable_retry_progress(
progress_key=progress_key,
text=message,
history_ids=history_ids,
)
return _SchemaResponse(
success=True,
message=message,
data={"progress_key": progress_key, "history_ids": history_ids},
)
async def _complete_durable_retry_progress(
*,
progress_key: str,
text: str,
history_ids: list[int],
) -> None:
"""写入可被现有 SSE 客户端立即消费的 durable 重试完成进度。"""
progress = AsyncProgressHelper(progress_key)
await progress.start()
await progress.end(
text=text,
data={
"history_ids": history_ids,
"success": True,
"completed": True,
"message": text,
},
)
def _build_progress_output_callback(
progress: AsyncProgressHelper,
data: dict[str, Any],
*,
submit: Callable[[Coroutine[Any, Any, Any]], object],
) -> Callable[[str], None]:
"""构造同步 Agent 输出回调,并把异步进度更新登记到宿主任务生命周期。"""
def update_output(text: str) -> None:
"""非阻塞提交一条进度更新,避免同步回调等待缓存 I/O。"""
submit(progress.update(text=text, data=data))
return update_output
def _start_ai_redo_task(
history_id: int,
prompt: str,
progress_key: str,
task_registry: TaskRegistry | None = None,
) -> None:
"""在后台任务中启动单条 AI 重新整理任务,并通过异步进度辅助类实时更新进度。"""
registry = resolve_background_task_registry(task_registry)
progress = AsyncProgressHelper(progress_key)
update_output = _build_progress_output_callback(
progress,
{"history_id": history_id},
submit=lambda coroutine: registry.submit_threadsafe(
coroutine,
loop=global_vars.loop,
owner="api.history.ai_redo.progress",
),
)
async def runner():
try:
await progress.start()
await progress.update(
text=f"智能助手正在准备整理记录 #{history_id} ...",
data={"history_id": history_id, "success": True},
)
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过单条整理历史 AI 重做")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt,
session_prefix=f"__agent_manual_redo_{history_id}",
output_callback=update_output,
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=False,
)
await progress.update(
text="智能助手整理完成",
data={"history_id": history_id, "success": True, "completed": True},
)
except Exception as e:
await progress.update(
text=f"智能助手整理失败:{str(e)}",
data={
"history_id": history_id,
"success": False,
"completed": True,
"error": str(e),
},
)
finally:
await progress.end()
registry.create(runner(), owner="api.history.ai_redo")
def _start_batch_ai_redo_task(
history_ids: list[int],
prompt: str,
progress_key: str,
task_registry: TaskRegistry | None = None,
) -> None:
"""在后台任务中启动批量 AI 重新整理任务,并通过异步进度辅助类实时更新进度。"""
registry = resolve_background_task_registry(task_registry)
progress = AsyncProgressHelper(progress_key)
update_output = _build_progress_output_callback(
progress,
{"history_ids": history_ids},
submit=lambda coroutine: registry.submit_threadsafe(
coroutine,
loop=global_vars.loop,
owner="api.history.ai_redo_batch.progress",
),
)
async def runner():
try:
await progress.start()
await progress.update(
text=f"智能助手正在准备批量整理 {len(history_ids)} 条记录 ...",
data={"history_ids": history_ids, "success": True},
)
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过批量整理历史 AI 重做")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt,
session_prefix="__agent_manual_redo_batch",
output_callback=update_output,
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=False,
)
await progress.update(
text="智能助手批量整理完成",
data={"history_ids": history_ids, "success": True, "completed": True},
)
except Exception as e:
await progress.update(
text=f"智能助手批量整理失败:{str(e)}",
data={
"history_ids": history_ids,
"success": False,
"completed": True,
"error": str(e),
},
)
finally:
await progress.end()
registry.create(runner(), owner="api.history.ai_redo_batch")
@router.get(
"/download",
summary="查询下载历史记录",
response_model=List[_SchemaDownloadHistory],
)
async def download_history(
page: Optional[int] = 1,
count: Optional[int] = 30,
query: HistoryQueryService = Depends(get_history_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
按下载时间倒序查询下载历史记录
"""
return await query.list_download(page=page, count=count)
@router.delete(
"/download",
summary="删除下载历史记录",
response_model=_SchemaResponse[None],
)
def delete_download_history(
history_in: _SchemaDownloadHistory,
command: DownloadHistoryMutationCommand = Depends(
get_download_history_mutation_command
),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
删除下载历史记录
"""
result = command.delete(history_in.id)
return _SchemaResponse(success=result.success, message=result.message)
@router.get(
"/transfer",
summary="查询整理记录",
response_model=_SchemaResponse[_SchemaTransferHistoryPage],
)
async def transfer_history(
title: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
status: Optional[bool] = None,
query: HistoryQueryService = Depends(get_history_query_service),
_: _SchemaTokenPayload = Depends(verify_token),
) -> Any:
"""
查询整理记录,title 支持通配符 * 和 ?(如 *.mkv、*2024*
"""
result = await query.list_transfer(
title=title,
page=page,
count=count,
status=status,
)
return _SchemaResponse(success=True, data=result)
@router.delete("/transfer", summary="删除整理记录", response_model=_SchemaResponse[None])
def delete_transfer_history(
history_in: _SchemaTransferHistory,
deletesrc: Optional[bool] = False,
deletedest: Optional[bool] = False,
command: TransferHistoryMutationCommand = Depends(
get_transfer_history_mutation_command
),
_: object = Depends(get_current_active_manage_user),
) -> Any:
"""
删除整理记录。
"""
result = command.delete(
history_in.id,
delete_source=bool(deletesrc),
delete_destination=bool(deletedest),
)
return _SchemaResponse(success=result.success, message=result.message)
@router.post(
"/transfer/{history_id}/ai-redo",
summary="智能助手重新整理",
response_model=_SchemaResponse[_SchemaProgressKeyData],
)
async def ai_redo_transfer_history(
history_id: int,
query: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: object = Depends(get_current_active_manage_user),
task_registry: TaskRegistry = Depends(get_background_task_registry),
) -> Any:
"""
手动触发单条历史记录的 AI 重新整理,并返回进度键。
"""
runtime_config = resolve_api_runtime_config(runtime_config)
history = await query.get_transfer(history_id)
if not history:
return _SchemaResponse(success=False, message="整理记录不存在")
if history.transfer_task_id:
retry = await asyncio.to_thread(
_request_durable_transfer_retry,
history_id=history.id,
task_id=history.transfer_task_id,
requested_by="history_ai_redo",
)
if not retry.accepted:
return _SchemaResponse(success=False, message=retry.message)
progress_key = f"transfer_retry_{history_id}_{int(time.time() * 1000)}"
await _complete_durable_retry_progress(
progress_key=progress_key,
text=retry.message,
history_ids=[history.id],
)
return _SchemaResponse(
success=True,
message=retry.message,
data={"progress_key": progress_key},
)
if not runtime_config.ai_agent_enable:
return _SchemaResponse(success=False, message="MoviePilot智能助手未启用")
prompt = build_manual_redo_prompt(history)
progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}"
_start_ai_redo_task(
history_id=history_id,
prompt=prompt,
progress_key=progress_key,
task_registry=task_registry,
)
return _SchemaResponse(success=True, data={"progress_key": progress_key})
@router.post(
"/transfer/ai-redo",
summary="智能助手批量重新整理",
response_model=_SchemaResponse[_SchemaBatchProgressKeyData],
)
async def batch_ai_redo_transfer_history(
payload: _SchemaBatchTransferHistoryRedoRequest,
query: HistoryQueryService = Depends(get_history_query_service),
runtime_config: ApiRuntimeConfig = Depends(get_api_runtime_config),
_: object = Depends(get_current_active_manage_user),
task_registry: TaskRegistry = Depends(get_background_task_registry),
) -> Any:
"""
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
"""
runtime_config = resolve_api_runtime_config(runtime_config)
history_ids = normalize_history_ids(payload.history_ids)
if not history_ids:
return _SchemaResponse(success=False, message="未提供有效的整理记录")
histories, missing_ids = await query.get_transfers(history_ids)
if missing_ids:
return _SchemaResponse(
success=False,
message="整理记录不存在: "
+ ", ".join(str(history_id) for history_id in missing_ids),
)
durable_histories, legacy_histories = _partition_durable_histories(histories)
accepted_count, rejections = await _request_batch_durable_retries(
durable_histories
)
response_message_parts = _durable_retry_messages(
accepted_count=accepted_count,
rejections=rejections,
)
if not legacy_histories:
return await _complete_durable_retry_batch(
histories=durable_histories,
messages=response_message_parts,
rejections=rejections,
)
if rejections:
response_message_parts.append(
f"{len(legacy_histories)} 条旧历史未提交:批量请求包含被拒绝的持久任务"
)
return _SchemaResponse(
success=False,
message="".join(response_message_parts),
)
if not runtime_config.ai_agent_enable:
response_message_parts.append(
f"{len(legacy_histories)} 条旧历史未处理:MoviePilot智能助手未启用"
)
return _SchemaResponse(
success=False,
message="".join(response_message_parts),
)
prompt = build_batch_manual_redo_prompt(legacy_histories)
progress_key = f"ai_redo_transfer_batch_{int(time.time() * 1000)}"
_start_batch_ai_redo_task(
history_ids=[history.id for history in legacy_histories],
prompt=prompt,
progress_key=progress_key,
task_registry=task_registry,
)
response_message_parts.append(
f"已提交 {len(legacy_histories)} 条旧历史给智能助手处理"
)
return _SchemaResponse(
success=not rejections,
message="".join(response_message_parts),
data={
"progress_key": progress_key,
"history_ids": [history.id for history in histories],
},
)
@router.get(
"/empty/transfer",
summary="清空整理记录",
response_model=_SchemaResponse[None],
)
def empty_transfer_history(
command: TransferHistoryMutationCommand = Depends(
get_transfer_history_mutation_command
),
_: object = Depends(get_current_active_superuser),
) -> Any:
"""
清空整理记录
"""
result = command.truncate()
return _SchemaResponse(success=result.success, message=result.message)