diff --git a/app/agent/tools/impl/search_media.py b/app/agent/tools/impl/search_media.py index 63ee3243..b1abf57a 100644 --- a/app/agent/tools/impl/search_media.py +++ b/app/agent/tools/impl/search_media.py @@ -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) diff --git a/app/agent/tools/impl/search_torrents.py b/app/agent/tools/impl/search_torrents.py index bedfdb2a..15a8957e 100644 --- a/app/agent/tools/impl/search_torrents.py +++ b/app/agent/tools/impl/search_torrents.py @@ -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: diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index eddef82b..69fbc1bb 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -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, diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 7d31491c..4a331f9a 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -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) diff --git a/app/chain/media.py b/app/chain/media.py index 220e7161..8eae13c5 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -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: diff --git a/app/chain/transfer.py b/app/chain/transfer.py index a9930d7c..0a42bfc1 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -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 diff --git a/app/helper/cookie.py b/app/helper/cookie.py index 6afe8f6e..396d6ccf 100644 --- a/app/helper/cookie.py +++ b/app/helper/cookie.py @@ -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"]', ] } diff --git a/app/helper/rss.py b/app/helper/rss.py index 5cd309d8..5257ff0c 100644 --- a/app/helper/rss.py +++ b/app/helper/rss.py @@ -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) diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index 82fc8664..15ed851a 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -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: """ diff --git a/app/modules/discord/discord.py b/app/modules/discord/discord.py index 6108d2da..f1997c5c 100644 --- a/app/modules/discord/discord.py +++ b/app/modules/discord/discord.py @@ -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 diff --git a/app/modules/douban/scraper.py b/app/modules/douban/scraper.py index 9b2ad984..5fc4afab 100644 --- a/app/modules/douban/scraper.py +++ b/app/modules/douban/scraper.py @@ -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: diff --git a/app/modules/filemanager/storages/u115.py b/app/modules/filemanager/storages/u115.py index 47a8799e..cff7b2a8 100644 --- a/app/modules/filemanager/storages/u115.py +++ b/app/modules/filemanager/storages/u115.py @@ -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") diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index db67bf71..8bd122eb 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -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) diff --git a/app/modules/indexer/spider/__init__.py b/app/modules/indexer/spider/__init__.py index 1ced2441..ef2bba9d 100644 --- a/app/modules/indexer/spider/__init__.py +++ b/app/modules/indexer/spider/__init__.py @@ -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 diff --git a/app/modules/slack/slack.py b/app/modules/slack/slack.py index 3931ecee..16f890c0 100644 --- a/app/modules/slack/slack.py +++ b/app/modules/slack/slack.py @@ -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") diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index c7588e95..86817b8a 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -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): diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 4cfd494d..4ee52c2e 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -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]: """ diff --git a/app/modules/themoviedb/tmdbapi.py b/app/modules/themoviedb/tmdbapi.py index 417a5931..2a02d777 100644 --- a/app/modules/themoviedb/tmdbapi.py +++ b/app/modules/themoviedb/tmdbapi.py @@ -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, diff --git a/app/utils/limit.py b/app/utils/limit.py index e9a90acd..a3e48d74 100644 --- a/app/utils/limit.py +++ b/app/utils/limit.py @@ -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) diff --git a/version.py b/version.py index ca47d8cc..73b0d8ac 100644 --- a/version.py +++ b/version.py @@ -1,2 +1,2 @@ -APP_VERSION = 'v2.9.8' -FRONTEND_VERSION = 'v2.9.8' +APP_VERSION = 'v2.9.9' +FRONTEND_VERSION = 'v2.9.9'