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

331 lines
14 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.
"""添加订阅工具"""
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.chain.subscribe import SubscribeChain
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, MessageChannel
from ._music_utils import normalize_music_type
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 = MessageChannel(self._channel)
except ValueError:
return resolved_username
binding_keys = {
MessageChannel.Telegram: ("telegram_userid",),
MessageChannel.Discord: ("discord_userid",),
MessageChannel.Wechat: ("wechat_userid",),
MessageChannel.Feishu: ("feishu_userid", "feishu_openid"),
MessageChannel.WechatClawBot: ("wechatclawbot_userid",),
MessageChannel.Slack: ("slack_userid",),
MessageChannel.VoceChat: ("vocechat_userid",),
MessageChannel.SynologyChat: ("synologychat_userid",),
MessageChannel.QQ: ("qq_userid", "qq_openid"),
}.get(channel)
if not binding_keys:
return resolved_username
mapped_username = await self.run_blocking(
"db",
UserOper().get_name,
**{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)}"