Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
+131
View File
@@ -0,0 +1,131 @@
"""Agent 编排服务门面。
chain 层需要触发 Agent 后台任务、渲染提示词、查询模型能力时统一经本模块调用。
具体实现由 startup 组合根注册,形成依赖倒置:
chain -> application.agent <- startup -> agent
门面保存 provider 而非重量级实现对象,注册本身不会物化 Agent、LLM 或工具树。
本模块禁止静态或函数内导入 app.agent,否则会重新形成跨层循环依赖。
"""
from typing import Any, Callable, Optional
Provider = Callable[[], Any]
# provider 注册表由 startup/agent_initializer 在组合根装配。
_agent_manager_provider: Optional[Provider] = None
_running_agent_manager_provider: Optional[Provider] = None
_prompt_manager_provider: Optional[Provider] = None
_agent_capability_manager_provider: Optional[Provider] = None
_llm_helper_provider: Optional[Provider] = None
_manual_redo_prompt_builder_provider: Optional[Provider] = None
def register_agent_service_providers(
*,
agent_manager_provider: Provider,
running_agent_manager_provider: Provider,
prompt_manager_provider: Provider,
capability_manager_provider: Provider,
llm_helper_provider: Provider,
manual_redo_prompt_builder_provider: Provider,
) -> None:
"""注册 Agent 服务 provider,保持组合根装配阶段零重量实现导入。"""
global _agent_manager_provider, _running_agent_manager_provider
global _prompt_manager_provider, _agent_capability_manager_provider
global _llm_helper_provider, _manual_redo_prompt_builder_provider
_agent_manager_provider = agent_manager_provider
_running_agent_manager_provider = running_agent_manager_provider
_prompt_manager_provider = prompt_manager_provider
_agent_capability_manager_provider = capability_manager_provider
_llm_helper_provider = llm_helper_provider
_manual_redo_prompt_builder_provider = manual_redo_prompt_builder_provider
def register_agent_services(
agent_manager: Any,
prompt_manager: Any,
capability_manager: Any,
llm_helper: Any,
manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None,
) -> None:
"""兼容直接对象注入;生产组合根应注册惰性 provider。"""
register_agent_service_providers(
agent_manager_provider=lambda: agent_manager,
running_agent_manager_provider=lambda: agent_manager,
prompt_manager_provider=lambda: prompt_manager,
capability_manager_provider=lambda: capability_manager,
llm_helper_provider=lambda: llm_helper,
manual_redo_prompt_builder_provider=lambda: manual_redo_prompt_builder,
)
def _resolve(provider: Optional[Provider], service_name: str) -> Any:
"""解析已注册服务;缺少组合根装配时给出稳定错误。"""
if provider is None:
raise RuntimeError(
f"Agent 服务 {service_name} 未注册:"
"请先导入 app.startup.agent_initializer 完成组合根装配"
)
return provider()
def get_agent_manager() -> Any:
"""返回 canonical AgentManager;调用可能触发实现物化。"""
return _resolve(_agent_manager_provider, "agent_manager")
def get_running_agent_manager() -> Any | None:
"""返回已进入 RUNNING 的 AgentManager,不触发实现物化。"""
return _resolve(_running_agent_manager_provider, "running_agent_manager")
def get_prompt_manager() -> Any:
"""按需返回提示词管理器。"""
return _resolve(_prompt_manager_provider, "prompt_manager")
def supports_image_input(
provider: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset: Optional[str] = None,
) -> bool:
"""判断当前模型是否启用了图片输入能力。"""
llm_helper = _resolve(_llm_helper_provider, "llm_helper")
return llm_helper.supports_image_input(
provider=provider,
model=model,
base_url=base_url,
base_url_preset=base_url_preset,
)
def is_audio_input_available() -> bool:
"""判断语音输入能力是否可用。"""
capability_manager = _resolve(
_agent_capability_manager_provider,
"agent_capability_manager",
)
return capability_manager.is_audio_input_available()
def transcribe_audio(content: bytes, filename: str = "input.ogg") -> Optional[str]:
"""把音频内容转写为文本。"""
capability_manager = _resolve(
_agent_capability_manager_provider,
"agent_capability_manager",
)
return capability_manager.transcribe_audio(content, filename=filename)
def build_manual_redo_prompt(history: Any) -> str:
"""构造整理记录 AI 重新整理提示词(builder 由 agent 层注册)。"""
builder = _resolve(
_manual_redo_prompt_builder_provider,
"manual_redo_prompt_builder",
)
if builder is None:
raise RuntimeError("整理记录重新整理提示词构建器未注册")
return builder(history)
+45
View File
@@ -0,0 +1,45 @@
"""命令工具服务门面。
Agent 工具与 API 端点对命令注册表的操作统一经本模块调用,
Command 实现由 startup 组合根在导入期注册,避免 application 层
静态依赖顶层 command 模块。
依赖方向:
agent.tools / api.endpoints -> application.commands <- startup(注册 Command 类)
"""
from typing import Any, Dict, Optional
# Command 类:由 startup/command_initializer 在导入期注册。
_command_class: Any = None
def register_command_class(command_class: Any) -> None:
"""注册 Command 类(组合根在导入期调用)。"""
global _command_class
_command_class = command_class
def get_command_object() -> Any:
"""返回命令注册表实例。"""
if _command_class is None:
raise RuntimeError(
"命令服务未初始化:请先通过 register_command_class 注册 Command 类"
)
return _command_class()
def get_commands() -> Dict[str, Any]:
"""返回全部已注册命令。"""
return get_command_object().get_commands()
def get_command(name: str) -> Optional[Any]:
"""按命令名查询注册表。"""
return get_command_object().get(name)
def init_commands(plugin_id: Optional[str] = None) -> None:
"""初始化命令(可指定单个插件)。"""
get_command_object().init_commands(plugin_id)
-66
View File
@@ -1,66 +0,0 @@
from typing import List, Optional
from app.db.oper.systemconfig import SystemConfigOper
from app.domain.context import MediaInfo
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.types import SystemConfigKey
class RuleHelper:
"""读取用户过滤规则配置,并按媒体上下文选择适用规则组。"""
@staticmethod
def get_rule_groups() -> List[FilterRuleGroup]:
"""返回用户配置的全部过滤规则组。"""
rule_groups: List[dict] = SystemConfigOper().get(
SystemConfigKey.UserFilterRuleGroups
)
if not rule_groups:
return []
return [FilterRuleGroup(**group) for group in rule_groups]
def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]:
"""按名称返回过滤规则组。"""
return next(
(group for group in self.get_rule_groups() if group.name == group_name),
None,
)
def get_rule_group_by_media(
self,
media: Optional[MediaInfo] = None,
group_names: Optional[list] = None,
) -> List[FilterRuleGroup]:
"""按媒体类型、分类和候选名称筛选适用规则组。"""
rule_groups = self.get_rule_groups()
if group_names:
rule_groups = [
group for group in rule_groups if group.name in group_names
]
return [
group
for group in rule_groups
if not group.media_type
or (
media
and (
(not group.category and group.media_type == media.type.value)
or group.category == media.category
)
)
]
@staticmethod
def get_custom_rules() -> List[CustomRule]:
"""返回用户配置的全部自定义过滤规则。"""
rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)
if not rules:
return []
return [CustomRule(**rule) for rule in rules]
def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]:
"""按 ID 返回一条自定义过滤规则。"""
return next(
(rule for rule in self.get_custom_rules() if rule.id == rule_id),
None,
)
+190
View File
@@ -0,0 +1,190 @@
"""插件 API 动态路由服务。
把插件 API 的动态注册/移除从 HTTP 端点层下沉到 application 层:
FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent 工具
统一经本模块操作路由,消除 api.endpoints 对 factory 的反向依赖。
依赖方向:
api.endpoints.plugin / agent.tools -> application.plugins <- factory(注入实例)
"""
from typing import Optional
from fastapi import Depends, FastAPI
from app.application.security.access import verify_apikey, verify_token
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.config import settings
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.log import logger
from app.schemas.types import SystemConfigKey
PROTECTED_ROUTES = {
"/api/v1/openapi.json",
"/docs",
"/docs/oauth2-redirect",
"/redoc",
}
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
# FastAPI 应用实例:由 factory 在创建应用后调用 register_api_app 注入。
_api_app: Optional[FastAPI] = None
def register_api_app(api_app: FastAPI) -> None:
"""注入 FastAPI 应用实例(组合根在创建应用后调用)。"""
global _api_app
_api_app = api_app
def get_api_app() -> FastAPI:
"""返回已注入的 FastAPI 应用实例。"""
if _api_app is None:
raise RuntimeError("插件路由服务未初始化:请先调用 register_api_app 注入应用实例")
return _api_app
def register_plugin_api(plugin_id: Optional[str] = None):
"""
动态注册插件 API
:param plugin_id: 插件 ID,如果为 None,则注册所有插件
"""
_update_plugin_api_routes(plugin_id, action="add")
def remove_plugin_api(plugin_id: str):
"""
动态移除单个插件的 API
:param plugin_id: 插件 ID
"""
_update_plugin_api_routes(plugin_id, action="remove")
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
"""
插件 API 路由注册和移除
:param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件
如果 action 为 "remove",plugin_id 必须是有效的插件 ID
:param action: "add""remove",决定是添加还是移除路由
"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")
app = get_api_app()
is_modified = False
existing_paths = {route.path: route for route in app.routes}
plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()
for plugin_id in plugin_ids:
routes_removed = _remove_routes(plugin_id)
if routes_removed:
is_modified = True
if action != "add":
continue
# 获取插件的 API 路由信息
plugin_apis = PluginManager().get_plugin_apis(plugin_id)
for api in plugin_apis:
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
try:
api["path"] = api_path
allow_anonymous = api.pop("allow_anonymous", False)
auth_mode = api.pop("auth", "apikey")
dependencies = api.setdefault("dependencies", [])
if not allow_anonymous:
if (
auth_mode == "bear"
and Depends(verify_token) not in dependencies
):
dependencies.append(Depends(verify_token))
elif Depends(verify_apikey) not in dependencies:
dependencies.append(Depends(verify_apikey))
app.add_api_route(**api, tags=["plugin"])
is_modified = True
logger.debug(f"Added plugin route: {api_path}")
except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
if is_modified:
_clean_protected_routes(existing_paths)
app.openapi_schema = None
app.setup()
def _remove_routes(plugin_id: str) -> bool:
"""
移除与单个插件相关的路由
:param plugin_id: 插件 ID
:return: 是否有路由被移除
"""
if not plugin_id:
return False
app = get_api_app()
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
routes_to_remove = [
route for route in app.routes if route.path.startswith(prefix)
]
removed = False
for route in routes_to_remove:
try:
app.routes.remove(route)
removed = True
logger.debug(f"Removed plugin route: {route.path}")
except Exception as e:
logger.error(f"Error removing plugin route {route.path}: {str(e)}")
return removed
def _clean_protected_routes(existing_paths: dict):
"""
清理受保护的路由,防止在插件操作中被删除或重复添加
:param existing_paths: 当前应用的路由路径映射
"""
app = get_api_app()
for protected_route in PROTECTED_ROUTES:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
app.routes.remove(existing_route)
except Exception as e:
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
def remove_plugin_from_folders(plugin_id: str):
"""
从所有文件夹中移除指定的插件
:param plugin_id: 要移除的插件ID
"""
try:
config_oper = SystemConfigOper()
# 获取插件文件夹配置
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
# 标记是否有修改
modified = False
# 遍历所有文件夹,移除指定插件
for folder_name, folder_data in folders.items():
if isinstance(folder_data, dict) and "plugins" in folder_data:
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
if plugin_id in folder_data["plugins"]:
folder_data["plugins"].remove(plugin_id)
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
modified = True
elif isinstance(folder_data, list):
# 旧格式:直接是插件列表
if plugin_id in folder_data:
folder_data.remove(plugin_id)
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
modified = True
# 如果有修改,保存更新后的文件夹配置
if modified:
config_oper.set(SystemConfigKey.PluginFolders, folders)
else:
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
except Exception as e:
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
# 文件夹处理失败不影响插件卸载的整体流程
@@ -1,11 +1,79 @@
"""过滤规则解析器与内置规则定义,过滤模块与 Agent 工具共享同一事实来源。"""
"""
规则域用户规则组配置访问内置规则定义与规则解析器
过滤模块与 Agent 工具共享同一事实来源
"""
import threading
from typing import Dict
from typing import Dict, List, Optional
from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, alphanums, Combine, nums, ParseResults
from app.adapters.system import rust as rust_accel
from app.db.oper.systemconfig import SystemConfigOper
from app.domain.context import MediaInfo
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.types import SystemConfigKey
class RuleHelper:
"""读取用户过滤规则配置,并按媒体上下文选择适用规则组。"""
@staticmethod
def get_rule_groups() -> List[FilterRuleGroup]:
"""返回用户配置的全部过滤规则组。"""
rule_groups: List[dict] = SystemConfigOper().get(
SystemConfigKey.UserFilterRuleGroups
)
if not rule_groups:
return []
return [FilterRuleGroup(**group) for group in rule_groups]
def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]:
"""按名称返回过滤规则组。"""
return next(
(group for group in self.get_rule_groups() if group.name == group_name),
None,
)
def get_rule_group_by_media(
self,
media: Optional[MediaInfo] = None,
group_names: Optional[list] = None,
) -> List[FilterRuleGroup]:
"""按媒体类型、分类和候选名称筛选适用规则组。"""
rule_groups = self.get_rule_groups()
if group_names:
rule_groups = [
group for group in rule_groups if group.name in group_names
]
return [
group
for group in rule_groups
if not group.media_type
or (
media
and (
(not group.category and group.media_type == media.type.value)
or group.category == media.category
)
)
]
@staticmethod
def get_custom_rules() -> List[CustomRule]:
"""返回用户配置的全部自定义过滤规则。"""
rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)
if not rules:
return []
return [CustomRule(**rule) for rule in rules]
def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]:
"""按 ID 返回一条自定义过滤规则。"""
return next(
(rule for rule in self.get_custom_rules() if rule.id == rule_id),
None,
)
# 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。
BUILTIN_RULE_SET: Dict[str, dict] = {
+73
View File
@@ -0,0 +1,73 @@
"""调度器工具服务门面。
Agent 工具与 API 端点对运行时调度器的操作统一经本模块调用,
Scheduler 实现由 startup 组合根在导入期注册,避免 application 层
静态依赖顶层 scheduler 模块(scheduler 反向依赖 chain,会成环)。
依赖方向:
agent.tools / api.endpoints -> application.scheduling <- startup(注册 Scheduler 类)
"""
from typing import Any, List, Optional
# Agent 自主定时任务在运行时调度器中的任务 ID 前缀。
AGENT_TASK_JOB_PREFIX = "agent-task"
# Scheduler 类:由 startup/scheduler_initializer 在导入期注册。
_scheduler_class: Any = None
def register_scheduler_class(scheduler_class: Any) -> None:
"""注册 Scheduler 类(组合根在导入期调用)。"""
global _scheduler_class
_scheduler_class = scheduler_class
def get_scheduler() -> Any:
"""返回调度器实例。"""
if _scheduler_class is None:
raise RuntimeError(
"调度器服务未初始化:请先通过 register_scheduler_class 注册 Scheduler 类"
)
return _scheduler_class()
def list_scheduler_jobs() -> List[Any]:
"""列出运行时调度器的全部任务。"""
return get_scheduler().list()
def start_scheduler_job(job_id: str) -> None:
"""立即运行指定的运行时定时任务。"""
get_scheduler().start(job_id)
def update_plugin_job(plugin_id: str) -> None:
"""更新插件的定时任务。"""
get_scheduler().update_plugin_job(plugin_id)
def remove_plugin_job(plugin_id: str) -> None:
"""移除插件的定时任务。"""
get_scheduler().remove_plugin_job(plugin_id)
def start_agent_task(task_id: int) -> bool:
"""立即执行 Agent 自主定时任务。"""
return get_scheduler().start_agent_task(task_id)
def get_agent_task_next_run(task_id: int) -> Optional[Any]:
"""查询 Agent 自主定时任务的下一次运行时间。"""
return get_scheduler().get_agent_task_next_run(task_id)
def update_agent_task_job(task_id: int) -> Optional[Any]:
"""更新 Agent 自主定时任务的注册信息,返回下一次运行时间。"""
return get_scheduler().update_agent_task_job(task_id)
def remove_agent_task_job(task_id: int) -> None:
"""移除 Agent 自主定时任务的注册信息。"""
get_scheduler().remove_agent_task_job(task_id)
+891 -4
View File
@@ -13,20 +13,38 @@ app.schemas -> app.schemas.transfer -> app.domain.* -> app.schemas.types -> app.
TransferJob / TransferJobTask,那两个用 app.schemas 的同名 DTO——一个是工作项,一个是
视图,分开表达之后两边都不必再迁就对方。
"""
import asyncio
import threading
from copy import deepcopy
from pathlib import Path
from typing import Callable, List, Optional, Union
from time import monotonic
from typing import Callable, Dict, List, Optional, Tuple, Union
from pydantic import BaseModel, ConfigDict
from app import schemas
from app.adapters.system.host import SystemUtils
from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.domain.context import MediaInfo, MusicInfo
from app.domain.media import normalize_music_type
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.foundation import text as text_tools
from app.runtime.log import logger
from app.schemas.agent import ReplyMode
from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.media import OptionalMediaIdentityMixin
from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity
from app.schemas.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType
from app.schemas.transfer import TransferInfo, TransferJob, TransferJobTask
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaType,
)
class TransferTask(OptionalMediaIdentityMixin, BaseModel):
@@ -89,3 +107,872 @@ class TransferQueue(BaseModel):
callback: Optional[Callable] = None
# 整理结果
result: Optional[TransferInfo] = None
# 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。
job_lock = threading.Lock()
class JobManager:
"""
作业管理器
task任务负责一个文件的整理,job作业负责一个媒体的整理
"""
# 整理中的作业
_job_view: Dict[Tuple, TransferJob] = {}
# 汇总季集清单
_season_episodes: Dict[Tuple, List[int]] = {}
# 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业
_meta_to_media_ids: Dict[Tuple, set[Tuple]] = {}
# 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用
_task_state_changed_at: Dict[Tuple[str, str], float] = {}
# 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活
_active_executions: set[Tuple[str, str]] = set()
def __init__(self):
self._job_view = {}
self._season_episodes = {}
self._meta_to_media_ids = {}
self._task_state_changed_at = {}
self._active_executions = set()
@staticmethod
def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple:
"""
获取元数据ID
"""
return meta.name, season
@staticmethod
def __get_media_id(media: Optional[Union[MediaInfo, MusicInfo]] = None,
season: Optional[int] = None) -> Tuple:
"""
获取媒体ID;音乐额外区分实体类型,并为无远端ID的曲目构造稳定身份。
"""
if not media:
return None, season
source, media_id = resolve_media_identity(media=media)
if getattr(media, "type", None) == MediaType.MUSIC:
music_type = normalize_music_type(
getattr(media, "music_type", None),
) or MUSIC_ENTITY_RECORDING
if source and media_id:
return "music", source, media_id, music_type
artists = tuple(
text_tools.normalize_upper(artist)
for artist in (getattr(media, "artists", None) or [])
if text_tools.normalize_upper(artist)
)
if music_type == MUSIC_ENTITY_ALBUM:
album_artist = text_tools.normalize_upper(
getattr(media, "album_artist", None)
or (artists[0] if artists else "")
)
album = text_tools.normalize_upper(
getattr(media, "album", None) or getattr(media, "title", None) or ""
)
return "music", "local", music_type, album_artist, album, getattr(media, "year", None)
return (
"music",
"local",
music_type,
artists,
text_tools.normalize_upper(getattr(media, "title", None) or ""),
text_tools.normalize_upper(getattr(media, "album", None) or ""),
getattr(media, "disc_number", None),
getattr(media, "track_number", None),
)
return (source, media_id), season
@staticmethod
def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]:
"""
获取源文件唯一键,用于跨媒体作业识别同一个整理任务。
"""
if not fileitem or not fileitem.path:
return None
normalized_path = (
Path(str(fileitem.path).replace("\\", "/")).as_posix().rstrip("/") or "/"
)
return fileitem.storage or "local", normalized_path
def __get_id(self, task: TransferTask = None) -> Tuple:
"""
获取作业ID
"""
if task.mediainfo:
return self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
else:
return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season)
def get_job_id(self, task: TransferTask) -> Tuple:
"""返回任务当前所属的稳定作业身份,供作业级附加状态隔离使用。"""
return self.__get_id(task)
@staticmethod
def __get_media(task: TransferTask) -> Union[schemas.MediaInfo, schemas.MusicInfo]:
"""
获取媒体信息
"""
if task.mediainfo:
# 有媒体信息
mediainfo = deepcopy(task.mediainfo)
mediainfo.clear()
if isinstance(mediainfo, MusicInfo):
return schemas.MusicInfo(**mediainfo.to_dict())
return schemas.MediaInfo(**mediainfo.to_dict())
else:
# 没有媒体信息
meta: MetaBase = task.meta
if isinstance(meta, MetaMusic):
# 未识别的音乐按已解析元数据兜底展示;音乐年份为 int,
# 不能复用 MediaInfoyear 为 str),否则触发 pydantic 校验异常
return schemas.MusicInfo(
title=meta.name,
artists=list(meta.artists or []),
artist=meta.artist,
album=meta.album,
album_artist=meta.album_artist,
year=meta.year,
title_year=f"{meta.name} ({meta.year})" if meta.year else meta.name,
media_source=meta.media_source,
media_id=meta.media_id,
)
return schemas.MediaInfo(
title=meta.name,
year=meta.year,
title_year=f"{meta.name} ({meta.year})",
type=meta.type.value if meta.type else None,
)
@staticmethod
def __get_meta(task: TransferTask) -> schemas.MetaInfo:
"""
获取元数据
"""
if isinstance(task.meta, MetaMusic):
return schemas.MusicMeta(**task.meta.to_dict())
return schemas.MetaInfo(**task.meta.to_dict())
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
"""
添加整理任务,自动分组到对应的作业中
:return: True表示任务已添加,False表示任务无效或已存在(重复)
"""
if not all([task, task.meta, task.fileitem]):
return False
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return False
with job_lock:
__mediaid__ = self.__get_id(task)
# 同一个源文件可能在识别前后落入不同作业,必须跨作业去重。
if any(
self.__get_file_key(t.fileitem) == file_key
for job in self._job_view.values()
for t in job.tasks
):
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
return False
if __mediaid__ not in self._job_view:
self._job_view[__mediaid__] = TransferJob(
media=self.__get_media(task),
season=task.meta.begin_season,
tasks=[
TransferJobTask(
fileitem=task.fileitem,
meta=self.__get_meta(task),
downloader=task.downloader,
download_hash=task.download_hash,
state=state,
)
],
)
else:
# 不重复添加任务
if any(
[
self.__get_file_key(t.fileitem) == file_key
for t in self._job_view[__mediaid__].tasks
]
):
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
return False
self._job_view[__mediaid__].tasks.append(
TransferJobTask(
fileitem=task.fileitem,
meta=self.__get_meta(task),
downloader=task.downloader,
download_hash=task.download_hash,
state=state,
)
)
self._task_state_changed_at[file_key] = monotonic()
# 添加季集信息
if self._season_episodes.get(__mediaid__):
self._season_episodes[__mediaid__].extend(task.meta.episode_list)
self._season_episodes[__mediaid__] = list(
set(self._season_episodes[__mediaid__])
)
else:
self._season_episodes[__mediaid__] = task.meta.episode_list
return True
def migrate_task(self, task: TransferTask) -> bool:
"""
将任务从 meta 作业迁移到 media 作业
"""
curr_task, source_job_id = self.__remove_task_with_job_id(
task.fileitem, preserve_execution=True
)
if not self.add_task(task, state=curr_task.state if curr_task else "waiting"):
return False
if curr_task and task.mediainfo:
metaid = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
mediaid = self.__get_id(task)
if source_job_id == metaid and mediaid != metaid:
with job_lock:
self._meta_to_media_ids.setdefault(metaid, set()).add(mediaid)
return True
def __is_job_done(self, job_id: Tuple) -> bool:
"""
检查指定作业是否已完成
"""
if job_id not in self._job_view:
return True
return all(
task.state in ["completed", "failed"]
for task in self._job_view[job_id].tasks
)
def __pop_job(self, job_id: Tuple):
"""
移除指定作业和对应季集缓存
"""
job = self._job_view.pop(job_id, None)
self._season_episodes.pop(job_id, None)
if not job:
return
for task in job.tasks:
file_key = self.__get_file_key(task.fileitem)
if file_key:
self._task_state_changed_at.pop(file_key, None)
self._active_executions.discard(file_key)
def __remove_done_job_groups(self, job_ids: set[Tuple]):
"""
清理已进入终态的独立作业或关联作业组。
"""
candidates = set(job_ids)
for metaid, mediaids in list(self._meta_to_media_ids.items()):
related_ids = {metaid, *mediaids}
if not related_ids.intersection(candidates):
continue
if all(self.__is_job_done(job_id) for job_id in related_ids):
for job_id in related_ids:
self.__pop_job(job_id)
self._meta_to_media_ids.pop(metaid, None)
candidates.difference_update(related_ids)
referenced_ids = {
job_id
for metaid, mediaids in self._meta_to_media_ids.items()
for job_id in {metaid, *mediaids}
}
for job_id in candidates - referenced_ids:
if self.__is_job_done(job_id):
self.__pop_job(job_id)
def start_execution(self, task: TransferTask):
"""
标记任务仍由主程序整理线程直接执行。
:param task: 整理任务
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
self._active_executions.add(file_key)
def finish_execution(self, task: TransferTask):
"""
结束主程序整理线程对任务的直接执行标记。
:param task: 整理任务
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
self._active_executions.discard(file_key)
def expire_stale_running_tasks(
self, timeout_seconds: int
) -> List[Tuple[FileItem, int]]:
"""
将外部接管后长期无心跳的运行中任务标记失败并清理作业视图。
主程序整理线程仍在直接执行的任务不会被清理,以免把阻塞中的真实任务
误报为已终止。外部接管方可重复调用 ``running_task`` 刷新状态心跳。
:param timeout_seconds: 失活超时秒数,小于等于 0 时禁用
:return: 已失活任务及其无心跳秒数
"""
if timeout_seconds <= 0:
return []
current_time = monotonic()
expired: List[Tuple[FileItem, int]] = []
affected_job_ids: set[Tuple] = set()
with job_lock:
for mediaid, job in self._job_view.items():
for task in job.tasks:
file_key = self.__get_file_key(task.fileitem)
if (
not file_key
or task.state != "running"
or file_key in self._active_executions
):
continue
updated_at = self._task_state_changed_at.get(file_key, current_time)
inactive_seconds = current_time - updated_at
if inactive_seconds < timeout_seconds:
continue
task.state = "failed"
self._task_state_changed_at[file_key] = current_time
episodes = getattr(task.meta, "episode_list", None) or []
if mediaid in self._season_episodes:
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid]) - set(episodes)
)
expired.append((task.fileitem, int(inactive_seconds)))
affected_job_ids.add(mediaid)
self.__remove_done_job_groups(affected_job_ids)
return expired
def running_task(self, task: TransferTask):
"""
设置任务为运行中,并刷新外部异步任务的状态心跳。
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "running"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def finish_task(self, task: TransferTask):
"""
设置任务为完成/成功
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "completed"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
def fail_task(self, task: TransferTask):
"""
设置任务为失败
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ not in self._job_view:
return
# 更新状态
for t in self._job_view[__mediaid__].tasks:
if t.fileitem == task.fileitem:
t.state = "failed"
file_key = self.__get_file_key(t.fileitem)
if file_key:
self._task_state_changed_at[file_key] = monotonic()
break
# 移除剧集信息
if __mediaid__ in self._season_episodes:
self._season_episodes[__mediaid__] = list(
set(self._season_episodes[__mediaid__])
- set(task.meta.episode_list)
)
def fail_unfinished_task(self, task: TransferTask):
"""
将指定任务视图中的非终态任务标记为失败
"""
if not task or not task.fileitem:
return
file_key = self.__get_file_key(task.fileitem)
if not file_key:
return
with job_lock:
for mediaid, job in self._job_view.items():
for job_task in job.tasks:
if self.__get_file_key(job_task.fileitem) != file_key:
continue
if job_task.state not in ["completed", "failed"]:
job_task.state = "failed"
self._task_state_changed_at[file_key] = monotonic()
if mediaid in self._season_episodes:
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid])
- set(task.meta.episode_list)
)
return
def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]:
"""
根据文件项移除任务
"""
task, _ = self.__remove_task_with_job_id(fileitem)
return task
def __remove_task_with_job_id(
self,
fileitem: FileItem,
preserve_execution: bool = False,
) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]:
"""
根据文件项移除任务,并返回任务所在的作业ID
"""
file_key = self.__get_file_key(fileitem)
if not file_key:
return None, None
with job_lock:
for mediaid in list(self._job_view):
job = self._job_view[mediaid]
for task in job.tasks:
if self.__get_file_key(task.fileitem) == file_key:
job.tasks.remove(task)
self._task_state_changed_at.pop(file_key, None)
if not preserve_execution:
self._active_executions.discard(file_key)
# 如果没有作业了,则移除作业
if not job.tasks:
self._job_view.pop(mediaid)
# 移除季集信息
if mediaid in self._season_episodes:
episodes = getattr(task.meta, "episode_list", None) or []
self._season_episodes[mediaid] = list(
set(self._season_episodes[mediaid])
- set(episodes)
)
return task, mediaid
return None, None
def remove_job(self, task: TransferTask) -> Optional[TransferJob]:
"""
移除任务对应的作业(强制,线程不安全)
"""
with job_lock:
__mediaid__ = self.__get_id(task)
if __mediaid__ in self._job_view:
job = self._job_view[__mediaid__]
self.__pop_job(__mediaid__)
return job
return None
def try_remove_job(self, task: TransferTask):
"""
尝试移除任务对应的作业(严格检查未完成作业,线程安全)
"""
with job_lock:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
related_media_ids = set(self._meta_to_media_ids.get(__metaid__, set()))
if task.mediainfo:
related_media_ids.add(__mediaid__)
meta_done = self.__is_job_done(__metaid__)
media_done = all(
self.__is_job_done(mediaid) for mediaid in related_media_ids
)
if meta_done and media_done:
remove_ids = {__metaid__, self.__get_id(task), *related_media_ids}
for job_id in remove_ids:
self.__pop_job(job_id)
self._meta_to_media_ids.pop(__metaid__, None)
def is_done(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否整理完成(不管成功还是失败)
"""
with job_lock:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
if __metaid__ in self._job_view:
meta_done = all(
task.state in ["completed", "failed"]
for task in self._job_view[__metaid__].tasks
)
else:
meta_done = True
if __mediaid__ in self._job_view:
media_done = all(
task.state in ["completed", "failed"]
for task in self._job_view[__mediaid__].tasks
)
else:
media_done = True
return meta_done and media_done
def is_finished(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否已完成且有成功的记录
"""
with job_lock:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
if __metaid__ in self._job_view:
meta_finished = all(
task.state in ["completed", "failed"]
for task in self._job_view[__metaid__].tasks
)
else:
meta_finished = True
if __mediaid__ in self._job_view:
tasks = self._job_view[__mediaid__].tasks
media_finished = all(
task.state in ["completed", "failed"] for task in tasks
) and any(task.state == "completed" for task in tasks)
else:
media_finished = True
return meta_finished and media_finished
def is_success(self, task: TransferTask) -> bool:
"""
检查任务对应的作业是否全部成功
"""
with job_lock:
__metaid__ = self.__get_meta_id(
meta=task.meta, season=task.meta.begin_season
)
__mediaid__ = self.__get_media_id(
media=task.mediainfo, season=task.meta.begin_season
)
if __metaid__ in self._job_view:
meta_success = all(
task.state in ["completed"]
for task in self._job_view[__metaid__].tasks
)
else:
meta_success = True
if __mediaid__ in self._job_view:
media_success = all(
task.state in ["completed"]
for task in self._job_view[__mediaid__].tasks
)
else:
media_success = True
return meta_success and media_success
def get_all_torrent_hashes(self) -> set[str]:
"""
获取所有种子的哈希值集合
"""
with job_lock:
return {
task.download_hash
for job in self._job_view.values()
for task in job.tasks
}
def is_torrent_done(self, download_hash: str) -> bool:
"""
检查指定种子的所有任务是否都已完成
"""
with job_lock:
if any(
task.state not in {"completed", "failed"}
for job in self._job_view.values()
for task in job.tasks
if task.download_hash == download_hash
):
return False
return True
def is_torrent_success(self, download_hash: str) -> bool:
"""
检查指定种子的所有任务是否都已成功
"""
with job_lock:
if any(
task.state != "completed"
for job in self._job_view.values()
for task in job.tasks
if task.download_hash == download_hash
):
return False
return True
def has_tasks(
self,
meta: MetaBase,
mediainfo: Optional[MediaInfo] = None,
season: Optional[int] = None,
) -> bool:
"""
判断作业是否还有任务正在处理
"""
with job_lock:
if mediainfo:
__mediaid__ = self.__get_media_id(media=mediainfo, season=season)
if __mediaid__ in self._job_view:
return True
__metaid__ = self.__get_meta_id(meta=meta, season=season)
return (
__metaid__ in self._job_view
and len(self._job_view[__metaid__].tasks) > 0
)
def success_tasks(
self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None
) -> List[TransferJobTask]:
"""
获取作业中所有成功的任务
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return []
return [
task
for task in self._job_view[__mediaid__].tasks
if task.state == "completed"
]
def all_tasks(
self, media: MediaInfo, season: Optional[int] = None
) -> List[TransferJobTask]:
"""
获取作业中全部任务
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return []
return self._job_view[__mediaid__].tasks
def count(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int:
"""
获取作业中成功总数
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return 0
return len(
[
task
for task in self._job_view[__mediaid__].tasks
if task.state == "completed"
]
)
def size(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int:
"""
获取作业中所有成功文件总大小
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
if __mediaid__ not in self._job_view:
return 0
return sum(
[
task.fileitem.size
if task.fileitem.size is not None
else (
SystemUtils.get_directory_size(Path(task.fileitem.path))
if task.fileitem.storage == "local"
else 0
)
for task in self._job_view[__mediaid__].tasks
if task.state == "completed"
]
)
def total(self) -> int:
"""
获取所有任务总数
"""
with job_lock:
return sum([len(job.tasks) for job in self._job_view.values()])
def pending_total(self) -> int:
"""
获取未到终态的任务总数。
作业要等关联任务全部终态才整体移除,追更/分批场景下已完成任务会
跨批次残留在视图中;批次统计若用全量 total() 会把历史任务计入
「当前共 N 个文件」并压低进度百分比,因此只数未终态任务。
"""
with job_lock:
return sum(
1
for job in self._job_view.values()
for task in job.tasks
if task.state not in ("completed", "failed")
)
def list_jobs(self) -> List[TransferJob]:
"""
获取所有作业的任务列表
"""
with job_lock:
return list(self._job_view.values())
def season_episodes(
self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None
) -> List[int]:
"""
获取作业的季集清单
"""
with job_lock:
__mediaid__ = self.__get_media_id(media=media, season=season)
return self._season_episodes.get(__mediaid__) or []
class FailedRetryScheduler:
"""
负责失败整理记录的 debounce 聚合与 AI 重试调度。
"""
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
def __init__(self):
super().__init__()
self._retry_transfer_buffer: dict[str, list[int]] = {}
self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {}
self._retry_transfer_lock = asyncio.Lock()
async def close(self):
async with self._retry_transfer_lock:
timers = list(self._retry_transfer_timers.values())
self._retry_transfer_timers.clear()
self._retry_transfer_buffer.clear()
for timer in timers:
timer.cancel()
@staticmethod
def _build_retry_transfer_template_context(
history_ids: list[int],
) -> tuple[str, dict[str, int | str]]:
"""仅负责把失败重试任务的动态数据映射成模板变量。"""
is_batch = len(history_ids) > 1
task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry"
template_context: dict[str, int | str] = {
"history_ids_csv": ", ".join(str(item) for item in history_ids),
"history_count": len(history_ids),
}
if not is_batch:
template_context["history_id"] = history_ids[0]
return task_type, template_context
def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str:
"""根据失败记录数量构建统一的重试整理后台任务提示词。"""
task_type, template_context = self._build_retry_transfer_template_context(history_ids)
return get_prompt_manager().render_system_task_message(
task_type,
template_context=template_context,
)
async def schedule_retry(self, history_id: int, group_key: str = ""):
"""
同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。
"""
if not group_key:
group_key = f"_default_{history_id}"
async with self._retry_transfer_lock:
if group_key not in self._retry_transfer_buffer:
self._retry_transfer_buffer[group_key] = []
if history_id not in self._retry_transfer_buffer[group_key]:
self._retry_transfer_buffer[group_key].append(history_id)
logger.info(
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
)
if group_key in self._retry_transfer_timers:
self._retry_transfer_timers[group_key].cancel()
loop = asyncio.get_running_loop()
self._retry_transfer_timers[group_key] = loop.call_later(
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
lambda gk=group_key: asyncio.create_task(self._flush_retry_transfer(gk)),
)
async def _flush_retry_transfer(self, group_key: str):
"""
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。
"""
async with self._retry_transfer_lock:
history_ids = self._retry_transfer_buffer.pop(group_key, [])
self._retry_transfer_timers.pop(group_key, None)
if not history_ids:
return
ids_str = ", ".join(str(item) for item in history_ids)
logger.info(
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
)
try:
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过整理失败自动重试")
return
await manager.run_background_prompt(
message=self._build_retry_transfer_prompt(history_ids),
session_prefix="__agent_retry_transfer_batch",
reply_mode=ReplyMode.DISPATCH,
)
logger.info(
f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})"
)
except Exception as err:
logger.error(
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}"
)