mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-29 11:41:52 +08:00
Merge remote-tracking branch 'origin/v2' into v2
This commit is contained in:
@@ -63,7 +63,7 @@ class SearchMediaTool(MoviePilotTool):
|
||||
if media_type:
|
||||
if result.type != MediaType(media_type):
|
||||
continue
|
||||
if season and result.season != season:
|
||||
if season is not None and result.season != season:
|
||||
continue
|
||||
filtered_results.append(result)
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class SearchTorrentsTool(MoviePilotTool):
|
||||
if media_type and torrent.media_info:
|
||||
if torrent.media_info.type != MediaType(media_type):
|
||||
continue
|
||||
if season and torrent.meta_info and torrent.meta_info.begin_season != season:
|
||||
if season is not None and torrent.meta_info and torrent.meta_info.begin_season != season:
|
||||
continue
|
||||
# 使用正则表达式过滤标题(分辨率、质量等关键字)
|
||||
if regex_pattern and torrent.torrent_info and torrent.torrent_info.title:
|
||||
|
||||
@@ -195,7 +195,7 @@ async def seasons(mediaid: Optional[str] = None,
|
||||
tmdbid = int(mediaid[5:])
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
|
||||
if seasons_info:
|
||||
if season:
|
||||
if season is not None:
|
||||
return [sea for sea in seasons_info if sea.season_number == season]
|
||||
return seasons_info
|
||||
if title:
|
||||
@@ -207,11 +207,11 @@ async def seasons(mediaid: Optional[str] = None,
|
||||
if settings.RECOGNIZE_SOURCE == "themoviedb":
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
|
||||
if seasons_info:
|
||||
if season:
|
||||
if season is not None:
|
||||
return [sea for sea in seasons_info if sea.season_number == season]
|
||||
return seasons_info
|
||||
else:
|
||||
sea = season or 1
|
||||
sea = season if season is not None else 1
|
||||
return [schemas.MediaSeason(
|
||||
season_number=sea,
|
||||
poster_path=mediainfo.poster_path,
|
||||
|
||||
@@ -199,7 +199,7 @@ async def subscribe_mediaid(
|
||||
# 使用名称检查订阅
|
||||
if title_check and title:
|
||||
meta = MetaInfo(title)
|
||||
if season:
|
||||
if season is not None:
|
||||
meta.begin_season = season
|
||||
result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
|
||||
|
||||
@@ -958,10 +958,10 @@ class MediaChain(ChainBase):
|
||||
year = None
|
||||
if tmdbinfo.get('release_date'):
|
||||
year = tmdbinfo['release_date'][:4]
|
||||
elif tmdbinfo.get('seasons') and season:
|
||||
elif tmdbinfo.get('seasons') and season is not None:
|
||||
for seainfo in tmdbinfo['seasons']:
|
||||
season_number = seainfo.get("season_number")
|
||||
if not season_number:
|
||||
if season_number is None:
|
||||
continue
|
||||
air_date = seainfo.get("air_date")
|
||||
if air_date and season_number == season:
|
||||
|
||||
@@ -1380,8 +1380,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
|
||||
# 获取下载Hash
|
||||
if download_history and (not downloader or not download_hash):
|
||||
downloader = download_history.downloader
|
||||
download_hash = download_history.download_hash
|
||||
_downloader = download_history.downloader
|
||||
_download_hash = download_history.download_hash
|
||||
else:
|
||||
_downloader = downloader
|
||||
_download_hash = download_hash
|
||||
|
||||
# 后台整理
|
||||
transfer_task = TransferTask(
|
||||
@@ -1395,8 +1398,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
scrape=scrape,
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
downloader=_downloader,
|
||||
download_hash=_download_hash,
|
||||
download_history=download_history,
|
||||
manual=manual,
|
||||
background=background
|
||||
|
||||
@@ -19,41 +19,42 @@ class CookieHelper:
|
||||
"username": [
|
||||
'//input[@name="username"]',
|
||||
'//input[@id="form_item_username"]',
|
||||
'//input[@id="username"]'
|
||||
'//input[@id="username"]',
|
||||
],
|
||||
"password": [
|
||||
'//input[@name="password"]',
|
||||
'//input[@id="form_item_password"]',
|
||||
'//input[@id="password"]',
|
||||
'//input[@type="password"]'
|
||||
'//input[@type="password"]',
|
||||
],
|
||||
"captcha": [
|
||||
'//input[@name="imagestring"]',
|
||||
'//input[@name="captcha"]',
|
||||
'//input[@id="form_item_captcha"]',
|
||||
'//input[@placeholder="驗證碼"]'
|
||||
'//input[@placeholder="驗證碼"]',
|
||||
],
|
||||
"captcha_img": [
|
||||
'//img[@alt="captcha"]/@src',
|
||||
'//img[@alt="CAPTCHA"]/@src',
|
||||
'//img[@alt="SECURITY CODE"]/@src',
|
||||
'//img[@id="LAY-user-get-vercode"]/@src',
|
||||
'//img[contains(@src,"/api/getCaptcha")]/@src'
|
||||
'//img[contains(@src,"/api/getCaptcha")]/@src',
|
||||
],
|
||||
"submit": [
|
||||
'//input[@type="submit"]',
|
||||
'//button[@type="submit"]',
|
||||
'//button[@lay-filter="login"]',
|
||||
'//button[@lay-filter="formLogin"]',
|
||||
'//input[@type="button"][@value="登录"]'
|
||||
'//input[@type="button"][@value="登录"]',
|
||||
'//input[@id="submit-btn"]',
|
||||
],
|
||||
"error": [
|
||||
"//table[@class='main']//td[@class='text']/text()"
|
||||
"//table[@class='main']//td[@class='text']/text()",
|
||||
],
|
||||
"twostep": [
|
||||
'//input[@name="two_step_code"]',
|
||||
'//input[@name="2fa_secret"]',
|
||||
'//input[@name="otp"]'
|
||||
'//input[@name="otp"]',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -382,7 +382,10 @@ class RssHelper:
|
||||
size = int(size_attr)
|
||||
|
||||
# 发布日期
|
||||
pubdate_nodes = item.xpath('.//pubDate | .//published | .//updated')
|
||||
pubdate_nodes = item.xpath('./pubDate | ./published | ./updated')
|
||||
if not pubdate_nodes:
|
||||
pubdate_nodes = item.xpath('.//*[local-name()="pubDate"] | .//*[local-name()="published"] | .//*[local-name()="updated"]')
|
||||
|
||||
pubdate = ""
|
||||
if pubdate_nodes and pubdate_nodes[0].text:
|
||||
pubdate = StringUtils.get_time(pubdate_nodes[0].text)
|
||||
|
||||
@@ -139,9 +139,23 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
发送通知消息
|
||||
:param message: 消息通知对象
|
||||
"""
|
||||
for conf in self.get_configs().values():
|
||||
# DEBUG: Log entry and configs
|
||||
configs = self.get_configs()
|
||||
logger.debug(f"[Discord] post_message 被调用,message.source={message.source}, "
|
||||
f"message.userid={message.userid}, message.channel={message.channel}")
|
||||
logger.debug(f"[Discord] 当前配置数量: {len(configs)}, 配置名称: {list(configs.keys())}")
|
||||
logger.debug(f"[Discord] 当前实例数量: {len(self.get_instances())}, 实例名称: {list(self.get_instances().keys())}")
|
||||
|
||||
if not configs:
|
||||
logger.warning("[Discord] get_configs() 返回空,没有可用的 Discord 配置")
|
||||
return
|
||||
|
||||
for conf in configs.values():
|
||||
logger.debug(f"[Discord] 检查配置: name={conf.name}, type={conf.type}, enabled={conf.enabled}")
|
||||
if not self.check_message(message, conf.name):
|
||||
logger.debug(f"[Discord] check_message 返回 False,跳过配置: {conf.name}")
|
||||
continue
|
||||
logger.debug(f"[Discord] check_message 通过,准备发送到: {conf.name}")
|
||||
targets = message.targets
|
||||
userid = message.userid
|
||||
if not userid and targets is not None:
|
||||
@@ -150,13 +164,18 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
logger.warn("用户没有指定 Discord 用户ID,消息无法发送")
|
||||
return
|
||||
client: Discord = self.get_instance(conf.name)
|
||||
logger.debug(f"[Discord] get_instance('{conf.name}') 返回: {client is not None}")
|
||||
if client:
|
||||
client.send_msg(title=message.title, text=message.text,
|
||||
logger.debug(f"[Discord] 调用 client.send_msg, userid={userid}, title={message.title[:50] if message.title else None}...")
|
||||
result = client.send_msg(title=message.title, text=message.text,
|
||||
image=message.image, userid=userid, link=message.link,
|
||||
buttons=message.buttons,
|
||||
original_message_id=message.original_message_id,
|
||||
original_chat_id=message.original_chat_id,
|
||||
mtype=message.mtype)
|
||||
logger.debug(f"[Discord] send_msg 返回结果: {result}")
|
||||
else:
|
||||
logger.warning(f"[Discord] 未找到配置 '{conf.name}' 对应的 Discord 客户端实例")
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional, List, Dict, Any, Tuple, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
@@ -33,6 +34,9 @@ class Discord:
|
||||
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
|
||||
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
|
||||
**kwargs):
|
||||
logger.debug(f"[Discord] 初始化 Discord 实例: name={kwargs.get('name')}, "
|
||||
f"GUILD_ID={DISCORD_GUILD_ID}, CHANNEL_ID={DISCORD_CHANNEL_ID}, "
|
||||
f"TOKEN={'已配置' if DISCORD_BOT_TOKEN else '未配置'}")
|
||||
if not DISCORD_BOT_TOKEN:
|
||||
logger.error("Discord Bot Token 未配置!")
|
||||
return
|
||||
@@ -40,10 +44,14 @@ class Discord:
|
||||
self._token = DISCORD_BOT_TOKEN
|
||||
self._guild_id = self._to_int(DISCORD_GUILD_ID)
|
||||
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
|
||||
logger.debug(f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}")
|
||||
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
|
||||
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
|
||||
if kwargs.get("name"):
|
||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
||||
# URL encode the source name to handle special characters in config names
|
||||
encoded_name = quote(kwargs.get('name'), safe='')
|
||||
self._ds_url = f"{self._ds_url}&source={encoded_name}"
|
||||
logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}")
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
@@ -59,6 +67,7 @@ class Discord:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._ready_event = threading.Event()
|
||||
self._user_dm_cache: Dict[str, discord.DMChannel] = {}
|
||||
self._user_chat_mapping: Dict[str, str] = {} # userid -> chat_id mapping for reply targeting
|
||||
self._broadcast_channel = None
|
||||
self._bot_user_id: Optional[int] = None
|
||||
|
||||
@@ -86,6 +95,9 @@ class Discord:
|
||||
if not self._should_process_message(message):
|
||||
return
|
||||
|
||||
# Update user-chat mapping for reply targeting
|
||||
self._update_user_chat_mapping(str(message.author.id), str(message.channel.id))
|
||||
|
||||
cleaned_text = self._clean_bot_mention(message.content or "")
|
||||
username = message.author.display_name or message.author.global_name or message.author.name
|
||||
payload = {
|
||||
@@ -112,6 +124,10 @@ class Discord:
|
||||
except Exception as e:
|
||||
logger.error(f"处理 Discord 交互响应失败:{e}")
|
||||
|
||||
# Update user-chat mapping for reply targeting
|
||||
if interaction.user and interaction.channel:
|
||||
self._update_user_chat_mapping(str(interaction.user.id), str(interaction.channel.id))
|
||||
|
||||
username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \
|
||||
if interaction.user else None
|
||||
payload = {
|
||||
@@ -168,13 +184,19 @@ class Discord:
|
||||
original_message_id: Optional[Union[int, str]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
mtype: Optional['NotificationType'] = None) -> Optional[bool]:
|
||||
logger.debug(f"[Discord] send_msg 被调用: userid={userid}, title={title[:50] if title else None}...")
|
||||
logger.debug(f"[Discord] get_state() = {self.get_state()}, "
|
||||
f"_ready_event.is_set() = {self._ready_event.is_set()}, "
|
||||
f"_client = {self._client is not None}")
|
||||
if not self.get_state():
|
||||
logger.warning("[Discord] get_state() 返回 False,Bot 未就绪,无法发送消息")
|
||||
return False
|
||||
if not title and not text:
|
||||
logger.warn("标题和内容不能同时为空")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.debug(f"[Discord] 准备异步发送消息...")
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._send_message(title=title, text=text, image=image, userid=userid,
|
||||
link=link, buttons=buttons,
|
||||
@@ -182,7 +204,9 @@ class Discord:
|
||||
original_chat_id=original_chat_id,
|
||||
mtype=mtype),
|
||||
self._loop)
|
||||
return future.result(timeout=30)
|
||||
result = future.result(timeout=30)
|
||||
logger.debug(f"[Discord] 异步发送完成,结果: {result}")
|
||||
return result
|
||||
except Exception as err:
|
||||
logger.error(f"发送 Discord 消息失败:{err}")
|
||||
return False
|
||||
@@ -254,7 +278,9 @@ class Discord:
|
||||
original_message_id: Optional[Union[int, str]],
|
||||
original_chat_id: Optional[str],
|
||||
mtype: Optional['NotificationType'] = None) -> bool:
|
||||
logger.debug(f"[Discord] _send_message: userid={userid}, original_chat_id={original_chat_id}")
|
||||
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
|
||||
logger.debug(f"[Discord] _resolve_channel 返回: {channel}, type={type(channel)}")
|
||||
if not channel:
|
||||
logger.error("未找到可用的 Discord 频道或私聊")
|
||||
return False
|
||||
@@ -264,11 +290,18 @@ class Discord:
|
||||
content = None
|
||||
|
||||
if original_message_id and original_chat_id:
|
||||
logger.debug(f"[Discord] 编辑现有消息: message_id={original_message_id}")
|
||||
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
|
||||
content=content, embed=embed, view=view)
|
||||
|
||||
await channel.send(content=content, embed=embed, view=view)
|
||||
return True
|
||||
logger.debug(f"[Discord] 发送新消息到频道: {channel}")
|
||||
try:
|
||||
await channel.send(content=content, embed=embed, view=view)
|
||||
logger.debug("[Discord] 消息发送成功")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 发送消息到频道失败: {e}")
|
||||
return False
|
||||
|
||||
async def _send_list_message(self, embeds: List[discord.Embed],
|
||||
userid: Optional[str],
|
||||
@@ -515,26 +548,54 @@ class Discord:
|
||||
return view
|
||||
|
||||
async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):
|
||||
# 优先使用明确的聊天 ID
|
||||
"""
|
||||
Resolve the channel to send messages to.
|
||||
Priority order:
|
||||
1. `chat_id` (original channel where user sent the message) - for contextual replies
|
||||
2. `userid` mapping (channel where user last sent a message) - for contextual replies
|
||||
3. Configured `_channel_id` (broadcast channel) - for system notifications
|
||||
4. Any available text channel in configured guild - fallback
|
||||
5. `userid` (DM) - for private conversations as a final fallback
|
||||
"""
|
||||
logger.debug(f"[Discord] _resolve_channel: userid={userid}, chat_id={chat_id}, "
|
||||
f"_channel_id={self._channel_id}, _guild_id={self._guild_id}")
|
||||
|
||||
# Priority 1: Use explicit chat_id (reply to the same channel where user sent message)
|
||||
if chat_id:
|
||||
logger.debug(f"[Discord] 尝试通过 chat_id={chat_id} 获取原始频道")
|
||||
channel = self._client.get_channel(int(chat_id))
|
||||
if channel:
|
||||
logger.debug(f"[Discord] 通过 get_channel 找到频道: {channel}")
|
||||
return channel
|
||||
try:
|
||||
return await self._client.fetch_channel(int(chat_id))
|
||||
channel = await self._client.fetch_channel(int(chat_id))
|
||||
logger.debug(f"[Discord] 通过 fetch_channel 找到频道: {channel}")
|
||||
return channel
|
||||
except Exception as err:
|
||||
logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}")
|
||||
|
||||
# 私聊
|
||||
# Priority 2: Use user-chat mapping (reply to where the user last sent a message)
|
||||
if userid:
|
||||
dm = await self._get_dm_channel(str(userid))
|
||||
if dm:
|
||||
return dm
|
||||
mapped_chat_id = self._get_user_chat_id(str(userid))
|
||||
if mapped_chat_id:
|
||||
logger.debug(f"[Discord] 从用户映射获取 chat_id={mapped_chat_id}")
|
||||
channel = self._client.get_channel(int(mapped_chat_id))
|
||||
if channel:
|
||||
logger.debug(f"[Discord] 通过映射找到频道: {channel}")
|
||||
return channel
|
||||
try:
|
||||
channel = await self._client.fetch_channel(int(mapped_chat_id))
|
||||
logger.debug(f"[Discord] 通过 fetch_channel 找到映射频道: {channel}")
|
||||
return channel
|
||||
except Exception as err:
|
||||
logger.warn(f"通过映射的 chat_id 获取 Discord 频道失败:{err}")
|
||||
|
||||
# 配置的广播频道
|
||||
# Priority 3: Use configured broadcast channel (for system notifications)
|
||||
if self._broadcast_channel:
|
||||
logger.debug(f"[Discord] 使用缓存的广播频道: {self._broadcast_channel}")
|
||||
return self._broadcast_channel
|
||||
if self._channel_id:
|
||||
logger.debug(f"[Discord] 尝试通过配置的 _channel_id={self._channel_id} 获取频道")
|
||||
channel = self._client.get_channel(self._channel_id)
|
||||
if not channel:
|
||||
try:
|
||||
@@ -544,9 +605,11 @@ class Discord:
|
||||
channel = None
|
||||
self._broadcast_channel = channel
|
||||
if channel:
|
||||
logger.debug(f"[Discord] 通过配置的频道ID找到频道: {channel}")
|
||||
return channel
|
||||
|
||||
# 按 Guild 寻找一个可用文本频道
|
||||
# Priority 4: Find any available text channel in guild (fallback)
|
||||
logger.debug(f"[Discord] 尝试在 Guild 中寻找可用频道")
|
||||
target_guilds = []
|
||||
if self._guild_id:
|
||||
guild = self._client.get_guild(self._guild_id)
|
||||
@@ -554,22 +617,47 @@ class Discord:
|
||||
target_guilds.append(guild)
|
||||
else:
|
||||
target_guilds = list(self._client.guilds)
|
||||
logger.debug(f"[Discord] 目标 Guilds 数量: {len(target_guilds)}")
|
||||
|
||||
for guild in target_guilds:
|
||||
for channel in guild.text_channels:
|
||||
if guild.me and channel.permissions_for(guild.me).send_messages:
|
||||
logger.debug(f"[Discord] 在 Guild 中找到可用频道: {channel}")
|
||||
self._broadcast_channel = channel
|
||||
return channel
|
||||
|
||||
# Priority 5: Fallback to DM (only if no channel available)
|
||||
if userid:
|
||||
logger.debug(f"[Discord] 回退到私聊: userid={userid}")
|
||||
dm = await self._get_dm_channel(str(userid))
|
||||
if dm:
|
||||
logger.debug(f"[Discord] 获取到私聊频道: {dm}")
|
||||
return dm
|
||||
else:
|
||||
logger.debug(f"[Discord] 无法获取用户 {userid} 的私聊频道")
|
||||
|
||||
return None
|
||||
|
||||
async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
|
||||
logger.debug(f"[Discord] _get_dm_channel: userid={userid}")
|
||||
if userid in self._user_dm_cache:
|
||||
logger.debug(f"[Discord] 从缓存获取私聊频道: {self._user_dm_cache.get(userid)}")
|
||||
return self._user_dm_cache.get(userid)
|
||||
try:
|
||||
user_obj = self._client.get_user(int(userid)) or await self._client.fetch_user(int(userid))
|
||||
logger.debug(f"[Discord] 尝试获取/创建用户 {userid} 的私聊频道")
|
||||
user_obj = self._client.get_user(int(userid))
|
||||
logger.debug(f"[Discord] get_user 结果: {user_obj}")
|
||||
if not user_obj:
|
||||
user_obj = await self._client.fetch_user(int(userid))
|
||||
logger.debug(f"[Discord] fetch_user 结果: {user_obj}")
|
||||
if not user_obj:
|
||||
logger.debug(f"[Discord] 无法找到用户 {userid}")
|
||||
return None
|
||||
dm = user_obj.dm_channel or await user_obj.create_dm()
|
||||
dm = user_obj.dm_channel
|
||||
logger.debug(f"[Discord] 用户现有 dm_channel: {dm}")
|
||||
if not dm:
|
||||
dm = await user_obj.create_dm()
|
||||
logger.debug(f"[Discord] 创建新的 dm_channel: {dm}")
|
||||
if dm:
|
||||
self._user_dm_cache[userid] = dm
|
||||
return dm
|
||||
@@ -577,6 +665,25 @@ class Discord:
|
||||
logger.error(f"获取 Discord 私聊失败:{err}")
|
||||
return None
|
||||
|
||||
def _update_user_chat_mapping(self, userid: str, chat_id: str) -> None:
|
||||
"""
|
||||
Update user-chat mapping for reply targeting.
|
||||
This ensures replies go to the same channel where the user sent the message.
|
||||
:param userid: User ID
|
||||
:param chat_id: Channel/Chat ID where the user sent the message
|
||||
"""
|
||||
if userid and chat_id:
|
||||
self._user_chat_mapping[userid] = chat_id
|
||||
logger.debug(f"[Discord] 更新用户频道映射: userid={userid} -> chat_id={chat_id}")
|
||||
|
||||
def _get_user_chat_id(self, userid: str) -> Optional[str]:
|
||||
"""
|
||||
Get the chat ID where the user last sent a message.
|
||||
:param userid: User ID
|
||||
:return: Chat ID or None if not found
|
||||
"""
|
||||
return self._user_chat_mapping.get(userid)
|
||||
|
||||
def _should_process_message(self, message: discord.Message) -> bool:
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
return True
|
||||
|
||||
@@ -21,7 +21,7 @@ class DoubanScraper:
|
||||
# 电影元数据文件
|
||||
doc = self.__gen_movie_nfo_file(mediainfo=mediainfo)
|
||||
else:
|
||||
if season:
|
||||
if season is not None:
|
||||
# 季元数据文件
|
||||
doc = self.__gen_tv_season_nfo_file(mediainfo=mediainfo, season=season)
|
||||
else:
|
||||
@@ -41,7 +41,7 @@ class DoubanScraper:
|
||||
:param episode: 集号
|
||||
"""
|
||||
ret_dict = {}
|
||||
if season:
|
||||
if season is not None:
|
||||
# 豆瓣无季图片
|
||||
return {}
|
||||
if episode:
|
||||
|
||||
@@ -3,7 +3,7 @@ import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import List, Optional, Tuple, Union, Dict
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from hashlib import sha256
|
||||
|
||||
import oss2
|
||||
@@ -20,7 +20,7 @@ from app.modules.filemanager.storages import transfer_process
|
||||
from app.schemas.types import StorageSchema
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.limit import QpsRateLimiter
|
||||
from app.utils.limit import QpsRateLimiter, RateStats
|
||||
|
||||
|
||||
lock = Lock()
|
||||
@@ -46,22 +46,23 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
# 文件块大小,默认10MB
|
||||
chunk_size = 10 * 1024 * 1024
|
||||
|
||||
# 流控重试间隔时间
|
||||
retry_delay = 70
|
||||
# 下载接口单独限流
|
||||
download_endpoint = "/open/ufile/downurl"
|
||||
# 风控触发后休眠时间(秒)
|
||||
limit_sleep_seconds = 3600
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._auth_state = {}
|
||||
self.session = httpx.Client(follow_redirects=True, timeout=20.0)
|
||||
self._init_session()
|
||||
self.qps_limiter: Dict[str, QpsRateLimiter] = {
|
||||
"/open/ufile/files": QpsRateLimiter(4),
|
||||
"/open/folder/get_info": QpsRateLimiter(3),
|
||||
"/open/ufile/move": QpsRateLimiter(2),
|
||||
"/open/ufile/copy": QpsRateLimiter(2),
|
||||
"/open/ufile/update": QpsRateLimiter(2),
|
||||
"/open/ufile/delete": QpsRateLimiter(2),
|
||||
}
|
||||
# 接口限流
|
||||
self._download_limiter = QpsRateLimiter(1)
|
||||
self._api_limiter = QpsRateLimiter(3)
|
||||
self._limit_until = 0.0
|
||||
self._limit_lock = Lock()
|
||||
# 总体 QPS/QPM/QPH 统计
|
||||
self._rate_stats = RateStats(source="115")
|
||||
|
||||
def _init_session(self):
|
||||
"""
|
||||
@@ -209,8 +210,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{settings.U115_AUTH_SERVER}/u115/token",
|
||||
params={"state": state}
|
||||
f"{settings.U115_AUTH_SERVER}/u115/token", params={"state": state}
|
||||
)
|
||||
if resp is None:
|
||||
return {}, "无法连接到授权服务器"
|
||||
@@ -221,12 +221,14 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
if status == "completed":
|
||||
data = result.get("data", {})
|
||||
if data:
|
||||
self.set_config({
|
||||
"refresh_time": int(time.time()),
|
||||
"access_token": data.get("access_token"),
|
||||
"refresh_token": data.get("refresh_token"),
|
||||
"expires_in": data.get("expires_in"),
|
||||
})
|
||||
self.set_config(
|
||||
{
|
||||
"refresh_time": int(time.time()),
|
||||
"access_token": data.get("access_token"),
|
||||
"refresh_token": data.get("refresh_token"),
|
||||
"expires_in": data.get("expires_in"),
|
||||
}
|
||||
)
|
||||
self._auth_state = {}
|
||||
return {"status": 2, "tip": "授权成功"}, ""
|
||||
return {}, "授权服务器返回数据不完整"
|
||||
@@ -292,11 +294,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
# 错误日志标志
|
||||
no_error_log = kwargs.pop("no_error_log", False)
|
||||
# 重试次数
|
||||
retry_times = kwargs.pop("retry_limit", 5)
|
||||
retry_times = kwargs.pop("retry_limit", 3)
|
||||
|
||||
# qps 速率限制
|
||||
if endpoint in self.qps_limiter:
|
||||
self.qps_limiter[endpoint].acquire()
|
||||
# 按接口类型限流
|
||||
if endpoint == self.download_endpoint:
|
||||
self._download_limiter.acquire()
|
||||
else:
|
||||
self._api_limiter.acquire()
|
||||
self._rate_stats.record()
|
||||
|
||||
# 风控冷却期间阻止所有接口调用,统一等待
|
||||
with self._limit_lock:
|
||||
wait_until = self._limit_until
|
||||
if wait_until > time.time():
|
||||
wait_secs = wait_until - time.time()
|
||||
logger.info(
|
||||
f"【115】风控冷却中,本请求等待 {wait_secs:.0f} 秒后再调用接口..."
|
||||
)
|
||||
time.sleep(wait_secs)
|
||||
|
||||
try:
|
||||
resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs)
|
||||
@@ -310,13 +325,24 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
kwargs["retry_limit"] = retry_times
|
||||
|
||||
# 处理速率限制
|
||||
if resp.status_code == 429:
|
||||
reset_time = 5 + int(resp.headers.get("X-RateLimit-Reset", 60))
|
||||
logger.debug(
|
||||
f"【115】{method} 请求 {endpoint} 限流,等待{reset_time}秒后重试"
|
||||
self._rate_stats.log_stats("warning")
|
||||
if retry_times <= 0:
|
||||
logger.error(
|
||||
f"【115】{method} 请求 {endpoint} 触发限流(429),重试次数用尽!"
|
||||
)
|
||||
return None
|
||||
with self._limit_lock:
|
||||
self._limit_until = max(
|
||||
self._limit_until,
|
||||
time.time() + self.limit_sleep_seconds,
|
||||
)
|
||||
logger.warning(
|
||||
f"【115】触发限流(429),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..."
|
||||
)
|
||||
time.sleep(reset_time)
|
||||
time.sleep(self.limit_sleep_seconds)
|
||||
kwargs["retry_limit"] = retry_times - 1
|
||||
kwargs["no_error_log"] = no_error_log
|
||||
return self._request_api(method, endpoint, result_key, **kwargs)
|
||||
|
||||
# 处理请求错误
|
||||
@@ -329,6 +355,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
)
|
||||
return None
|
||||
kwargs["retry_limit"] = retry_times - 1
|
||||
kwargs["no_error_log"] = no_error_log
|
||||
sleep_duration = 2 ** (5 - retry_times + 1)
|
||||
logger.info(
|
||||
f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..."
|
||||
@@ -339,20 +366,27 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
# 返回数据
|
||||
ret_data = resp.json()
|
||||
if ret_data.get("code") not in (0, 20004):
|
||||
error_msg = ret_data.get("message")
|
||||
error_msg = ret_data.get("message", "")
|
||||
if not no_error_log:
|
||||
logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}")
|
||||
if "已达到当前访问上限" in error_msg:
|
||||
self._rate_stats.log_stats("warning")
|
||||
if retry_times <= 0:
|
||||
logger.error(
|
||||
f"【115】{method} 请求 {endpoint} 达到访问上限,重试次数用尽!"
|
||||
f"【115】{method} 请求 {endpoint} 触发风控(访问上限),重试次数用尽!"
|
||||
)
|
||||
return None
|
||||
kwargs["retry_limit"] = retry_times - 1
|
||||
logger.info(
|
||||
f"【115】{method} 请求 {endpoint} 达到访问上限,等待 {self.retry_delay} 秒后重试..."
|
||||
with self._limit_lock:
|
||||
self._limit_until = max(
|
||||
self._limit_until,
|
||||
time.time() + self.limit_sleep_seconds,
|
||||
)
|
||||
logger.warning(
|
||||
f"【115】触发风控(访问上限),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..."
|
||||
)
|
||||
time.sleep(self.retry_delay)
|
||||
time.sleep(self.limit_sleep_seconds)
|
||||
kwargs["retry_limit"] = retry_times - 1
|
||||
kwargs["no_error_log"] = no_error_log
|
||||
return self._request_api(method, endpoint, result_key, **kwargs)
|
||||
return None
|
||||
|
||||
@@ -879,7 +913,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
企业级复制实现(支持目录递归复制)
|
||||
复制
|
||||
"""
|
||||
if fileitem.fileid is None:
|
||||
fileitem = self.get_item(Path(fileitem.path))
|
||||
@@ -912,7 +946,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||||
"""
|
||||
原子性移动操作实现
|
||||
移动
|
||||
"""
|
||||
if fileitem.fileid is None:
|
||||
fileitem = self.get_item(Path(fileitem.path))
|
||||
@@ -950,7 +984,7 @@ class U115Pan(StorageBase, metaclass=WeakSingleton):
|
||||
|
||||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||||
"""
|
||||
获取带有企业级配额信息的存储使用情况
|
||||
存储使用情况
|
||||
"""
|
||||
try:
|
||||
resp = self._request_api("GET", "/open/user/info", "data")
|
||||
|
||||
@@ -295,6 +295,7 @@ class TransHandler:
|
||||
elif overwrite_mode == 'never':
|
||||
# 存在不覆盖
|
||||
self.__update_result(result=result,
|
||||
success=False,
|
||||
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
|
||||
fileitem=fileitem,
|
||||
target_item=target_item,
|
||||
@@ -313,6 +314,9 @@ class TransHandler:
|
||||
logger.info(
|
||||
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
|
||||
self.__delete_version_files(target_oper, new_file)
|
||||
else:
|
||||
# 附加文件 总是需要覆盖
|
||||
overflag = True
|
||||
|
||||
# 整理文件
|
||||
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
|
||||
@@ -797,8 +801,8 @@ class TransHandler:
|
||||
continue
|
||||
if media_file.type != "file":
|
||||
continue
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
|
||||
if f".{media_file.extension.lower()}" not in media_exts:
|
||||
# 当前只有视频文件需要保留最新版本,其余格式无需处理,以避免误删 (issue 5449)
|
||||
if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
# 识别文件中的季集信息
|
||||
filemeta = MetaInfoPath(media_path)
|
||||
|
||||
@@ -428,6 +428,12 @@ class SiteSpider:
|
||||
if pubdate_str:
|
||||
pubdate_str = pubdate_str.replace('\n', ' ').strip()
|
||||
self.torrents_info['pubdate'] = self.__filter_text(pubdate_str, selector.get('filters'))
|
||||
if self.torrents_info.get('pubdate'):
|
||||
try:
|
||||
if not isinstance(self.torrents_info['pubdate'], datetime.datetime):
|
||||
datetime.datetime.strptime(str(self.torrents_info['pubdate']), '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, TypeError):
|
||||
self.torrents_info['pubdate'] = StringUtils.unify_datetime_str(str(self.torrents_info['pubdate']))
|
||||
|
||||
def __get_date_elapsed(self, torrent: Any):
|
||||
# torrent date elapsed text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
from threading import Lock
|
||||
from typing import List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from slack_bolt import App
|
||||
@@ -42,7 +43,9 @@ class Slack:
|
||||
|
||||
# 标记消息来源
|
||||
if kwargs.get("name"):
|
||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
||||
# URL encode the source name to handle special characters
|
||||
encoded_name = quote(kwargs.get('name'), safe='')
|
||||
self._ds_url = f"{self._ds_url}&source={encoded_name}"
|
||||
|
||||
# 注册消息响应
|
||||
@slack_app.event("message")
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional, List, Dict, Callable
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, quote
|
||||
|
||||
from telebot import TeleBot, apihelper
|
||||
from telebot.types import BotCommand, InlineKeyboardMarkup, InlineKeyboardButton, InputMediaPhoto
|
||||
@@ -65,7 +65,9 @@ class Telegram:
|
||||
|
||||
# 标记渠道来源
|
||||
if kwargs.get("name"):
|
||||
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
|
||||
# URL encode the source name to handle special characters
|
||||
encoded_name = quote(kwargs.get('name'), safe='')
|
||||
self._ds_url = f"{self._ds_url}&source={encoded_name}"
|
||||
|
||||
@_bot.message_handler(commands=['start', 'help'])
|
||||
def send_welcome(message):
|
||||
|
||||
@@ -798,7 +798,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
if not tmdb_info:
|
||||
return []
|
||||
return [schemas.TmdbSeason(**sea)
|
||||
for sea in tmdb_info.get("seasons", []) if sea.get("season_number")]
|
||||
for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None]
|
||||
|
||||
def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
@@ -1168,7 +1168,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
if not tmdb_info:
|
||||
return []
|
||||
return [schemas.TmdbSeason(**sea)
|
||||
for sea in tmdb_info.get("seasons", []) if sea.get("season_number")]
|
||||
for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None]
|
||||
|
||||
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
|
||||
@@ -167,7 +167,7 @@ class TmdbApi:
|
||||
"""
|
||||
记录匹配调试日志
|
||||
"""
|
||||
if season_number and season_year:
|
||||
if season_number is not None and season_year:
|
||||
logger.debug(f"正在识别{mtype.value}:{name}, 季集={season_number}, 季集年份={season_year} ...")
|
||||
else:
|
||||
logger.debug(f"正在识别{mtype.value}:{name}, 年份={year} ...")
|
||||
@@ -473,7 +473,7 @@ class TmdbApi:
|
||||
info = self._set_media_type(info, MediaType.MOVIE)
|
||||
else:
|
||||
# 有当前季和当前季集年份,使用精确匹配
|
||||
if season_year and season_number:
|
||||
if season_year and season_number is not None:
|
||||
self._log_match_debug(mtype, name, season_year, season_number, season_year)
|
||||
info = self.__search_tv_by_season(name,
|
||||
season_year,
|
||||
@@ -697,7 +697,7 @@ class TmdbApi:
|
||||
return {}
|
||||
ret_seasons = {}
|
||||
for season_info in tv_info.get("seasons") or []:
|
||||
if not season_info.get("season_number"):
|
||||
if season_info.get("season_number") is None:
|
||||
continue
|
||||
ret_seasons[season_info.get("season_number")] = season_info
|
||||
return ret_seasons
|
||||
@@ -2028,7 +2028,7 @@ class TmdbApi:
|
||||
info = self._set_media_type(info, MediaType.MOVIE)
|
||||
else:
|
||||
# 有当前季和当前季集年份,使用精确匹配
|
||||
if season_year and season_number:
|
||||
if season_year and season_number is not None:
|
||||
self._log_match_debug(mtype, name, season_year, season_number, season_year)
|
||||
info = await self.__async_search_tv_by_season(name,
|
||||
season_year,
|
||||
|
||||
@@ -98,8 +98,14 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
||||
每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
||||
"""
|
||||
|
||||
def __init__(self, base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0,
|
||||
source: str = "", enable_logging: bool = True):
|
||||
def __init__(
|
||||
self,
|
||||
base_wait: float = 60.0,
|
||||
max_wait: float = 600.0,
|
||||
backoff_factor: float = 2.0,
|
||||
source: str = "",
|
||||
enable_logging: bool = True,
|
||||
):
|
||||
"""
|
||||
初始化 ExponentialBackoffRateLimiter 实例
|
||||
:param base_wait: 基础等待时间(秒),默认值为 60 秒(1 分钟)
|
||||
@@ -156,7 +162,9 @@ class ExponentialBackoffRateLimiter(BaseRateLimiter):
|
||||
current_time = time.time()
|
||||
with self.lock:
|
||||
self.next_allowed_time = current_time + self.current_wait
|
||||
self.current_wait = min(self.current_wait * self.backoff_factor, self.max_wait)
|
||||
self.current_wait = min(
|
||||
self.current_wait * self.backoff_factor, self.max_wait
|
||||
)
|
||||
wait_time = self.next_allowed_time - current_time
|
||||
self.log_warning(f"触发限流,将在 {wait_time:.2f} 秒后允许继续调用")
|
||||
|
||||
@@ -168,8 +176,13 @@ class WindowRateLimiter(BaseRateLimiter):
|
||||
如果超过允许的最大调用次数,则限流直到窗口期结束
|
||||
"""
|
||||
|
||||
def __init__(self, max_calls: int, window_seconds: float,
|
||||
source: str = "", enable_logging: bool = True):
|
||||
def __init__(
|
||||
self,
|
||||
max_calls: int,
|
||||
window_seconds: float,
|
||||
source: str = "",
|
||||
enable_logging: bool = True,
|
||||
):
|
||||
"""
|
||||
初始化 WindowRateLimiter 实例
|
||||
:param max_calls: 在时间窗口内允许的最大调用次数
|
||||
@@ -190,7 +203,10 @@ class WindowRateLimiter(BaseRateLimiter):
|
||||
current_time = time.time()
|
||||
with self.lock:
|
||||
# 清理超出时间窗口的调用记录
|
||||
while self.call_times and current_time - self.call_times[0] > self.window_seconds:
|
||||
while (
|
||||
self.call_times
|
||||
and current_time - self.call_times[0] > self.window_seconds
|
||||
):
|
||||
self.call_times.popleft()
|
||||
|
||||
if len(self.call_times) < self.max_calls:
|
||||
@@ -225,8 +241,12 @@ class CompositeRateLimiter(BaseRateLimiter):
|
||||
当任意一个限流策略触发限流时,都会阻止调用
|
||||
"""
|
||||
|
||||
def __init__(self, limiters: List[BaseRateLimiter], source: str = "", enable_logging: bool = True):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limiters: List[BaseRateLimiter],
|
||||
source: str = "",
|
||||
enable_logging: bool = True,
|
||||
):
|
||||
"""
|
||||
初始化 CompositeRateLimiter 实例
|
||||
:param limiters: 要组合的限流器列表
|
||||
@@ -263,7 +283,9 @@ class CompositeRateLimiter(BaseRateLimiter):
|
||||
|
||||
|
||||
# 通用装饰器:自定义限流器实例
|
||||
def rate_limit_handler(limiter: BaseRateLimiter, raise_on_limit: bool = False) -> Callable:
|
||||
def rate_limit_handler(
|
||||
limiter: BaseRateLimiter, raise_on_limit: bool = False
|
||||
) -> Callable:
|
||||
"""
|
||||
通用装饰器,允许用户传递自定义的限流器实例,用于处理限流逻辑
|
||||
该装饰器可灵活支持任意继承自 BaseRateLimiter 的限流器
|
||||
@@ -344,8 +366,14 @@ def rate_limit_handler(limiter: BaseRateLimiter, raise_on_limit: bool = False) -
|
||||
|
||||
|
||||
# 装饰器:指数退避限流
|
||||
def rate_limit_exponential(base_wait: float = 60.0, max_wait: float = 600.0, backoff_factor: float = 2.0,
|
||||
raise_on_limit: bool = False, source: str = "", enable_logging: bool = True) -> Callable:
|
||||
def rate_limit_exponential(
|
||||
base_wait: float = 60.0,
|
||||
max_wait: float = 600.0,
|
||||
backoff_factor: float = 2.0,
|
||||
raise_on_limit: bool = False,
|
||||
source: str = "",
|
||||
enable_logging: bool = True,
|
||||
) -> Callable:
|
||||
"""
|
||||
装饰器,用于应用指数退避限流策略
|
||||
通过逐渐增加调用等待时间控制调用频率。每次触发限流时,等待时间会成倍增加,直到达到最大等待时间
|
||||
@@ -359,14 +387,21 @@ def rate_limit_exponential(base_wait: float = 60.0, max_wait: float = 600.0, bac
|
||||
:return: 装饰器函数
|
||||
"""
|
||||
# 实例化 ExponentialBackoffRateLimiter,并传入相关参数
|
||||
limiter = ExponentialBackoffRateLimiter(base_wait, max_wait, backoff_factor, source, enable_logging)
|
||||
limiter = ExponentialBackoffRateLimiter(
|
||||
base_wait, max_wait, backoff_factor, source, enable_logging
|
||||
)
|
||||
# 使用通用装饰器逻辑包装该限流器
|
||||
return rate_limit_handler(limiter, raise_on_limit)
|
||||
|
||||
|
||||
# 装饰器:时间窗口限流
|
||||
def rate_limit_window(max_calls: int, window_seconds: float,
|
||||
raise_on_limit: bool = False, source: str = "", enable_logging: bool = True) -> Callable:
|
||||
def rate_limit_window(
|
||||
max_calls: int,
|
||||
window_seconds: float,
|
||||
raise_on_limit: bool = False,
|
||||
source: str = "",
|
||||
enable_logging: bool = True,
|
||||
) -> Callable:
|
||||
"""
|
||||
装饰器,用于应用时间窗口限流策略
|
||||
在固定的时间窗口内限制调用次数,当调用次数超过最大值时,触发限流,直到时间窗口结束
|
||||
@@ -407,3 +442,63 @@ class QpsRateLimiter:
|
||||
self.next_call_time = max(now, self.next_call_time) + self.interval
|
||||
if sleep_duration > 0:
|
||||
time.sleep(sleep_duration)
|
||||
|
||||
|
||||
class RateStats:
|
||||
"""
|
||||
请求速率统计:记录时间戳,计算 QPS / QPM / QPH
|
||||
"""
|
||||
|
||||
def __init__(self, window_seconds: float = 7200, source: str = ""):
|
||||
"""
|
||||
:param window_seconds: 统计窗口(秒),默认 2 小时,用于计算 QPH
|
||||
:param source: 日志来源标识
|
||||
"""
|
||||
self._window = window_seconds
|
||||
self._source = source
|
||||
self._lock = threading.Lock()
|
||||
self._timestamps: deque = deque()
|
||||
|
||||
def record(self) -> None:
|
||||
"""
|
||||
记录一次请求
|
||||
"""
|
||||
t = time.time()
|
||||
with self._lock:
|
||||
self._timestamps.append(t)
|
||||
while self._timestamps and t - self._timestamps[0] > self._window:
|
||||
self._timestamps.popleft()
|
||||
|
||||
def _count_since(self, seconds: float) -> int:
|
||||
t = time.time()
|
||||
with self._lock:
|
||||
return sum(1 for ts in self._timestamps if t - ts <= seconds)
|
||||
|
||||
def get_qps(self) -> float:
|
||||
"""
|
||||
最近 1 秒内请求数
|
||||
"""
|
||||
return self._count_since(1.0)
|
||||
|
||||
def get_qpm(self) -> float:
|
||||
"""
|
||||
最近 1 分钟内请求数
|
||||
"""
|
||||
return self._count_since(60.0)
|
||||
|
||||
def get_qph(self) -> float:
|
||||
"""
|
||||
最近 1 小时内请求数
|
||||
"""
|
||||
return self._count_since(3600.0)
|
||||
|
||||
def log_stats(self, level: str = "info") -> None:
|
||||
"""
|
||||
输出当前 QPS/QPM/QPH
|
||||
"""
|
||||
qps, qpm, qph = self.get_qps(), self.get_qpm(), self.get_qph()
|
||||
msg = f"QPS={qps} QPM={qpm} QPH={qph}"
|
||||
if self._source:
|
||||
msg = f"[{self._source}] {msg}"
|
||||
log_fn = getattr(logger, level, logger.info)
|
||||
log_fn(msg)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
APP_VERSION = 'v2.9.8'
|
||||
FRONTEND_VERSION = 'v2.9.8'
|
||||
APP_VERSION = 'v2.9.9'
|
||||
FRONTEND_VERSION = 'v2.9.9'
|
||||
|
||||
Reference in New Issue
Block a user