Files
MoviePilot/app/agent/tools/impl/add_subscribe.py
T

331 lines
15 KiB
Python

"""添加订阅工具"""
from typing import List, Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.application.agentdata import get_agent_user_port
from app.chain.subscribe import SubscribeChain
from app.domain.media import normalize_music_type
from app.runtime.log import logger
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, NotificationChannel
class AddSubscribeInput(BaseModel):
"""添加订阅工具的输入参数模型"""
title: str = Field(
...,
description="The title of the media to subscribe to (e.g., 'The Matrix', 'Breaking Bad')",
)
year: Optional[str] = Field(
None,
description="Release year. Recommended for video; optional when a stable music ID is provided",
)
media_type: str = Field(..., description="Allowed values: movie, tv, music")
music_type: Optional[str] = Field(
None,
description="Required for music subscriptions: recording for one track or album for the complete album. Artists cannot be subscribed",
)
season: Optional[int] = Field(
None,
description=(
"Season number for TV shows (optional). If omitted, the subscription defaults to season 1 only. "
"To subscribe multiple seasons or the full series, call this tool separately for each season."
),
)
media_source: Optional[MediaSource] = Field(
None,
description="Media metadata source returned by search_media",
)
media_id: Optional[str] = Field(None, description="Native ID for media_source")
start_episode: Optional[int] = Field(
None,
description="Starting episode number for TV shows (optional, defaults to 1 if not specified)",
)
total_episode: Optional[int] = Field(
None,
description="Total number of episodes for TV shows (optional, will be auto-detected from TMDB if not specified)",
)
quality: Optional[str] = Field(
None,
description="Quality filter as regular expression (optional, e.g., 'BluRay|WEB-DL|HDTV')",
)
resolution: Optional[str] = Field(
None,
description="Resolution filter as regular expression (optional, e.g., '1080p|720p|2160p')",
)
effect: Optional[str] = Field(
None,
description="Effect filter as regular expression (optional, e.g., 'HDR|DV|SDR')",
)
audio_quality: Optional[str] = Field(
None,
description="Music quality tier filter: hires, lossless, lossy, or a regular-expression combination",
)
audio_format: Optional[str] = Field(
None,
description="Music audio-format filter as a regular expression, e.g. FLAC|ALAC|DSD",
)
min_bitrate: Optional[int] = Field(None, description="Minimum music bitrate in bits per second")
min_bit_depth: Optional[int] = Field(None, description="Minimum music bit depth")
min_sample_rate: Optional[int] = Field(None, description="Minimum music sample rate in Hz")
best_version: Optional[int] = Field(
None,
description="Enable quality upgrades: 0 for no, 1 for yes. Music upgrades use normalized audio quality",
)
filter_groups: Optional[List[str]] = Field(
None,
description="List of filter rule group names to apply (optional, can be obtained from query_rule_groups tool)",
)
sites: Optional[List[int]] = Field(
None,
description="List of site IDs to search from (optional, can be obtained from query_sites tool)",
)
class AddSubscribeTool(MoviePilotTool):
"""添加电影、电视剧、单曲或整张专辑订阅。"""
name: str = "add_subscribe"
tags: list[str] = [
ToolTag.Write,
ToolTag.Subscription,
ToolTag.Media,
]
description: str = (
"Add automated subscriptions for movies, TV shows, one music recording, or one complete album. "
"Music requires the source-native ID and music_type='recording' or 'album'; artists are browse-only. "
"Album subscriptions only complete after a resource is confirmed to cover the album's expected track count. "
"For TV shows, omitting `season` subscribes season 1 only by default; to subscribe multiple seasons or "
"the full series, call this tool once per season. Supports advanced filtering options like quality, "
"resolution, and effect filters using regular expressions."
)
args_schema: Type[BaseModel] = AddSubscribeInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据订阅参数生成友好的提示消息"""
title = kwargs.get("title", "")
year = kwargs.get("year", "")
media_type = kwargs.get("media_type", "")
music_type = kwargs.get("music_type")
season = kwargs.get("season")
message = f"添加订阅: {title}"
if year:
message += f" ({year})"
if media_type:
message += f" [{media_type}]"
if music_type:
message += f" [{music_type}]"
if season is not None:
message += f" 第{season}季"
elif media_type == "tv":
message += " 第1季(默认)"
return message
async def _resolve_subscribe_username(self) -> Optional[str]:
"""优先映射为系统用户名,未绑定时回退当前渠道用户名。"""
resolved_username = self._username
if not self._channel or not self._user_id:
return resolved_username
try:
channel = NotificationChannel(self._channel)
except ValueError:
return resolved_username
binding_keys = {
NotificationChannel.Telegram: ("telegram_userid",),
NotificationChannel.Discord: ("discord_userid",),
NotificationChannel.Wechat: ("wechat_userid",),
NotificationChannel.Feishu: ("feishu_userid", "feishu_openid"),
NotificationChannel.WechatClawBot: ("wechatclawbot_userid",),
NotificationChannel.Slack: ("slack_userid",),
NotificationChannel.VoceChat: ("vocechat_userid",),
NotificationChannel.SynologyChat: ("synologychat_userid",),
NotificationChannel.QQ: ("qq_userid", "qq_openid"),
}.get(channel)
if not binding_keys:
return resolved_username
mapped_username = await self.run_blocking(
"db",
get_agent_user_port().find_name_by_bindings,
{key: self._user_id for key in binding_keys},
)
return mapped_username or resolved_username
async def run(
self,
title: str,
media_type: str,
year: Optional[str] = None,
music_type: Optional[str] = None,
season: Optional[int] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
start_episode: Optional[int] = None,
total_episode: Optional[int] = None,
quality: Optional[str] = None,
resolution: Optional[str] = None,
effect: Optional[str] = None,
audio_quality: Optional[str] = None,
audio_format: Optional[str] = None,
min_bitrate: Optional[int] = None,
min_bit_depth: Optional[int] = None,
min_sample_rate: Optional[int] = None,
best_version: Optional[int] = None,
filter_groups: Optional[List[str]] = None,
sites: Optional[List[int]] = None,
**kwargs,
) -> str:
"""校验实体语义后调用订阅链添加精确订阅。"""
logger.info(
f"执行工具: {self.name}, 参数: title={title}, year={year}, media_type={media_type}, "
f"music_type={music_type}, season={season}, media_source={media_source}, "
f"media_id={media_id}, "
f"start_episode={start_episode}, "
f"total_episode={total_episode}, quality={quality}, resolution={resolution}, "
f"effect={effect}, filter_groups={filter_groups}, sites={sites}"
)
try:
subscribe_chain = SubscribeChain()
media_type_enum = MediaType.from_agent(media_type)
if not media_type_enum:
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv', 'music'"
normalized_music_type = None
if media_type_enum == MediaType.MUSIC:
normalized_music_type = normalize_music_type(
music_type,
allow_artist=False,
)
if not normalized_music_type:
return (
"错误:音乐订阅必须提供 music_type='recording'(单曲)"
"或 music_type='album'(整张专辑),艺术家不能订阅"
)
if not media_source or not media_id:
return "错误:音乐订阅必须同时提供 search_media 返回的 media_source 和 media_id"
if any(value is not None for value in (season, start_episode, total_episode)):
return "错误:音乐订阅没有季集参数,不能传入 season、start_episode 或 total_episode"
elif music_type:
return "错误:music_type 仅能与 media_type='music' 一起使用"
if (media_source is None) != (media_id is None):
return "错误:media_source 和 media_id 必须同时提供"
audio_filter_values = (
audio_quality, audio_format, min_bitrate, min_bit_depth, min_sample_rate
)
if media_type_enum != MediaType.MUSIC and any(
value is not None for value in audio_filter_values
):
return "错误:audio_quality、audio_format 和音频技术参数仅用于音乐订阅"
effective_season = (
season
if season is not None
else 1
if media_type_enum == MediaType.TV
else None
)
subscribe_username = await self._resolve_subscribe_username()
# 构建额外的订阅参数
subscribe_kwargs = {}
if normalized_music_type:
subscribe_kwargs["music_type"] = normalized_music_type
if start_episode is not None:
subscribe_kwargs["start_episode"] = start_episode
if total_episode is not None:
subscribe_kwargs["total_episode"] = total_episode
if quality:
subscribe_kwargs["quality"] = quality
if resolution:
subscribe_kwargs["resolution"] = resolution
if effect:
subscribe_kwargs["effect"] = effect
if audio_quality:
subscribe_kwargs["audio_quality"] = audio_quality
if audio_format:
subscribe_kwargs["audio_format"] = audio_format
if min_bitrate is not None:
subscribe_kwargs["min_bitrate"] = min_bitrate
if min_bit_depth is not None:
subscribe_kwargs["min_bit_depth"] = min_bit_depth
if min_sample_rate is not None:
subscribe_kwargs["min_sample_rate"] = min_sample_rate
if best_version is not None:
subscribe_kwargs["best_version"] = best_version
if filter_groups:
subscribe_kwargs["filter_groups"] = filter_groups
if sites:
subscribe_kwargs["sites"] = sites
sid, message = await subscribe_chain.async_add(
mtype=media_type_enum,
title=title,
year=year or "",
media_source=media_source,
media_id=media_id,
season=season,
username=subscribe_username,
**subscribe_kwargs,
)
if sid:
display_year = f" ({year})" if year else ""
music_label = (
"专辑" if normalized_music_type == MUSIC_ENTITY_ALBUM else "单曲"
) if normalized_music_type else ""
if message and "已存在" in message:
result_msg = f"{music_label}订阅已存在:{title}{display_year}"
if effective_season is not None:
result_msg += f" 第{effective_season}季"
result_msg += "。如需修改参数请先删除旧订阅。"
return result_msg
result_msg = f"成功添加{music_label}订阅:{title}{display_year}"
if effective_season is not None:
result_msg += f" 第{effective_season}季"
if season is None:
result_msg += "(未指定季号,默认按第一季订阅)"
if subscribe_kwargs:
params = []
if start_episode is not None:
params.append(f"开始集数: {start_episode}")
if total_episode is not None:
params.append(f"总集数: {total_episode}")
if quality:
params.append(f"质量过滤: {quality}")
if resolution:
params.append(f"分辨率过滤: {resolution}")
if effect:
params.append(f"特效过滤: {effect}")
if audio_quality:
params.append(f"音质等级: {audio_quality}")
if audio_format:
params.append(f"音频格式: {audio_format}")
if min_bitrate is not None:
params.append(f"最低码率: {round(min_bitrate / 1000)}kbps")
if min_bit_depth is not None:
params.append(f"最低位深: {min_bit_depth}bit")
if min_sample_rate is not None:
params.append(f"最低采样率: {min_sample_rate / 1000:g}kHz")
if best_version is not None:
params.append(f"音质洗版: {'开启' if best_version else '关闭'}")
if filter_groups:
params.append(f"规则组: {', '.join(filter_groups)}")
if sites:
params.append(f"站点: {', '.join(map(str, sites))}")
if params:
result_msg += f"\n配置参数: {', '.join(params)}"
return result_msg
else:
return f"添加订阅失败:{message}"
except Exception as e:
logger.error(f"添加订阅失败: {e}", exc_info=True)
return f"添加订阅时发生错误: {str(e)}"