From 1f87bc643ae833b81b188a50013b353200704e89 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 19 Aug 2024 13:06:39 +0800 Subject: [PATCH] sync main --- app/api/endpoints/plugin.py | 25 ++++--- app/api/endpoints/subscribe.py | 11 +++ app/chain/__init__.py | 15 ++-- app/chain/download.py | 2 + app/chain/subscribe.py | 8 +-- app/command.py | 5 +- app/core/config.py | 37 ++++++++++ app/core/context.py | 4 +- app/core/meta/releasegroup.py | 6 +- app/core/plugin.py | 102 ++++++++++++++++++++++------ app/db/models/transferhistory.py | 2 + app/helper/doh.py | 23 +------ app/helper/plugin.py | 14 ++-- app/main.py | 10 ++- app/modules/douban/__init__.py | 31 ++++++--- app/modules/filemanager/__init__.py | 7 +- app/modules/plex/plex.py | 54 ++++++++++++--- app/modules/telegram/telegram.py | 15 ++-- app/modules/themoviedb/__init__.py | 4 +- app/modules/wechat/wechat.py | 62 +++++++++++++---- app/schemas/__init__.py | 1 + app/schemas/exception.py | 14 ++++ app/schemas/plugin.py | 2 + app/utils/common.py | 4 ++ app/utils/crypto.py | 89 ++++++++++++++++++++++++ app/utils/http.py | 36 +++++++++- config/app.env | 4 ++ 27 files changed, 476 insertions(+), 111 deletions(-) create mode 100644 app/schemas/exception.py create mode 100644 app/utils/crypto.py diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 4a080234..52077d14 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -110,13 +110,19 @@ def install(plugin_id: str, """ # 已安装插件 install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or [] - # 如果是非本地括件,或者强制安装时,则需要下载安装 - if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()): - # 下载安装 - state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url) - if not state: - # 安装失败 - return schemas.Response(success=False, message=msg) + # 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计 + if not force and plugin_id in PluginManager().get_plugin_ids(): + PluginHelper().install_reg(pid=plugin_id) + else: + # 插件不存在或需要强制安装,下载安装并注册插件 + if repo_url: + state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url) + # 安装失败则直接响应 + if not state: + return schemas.Response(success=False, message=msg) + else: + # repo_url 为空时,也直接响应 + return schemas.Response(success=False, message="没有传入仓库地址,无法正确安装插件,请检查配置") # 安装插件 if plugin_id not in install_plugins: install_plugins.append(plugin_id) @@ -189,10 +195,7 @@ def reset_plugin(plugin_id: str, # 删除插件所有数据 PluginManager().delete_plugin_data(plugin_id) # 重新生效插件 - PluginManager().init_plugin(plugin_id, { - "enabled": False, - "enable": False - }) + PluginManager().reload_plugin(plugin_id) # 注册插件服务 Scheduler().update_plugin_job(plugin_id) # 注册插件API diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 574304ce..ebfe8079 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -387,6 +387,17 @@ def popular_subscribes( return [] +@router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe]) +def user_subscribes( + username: str, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询用户订阅 + """ + return Subscribe.list_by_username(db, username) + + @router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe) def read_subscribe( subscribe_id: int, diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 1774e757..9e1d477d 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -83,6 +83,7 @@ class ChainBase(metaclass=ABCMeta): def run_module(self, method: str, *args, **kwargs) -> Any: """ 运行包含该方法的所有模块,然后返回结果 + 当kwargs包含命名参数raise_exception时,如模块方法抛出异常且raise_exception为True,则同步抛出异常 """ def is_result_empty(ret): @@ -121,6 +122,8 @@ class ChainBase(metaclass=ABCMeta): # 中止继续执行 break except Exception as err: + if kwargs.get("raise_exception"): + raise logger.error( f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}") self.messagehelper.put(title=f"{module_name}发生了错误", @@ -170,7 +173,8 @@ class ChainBase(metaclass=ABCMeta): tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache) def match_doubaninfo(self, name: str, imdbid: str = None, - mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]: + mtype: MediaType = None, year: str = None, season: int = None, + raise_exception: bool = False) -> Optional[dict]: """ 搜索和匹配豆瓣信息 :param name: 标题 @@ -178,9 +182,10 @@ class ChainBase(metaclass=ABCMeta): :param mtype: 类型 :param year: 年份 :param season: 季 + :param raise_exception: 触发速率限制时是否抛出异常 """ return self.run_module("match_doubaninfo", name=name, imdbid=imdbid, - mtype=mtype, year=year, season=season) + mtype=mtype, year=year, season=season, raise_exception=raise_exception) def match_tmdbinfo(self, name: str, mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]: @@ -218,14 +223,16 @@ class ChainBase(metaclass=ABCMeta): image_prefix=image_prefix, image_type=image_type, season=season, episode=episode) - def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]: + def douban_info(self, doubanid: str, mtype: MediaType = None, + raise_exception: bool = False) -> Optional[dict]: """ 获取豆瓣信息 :param doubanid: 豆瓣ID :param mtype: 媒体类型 :return: 豆瓣信息 + :param raise_exception: 触发速率限制时是否抛出异常 """ - return self.run_module("douban_info", doubanid=doubanid, mtype=mtype) + return self.run_module("douban_info", doubanid=doubanid, mtype=mtype, raise_exception=raise_exception) def tvdb_info(self, tvdbid: int) -> Optional[dict]: """ diff --git a/app/chain/download.py b/app/chain/download.py index dd3de97d..6221b6fa 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -76,6 +76,8 @@ class DownloadChain(ChainBase): msg_text = f"{msg_text}\n促销:{torrent.volume_factor}" if torrent.hit_and_run: msg_text = f"{msg_text}\nHit&Run:是" + if torrent.labels: + msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}" if torrent.description: html_re = re.compile(r'<[^>]+>', re.S) description = html_re.sub('', torrent.description) diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 6e831fca..bf2f67bd 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -180,9 +180,9 @@ class SubscribeChain(ChainBase): else: text = f"评分:{mediainfo.vote_average}" if mediainfo.type == MediaType.TV: - link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub') + link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') else: - link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub') + link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 self.post_message(Notification(mtype=NotificationType.Subscribe, title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅", @@ -899,9 +899,9 @@ class SubscribeChain(ChainBase): self.subscribeoper.delete(subscribe.id) # 发送通知 if mediainfo.type == MediaType.TV: - link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub') + link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') else: - link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub') + link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 完成订阅按规则发送消息 self.post_message(Notification(mtype=NotificationType.Subscribe, title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}', diff --git a/app/command.py b/app/command.py index 2dc704ff..c3a606b3 100644 --- a/app/command.py +++ b/app/command.py @@ -1,3 +1,4 @@ +import copy import importlib import threading import traceback @@ -194,7 +195,7 @@ class Command(metaclass=Singleton): # 插件事件 self.threader.submit( self.pluginmanager.run_plugin_method, - class_name, method_name, event + class_name, method_name, copy.deepcopy(event) ) else: @@ -217,7 +218,7 @@ class Command(metaclass=Singleton): if hasattr(class_obj, method_name): self.threader.submit( getattr(class_obj, method_name), - event + copy.deepcopy(event) ) except Exception as e: logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}") diff --git a/app/core/config.py b/app/core/config.py index aa2233e3..9517e589 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -81,6 +81,10 @@ class Settings(BaseSettings): AUTO_UPDATE_RESOURCE: bool = True # 是否启用DOH解析域名 DOH_ENABLE: bool = True + # 使用 DOH 解析的域名列表 + DOH_DOMAINS: str = "api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org" + # DOH 解析服务器列表 + DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112" # 支持的后缀格式 RMT_MEDIAEXT: list = ['.mp4', '.mkv', '.ts', '.iso', '.rmvb', '.avi', '.mov', '.mpeg', @@ -150,6 +154,8 @@ class Settings(BaseSettings): GITHUB_PROXY: Optional[str] = '' # pip镜像站点,格式:https://pypi.tuna.tsinghua.edu.cn/simple PIP_PROXY: Optional[str] = '' + # 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** + REPO_GITHUB_TOKEN: Optional[str] = None # 大内存模式 BIG_MEMORY_MODE: bool = False @@ -308,6 +314,37 @@ class Settings(BaseSettings): PIP_OPTIONS = "" return PIP_OPTIONS + def REPO_GITHUB_HEADERS(self, repo: str = None): + """ + Github指定的仓库请求头 + :param repo: 指定的仓库名称,格式为 "user/repo"。如果为空,或者没有找到指定仓库请求头,则返回默认的请求头信息 + :return: Github请求头 + """ + # 如果没有传入指定的仓库名称,或没有配置指定的仓库Token,则返回默认的请求头信息 + if not repo or not self.REPO_GITHUB_TOKEN: + return self.GITHUB_HEADERS + headers = {} + # 格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_**** + token_pairs = self.REPO_GITHUB_TOKEN.split(",") + for token_pair in token_pairs: + try: + parts = token_pair.split(":") + if len(parts) != 2: + print(f"无效的令牌格式: {token_pair}") + continue + repo_info = parts[0].strip() + token = parts[1].strip() + if not repo_info or not token: + print(f"无效的令牌或仓库信息: {token_pair}") + continue + headers[repo_info] = { + "Authorization": f"Bearer {token}" + } + except Exception as e: + print(f"处理令牌对 '{token_pair}' 时出错: {e}") + # 如果传入了指定的仓库名称,则返回该仓库的请求头信息,否则返回默认请求头 + return headers.get(repo, self.GITHUB_HEADERS) + @property def VAPID(self): return { diff --git a/app/core/context.py b/app/core/context.py index 32e1c6da..35799c2f 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -347,10 +347,10 @@ class MediaInfo: return [], [] directors = [] actors = [] - for cast in _credits.get("cast"): + for cast in _credits.get("cast") or []: if cast.get("known_for_department") == "Acting": actors.append(cast) - for crew in _credits.get("crew"): + for crew in _credits.get("crew") or []: if crew.get("job") in ["Director", "Writer", "Editor", "Producer"]: directors.append(crew) return directors, actors diff --git a/app/core/meta/releasegroup.py b/app/core/meta/releasegroup.py index 1f629fc3..77eab1e1 100644 --- a/app/core/meta/releasegroup.py +++ b/app/core/meta/releasegroup.py @@ -71,7 +71,11 @@ class ReleaseGroupsMatcher(metaclass=Singleton): "ultrahd": [], "others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:|yG)', 'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'], - "anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', '(?:Lilith|NC)-Raws', '织梦字幕组'] + "anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY', + '(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社', + '霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社', + '悠哈璃羽字幕社', + '❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组'] } def __init__(self): diff --git a/app/core/plugin.py b/app/core/plugin.py index e3038923..243c4aaf 100644 --- a/app/core/plugin.py +++ b/app/core/plugin.py @@ -1,11 +1,13 @@ import concurrent import concurrent.futures +import importlib.util import inspect +import os import threading import time import traceback from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union, Type from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -19,6 +21,7 @@ from app.helper.module import ModuleHelper from app.helper.plugin import PluginHelper from app.helper.sites import SitesHelper from app.log import logger +from app.utils.crypto import RSAUtils from app.schemas.types import SystemConfigKey from app.utils.object import ObjectUtils from app.utils.singleton import Singleton @@ -158,11 +161,12 @@ class PluginManager(metaclass=Singleton): if pid and plugin_id != pid: continue try: - # 如果插件具有认证级别且当前认证级别不足,则不进行实例化 - if hasattr(plugin, "auth_level"): - plugin.auth_level = plugin.auth_level - if self.siteshelper.auth_level < plugin.auth_level: - continue + # 判断插件是否满足认证要求,如不满足则不进行实例化 + if not self.__set_and_check_auth_level(plugin=plugin): + # 如果是插件热更新实例,这里则进行替换 + if plugin_id in self._plugins: + self._plugins[plugin_id] = plugin + continue # 存储Class self._plugins[plugin_id] = plugin # 未安装的不加载 @@ -220,8 +224,6 @@ class PluginManager(metaclass=Singleton): # 清空指定插件 if pid in self._running_plugins: self._running_plugins.pop(pid) - if pid in self._plugins: - self._plugins.pop(pid) else: # 清空 self._plugins = {} @@ -602,11 +604,12 @@ class PluginManager(metaclass=Singleton): if plugin_obj and hasattr(plugin_obj, "get_page"): if ObjectUtils.check_method(plugin_obj.get_page): plugin.has_page = True + # 公钥 + if plugin_info.get("key"): + plugin.plugin_public_key = plugin_info.get("key") # 权限 - if plugin_info.get("level"): - plugin.auth_level = plugin_info.get("level") - if self.siteshelper.auth_level < plugin.auth_level: - continue + if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_info): + continue # 名称 if plugin_info.get("name"): plugin.plugin_name = plugin_info.get("name") @@ -709,11 +712,12 @@ class PluginManager(metaclass=Singleton): plugin.has_page = True else: plugin.has_page = False + # 公钥 + if hasattr(plugin_class, "plugin_public_key"): + plugin.plugin_public_key = plugin_class.plugin_public_key # 权限 - if hasattr(plugin_class, "auth_level"): - plugin.auth_level = plugin_class.auth_level - if self.siteshelper.auth_level < plugin.auth_level: - continue + if not self.__set_and_check_auth_level(plugin=plugin, source=plugin_class): + continue # 名称 if hasattr(plugin_class, "plugin_name"): plugin.plugin_name = plugin_class.plugin_name @@ -748,10 +752,70 @@ class PluginManager(metaclass=Singleton): @staticmethod def is_plugin_exists(pid: str) -> bool: """ - 判断插件是否在本地文件系统存在 + 判断插件是否在本地包中存在 :param pid: 插件ID """ if not pid: return False - plugin_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower() - return plugin_dir.exists() + try: + # 构建包名 + package_name = f"app.plugins.{pid.lower()}" + # 检查包是否存在 + package_exists = importlib.util.find_spec(package_name) is not None + logger.debug(f"{pid} exists: {package_exists}") + return package_exists + except Exception as e: + logger.debug(f"获取插件是否在本地包中存在失败,{e}") + return False + + def __set_and_check_auth_level(self, plugin: Union[schemas.Plugin, Type[Any]], + source: Optional[Union[dict, Type[Any]]] = None) -> bool: + """ + 设置并检查插件的认证级别 + :param plugin: 插件对象或包含 auth_level 属性的对象 + :param source: 可选的字典对象或类对象,可能包含 "level" 或 "auth_level" 键 + :return: 如果插件的认证级别有效且当前环境的认证级别满足要求,返回 True,否则返回 False + """ + # 检查并赋值 source 中的 level 或 auth_level + if source: + if isinstance(source, dict) and "level" in source: + plugin.auth_level = source.get("level") + elif hasattr(source, "auth_level"): + plugin.auth_level = source.auth_level + # 如果 source 为空且 plugin 本身没有 auth_level,直接返回 True + elif not hasattr(plugin, "auth_level"): + return True + + # auth_level 级别说明 + # 1 - 所有用户可见 + # 2 - 站点认证用户可见 + # 3 - 站点&密钥认证可见 + # 99 - 站点&特殊密钥认证可见 + # 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证 + if self.siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"): + plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__ + public_key = plugin.plugin_public_key + if public_key: + private_key = PluginManager.__get_plugin_private_key(plugin_id) + verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key) + return verify + # 如果当前站点认证级别小于插件级别,则返回 False + if self.siteshelper.auth_level < plugin.auth_level: + return False + return True + + @staticmethod + def __get_plugin_private_key(plugin_id: str) -> Optional[str]: + """ + 根据插件标识获取对应的私钥 + :param plugin_id: 插件标识 + :return: 对应的插件私钥,如果未找到则返回 None + """ + try: + # 将插件标识转换为大写并构建环境变量名称 + env_var_name = f"PLUGIN_{plugin_id.upper()}_PRIVATE_KEY" + private_key = os.environ.get(env_var_name) + return private_key + except Exception as e: + logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}") + return None diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 54d07615..62e76f6a 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -65,6 +65,7 @@ class TransferHistory(Base): ).offset((page - 1) * count).limit(count).all() else: result = db.query(TransferHistory).filter(or_( + TransferHistory.title.like(f'%{title}%'), TransferHistory.src.like(f'%{title}%'), TransferHistory.dest.like(f'%{title}%'), )).order_by( @@ -136,6 +137,7 @@ class TransferHistory(Base): return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0] else: return db.query(func.count(TransferHistory.id)).filter(or_( + TransferHistory.title.like(f'%{title}%'), TransferHistory.src.like(f'%{title}%'), TransferHistory.dest.like(f'%{title}%') )).first()[0] diff --git a/app/helper/doh.py b/app/helper/doh.py index 88dc4ff1..6b36aed9 100644 --- a/app/helper/doh.py +++ b/app/helper/doh.py @@ -15,38 +15,19 @@ from typing import Dict, Optional from app.core.config import settings from app.log import logger -# 定义一个全局集合来存储注册的主机 -_registered_hosts = { - 'api.themoviedb.org', - 'api.tmdb.org', - 'webservice.fanart.tv', - 'api.github.com', - 'github.com', - 'raw.githubusercontent.com', - 'api.telegram.org' -} - # 定义一个全局线程池执行器 _executor = concurrent.futures.ThreadPoolExecutor() # 定义默认的DoH配置 _doh_timeout = 5 _doh_cache: Dict[str, str] = {} -_doh_resolvers = [ - # https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https - "1.0.0.1", - "1.1.1.1", - # https://support.quad9.net/hc/en-us - "9.9.9.9", - "149.112.112.112" -] def _patched_getaddrinfo(host, *args, **kwargs): """ socket.getaddrinfo的补丁版本。 """ - if host not in _registered_hosts: + if host not in settings.DOH_DOMAINS.split(","): return _orig_getaddrinfo(host, *args, **kwargs) # 检查主机是否已解析 @@ -57,7 +38,7 @@ def _patched_getaddrinfo(host, *args, **kwargs): # 使用DoH解析主机 futures = [] - for resolver in _doh_resolvers: + for resolver in settings.DOH_RESOLVERS.split(","): futures.append(_executor.submit(_doh_query, resolver, host)) for future in concurrent.futures.as_completed(futures): diff --git a/app/helper/plugin.py b/app/helper/plugin.py index 523ac1fe..6520ba43 100644 --- a/app/helper/plugin.py +++ b/app/helper/plugin.py @@ -51,7 +51,8 @@ class PluginHelper(metaclass=Singleton): if not user or not repo: return {} raw_url = self._base_url % (user, repo) - res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, + res = RequestUtils(proxies=self.proxies, + headers=settings.REPO_GITHUB_HEADERS(repo=f"{user}/{repo}"), timeout=10).get_res(f"{raw_url}package.json") if res: try: @@ -137,12 +138,16 @@ class PluginHelper(metaclass=Singleton): if not user or not repo: return False, "不支持的插件仓库地址格式" + user_repo = f"{user}/{repo}" + def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]: """ 获取插件的文件列表 """ - file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p}" - r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api) + file_api = f"https://api.github.com/repos/{user_repo}/contents/plugins/{_p}" + r = RequestUtils(proxies=settings.PROXY, + headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + timeout=30).get_res(file_api) if r is None: return None, "连接仓库失败" elif r.status_code != 200: @@ -164,7 +169,8 @@ class PluginHelper(metaclass=Singleton): download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}" # 下载插件文件 res = RequestUtils(proxies=self.proxies, - headers=settings.GITHUB_HEADERS, timeout=60).get_res(download_url) + headers=settings.REPO_GITHUB_HEADERS(repo=user_repo), + timeout=60).get_res(download_url) if not res: return False, f"文件 {item.get('name')} 下载失败!" elif res.status_code != 200: diff --git a/app/main.py b/app/main.py index 94516327..bb1bc4c8 100644 --- a/app/main.py +++ b/app/main.py @@ -20,12 +20,20 @@ if SystemUtils.is_frozen(): from app.core.config import settings, global_vars from app.core.module import ModuleManager + +# SitesHelper涉及资源包拉取,提前引入并容错提示 +try: + from app.helper.sites import SitesHelper +except ImportError as e: + error_message = f"错误: {str(e)}\n站点认证及索引相关资源导入失败,请尝试重建容器或手动拉取资源" + print(error_message, file=sys.stderr) + sys.exit(1) + from app.core.plugin import PluginManager from app.db.init import init_db, update_db from app.helper.thread import ThreadHelper from app.helper.display import DisplayHelper from app.helper.resource import ResourceHelper -from app.helper.sites import SitesHelper from app.helper.message import MessageHelper from app.scheduler import Scheduler from app.monitor import Monitor diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 06ffc44d..733b1610 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -13,7 +13,7 @@ from app.modules import _ModuleBase from app.modules.douban.apiv2 import DoubanApi from app.modules.douban.douban_cache import DoubanCache from app.modules.douban.scraper import DoubanScraper -from app.schemas import MediaPerson +from app.schemas import MediaPerson, APIRateLimitException from app.schemas.types import MediaType from app.utils.common import retry from app.utils.http import RequestUtils @@ -145,11 +145,12 @@ class DoubanModule(_ModuleBase): return None - def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]: + def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]: """ 获取豆瓣信息 :param doubanid: 豆瓣ID :param mtype: 媒体类型 + :param raise_exception: 触发速率限制时是否抛出异常 :return: 豆瓣信息 """ """ @@ -425,7 +426,10 @@ class DoubanModule(_ModuleBase): info = self.doubanapi.tv_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): - logger.warn(f"触发豆瓣IP速率限制,错误信息:{info} ...") + msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." + logger.warn(msg) + if raise_exception: + raise APIRateLimitException(msg) return None celebrities = self.doubanapi.tv_celebrities(doubanid) if celebrities: @@ -440,7 +444,10 @@ class DoubanModule(_ModuleBase): info = self.doubanapi.movie_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): - logger.warn(f"触发豆瓣IP速率限制,错误信息:{info} ...") + msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." + logger.warn(msg) + if raise_exception: + raise APIRateLimitException(msg) return None celebrities = self.doubanapi.movie_celebrities(doubanid) if celebrities: @@ -599,7 +606,8 @@ class DoubanModule(_ModuleBase): @retry(Exception, 5, 3, 3, logger=logger) def match_doubaninfo(self, name: str, imdbid: str = None, - mtype: MediaType = None, year: str = None, season: int = None) -> dict: + mtype: MediaType = None, year: str = None, season: int = None, + raise_exception: bool = False) -> dict: """ 搜索和匹配豆瓣信息 :param name: 名称 @@ -607,6 +615,7 @@ class DoubanModule(_ModuleBase): :param mtype: 类型 :param year: 年份 :param season: 季号 + :param raise_exception: 触发速率限制时是否抛出异常 """ if imdbid: # 优先使用IMDBID查询 @@ -622,13 +631,19 @@ class DoubanModule(_ModuleBase): # 搜索 logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") result = self.doubanapi.search(f"{name} {year or ''}".strip()) - if not result or not result.get("items"): + if not result: logger.warn(f"未找到 {name} 的豆瓣信息") return {} # 触发rate limit if "search_access_rate_limit" in result.values(): - logger.warn(f"触发豆瓣API速率限制 错误信息 {result} ...") - raise Exception("触发豆瓣API速率限制") + msg = f"触发豆瓣API速率限制,错误信息:{result} ..." + logger.warn(msg) + if raise_exception: + raise APIRateLimitException(msg) + return {} + if not result.get("items"): + logger.warn(f"未找到 {name} 的豆瓣信息") + return {} for item_obj in result.get("items"): type_name = item_obj.get("type_name") if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]: diff --git a/app/modules/filemanager/__init__.py b/app/modules/filemanager/__init__.py index ff4dca78..d1f14d08 100644 --- a/app/modules/filemanager/__init__.py +++ b/app/modules/filemanager/__init__.py @@ -487,12 +487,13 @@ class FileManagerModule(_ModuleBase): """ # 字幕正则式 _zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \ - r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&](cn|ch[si]|sg|zho?|eng)" \ + r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&]?(cn|ch[si]|sg|zho?|eng)" \ r"|简[体中]?)[.\])])" \ r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \ r"|简体|简中|JPSC" \ r"|(? Optional[str]: + def get_remote_image_by_id(self, item_id: str, image_type: str, depth: int = 0) -> Optional[str]: """ 根据ItemId从Plex查询图片地址 :param item_id: 在Emby中的ID :param image_type: 图片的类型,Poster或者Backdrop等 + :param depth: 当前递归深度,默认为0 :return: 图片对应在TMDB中的URL """ - if not self._plex: + if not self._plex or depth > 2 or not item_id: return None try: - if image_type == "Poster": - images = self._plex.fetchItems('/library/metadata/%s/posters' % item_id, - cls=media.Poster) + image_url = None + ekey = f"/library/metadata/{item_id}" + item = self._plex.fetchItem(ekey=ekey) + if not item: + return None + # 如果配置了外网播放地址以及Token,则默认从Plex媒体服务器获取图片,否则返回有外网地址的图片资源 + if self._playhost and self._token: + query = {"X-Plex-Token": settings.PLEX_TOKEN} + if image_type == "Poster": + if item.thumb: + image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.thumb, query=query) + else: + # 默认使用art也就是Backdrop进行处理 + if item.art: + image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.art, query=query) + # 这里对episode进行特殊处理,实际上episode的Backdrop是Poster + # 也有个别情况,比如机智的凡人小子episode就是Poster,因此这里把episode的优先级降低,默认还是取art + if not image_url and item.TYPE == "episode" and item.thumb: + image_url = RequestUtils.combine_url(host=settings.PLEX_PLAY_HOST, path=item.thumb, query=query) else: - images = self._plex.fetchItems('/library/metadata/%s/arts' % item_id, - cls=media.Art) - for image in images: - if hasattr(image, 'key') and image.key.startswith('http'): - return image.key + if image_type == "Poster": + images = self._plex.fetchItems(ekey=f"{ekey}/posters", + cls=media.Poster) + else: + # 默认使用art也就是Backdrop进行处理 + images = self._plex.fetchItems(ekey=f"{ekey}/arts", + cls=media.Art) + # 这里对episode进行特殊处理,实际上episode的Backdrop是Poster + # 也有个别情况,比如机智的凡人小子episode就是Poster,因此这里把episode的优先级降低,默认还是取art + if not images and item.TYPE == "episode": + images = self._plex.fetchItems(ekey=f"{ekey}/posters", + cls=media.Poster) + for image in images: + if hasattr(image, "key") and image.key.startswith("http"): + image_url = image.key + break + # 如果最后还是找不到,则递归父级进行查找 + if not image_url and hasattr(item, "parentRatingKey"): + return self.get_remote_image_by_id(item_id=item.parentRatingKey, + image_type=image_type, + depth=depth + 1) + return image_url except Exception as e: logger.error(f"获取封面出错:" + str(e)) return None diff --git a/app/modules/telegram/telegram.py b/app/modules/telegram/telegram.py index 7d55ca6f..1d499996 100644 --- a/app/modules/telegram/telegram.py +++ b/app/modules/telegram/telegram.py @@ -1,5 +1,6 @@ import re import threading +import uuid from pathlib import Path from threading import Event from typing import Optional, List, Dict @@ -93,6 +94,8 @@ class Telegram: try: if text: + # 对text进行Markdown特殊字符转义 + text = re.sub(r"([_`])", r"\\\1", text) caption = f"*{title}*\n{text}" else: caption = f"*{title}*" @@ -205,13 +208,15 @@ class Telegram: """ if image: - req = RequestUtils(proxies=settings.PROXY).get_res(image) - if req is None: + res = RequestUtils(proxies=settings.PROXY).get_res(image) + if res is None: raise Exception("获取图片失败") - if req.content: - image_file = Path(settings.TEMP_PATH) / Path(image).name - image_file.write_bytes(req.content) + if res.content: + # 使用随机标识构建图片文件的完整路径,并写入图片内容到文件 + image_file = Path(settings.TEMP_PATH) / str(uuid.uuid4()) + image_file.write_bytes(res.content) photo = InputFile(image_file) + # 发送图片到Telegram ret = self._bot.send_photo(chat_id=userid or self._telegram_chat_id, photo=photo, caption=caption, diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 4bb11554..79595629 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -365,9 +365,9 @@ class TheMovieDbModule(_ModuleBase): :param season: 季 """ season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season) - if not season_info: + if not season_info or not season_info.get("episodes"): return [] - return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes", [])] + return [schemas.TmdbEpisode(**episode) for episode in season_info.get("episodes")] def scheduler_job(self) -> None: """ diff --git a/app/modules/wechat/wechat.py b/app/modules/wechat/wechat.py index 1062e18f..49231507 100644 --- a/app/modules/wechat/wechat.py +++ b/app/modules/wechat/wechat.py @@ -112,28 +112,60 @@ class WeChat: """ message_url = self._send_msg_url % self.__get_access_token() if text: - conent = "%s\n%s" % (title, text.replace("\n\n", "\n")) + content = "%s\n%s" % (title, text.replace("\n\n", "\n")) else: - conent = title + content = title if link: - conent = f"{conent}\n点击查看:{link}" + content = f"{content}\n点击查看:{link}" if not userid: userid = "@all" - req_json = { - "touser": userid, - "msgtype": "text", - "agentid": self._appid, - "text": { - "content": conent - }, - "safe": 0, - "enable_id_trans": 0, - "enable_duplicate_check": 0 - } - return self.__post_request(message_url, req_json) + # Check if content exceeds 2048 bytes and split if necessary + if len(content.encode('utf-8')) > 2048: + content_chunks = [] + current_chunk = "" + for line in content.splitlines(): + if len(current_chunk.encode('utf-8')) + len(line.encode('utf-8')) > 2048: + content_chunks.append(current_chunk.strip()) + current_chunk = "" + current_chunk += line + "\n" + if current_chunk: + content_chunks.append(current_chunk.strip()) + + # Send each chunk as a separate message + result = True + for chunk in content_chunks: + req_json = { + "touser": userid, + "msgtype": "text", + "agentid": self._appid, + "text": { + "content": chunk + }, + "safe": 0, + "enable_id_trans": 0, + "enable_duplicate_check": 0 + } + result = self.__post_request(message_url, req_json) + if not result: + return False + else: + req_json = { + "touser": userid, + "msgtype": "text", + "agentid": self._appid, + "text": { + "content": content + }, + "safe": 0, + "enable_id_trans": 0, + "enable_duplicate_check": 0 + } + result = self.__post_request(message_url, req_json) + + return result def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None, link: str = None) -> Optional[bool]: diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index cab5d2c1..4751afbd 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -16,3 +16,4 @@ from .transfer import * from .rule import * from .system import * from .file import * +from .exception import * diff --git a/app/schemas/exception.py b/app/schemas/exception.py new file mode 100644 index 00000000..63ef784e --- /dev/null +++ b/app/schemas/exception.py @@ -0,0 +1,14 @@ +class ImmediateException(Exception): + """ + 用于立即抛出异常而不重试的特殊异常类。 + 当不希望使用重试机制时,可以抛出此异常。 + """ + pass + + +class APIRateLimitException(ImmediateException): + """ + 用于表示API速率限制的异常类。 + 当API调用触发速率限制时,可以抛出此异常以立即终止操作并报告错误。 + """ + pass diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index c38fd074..742ff1a7 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -46,6 +46,8 @@ class Plugin(BaseModel): history: Optional[dict] = {} # 添加时间,值越小表示越靠后发布 add_time: Optional[int] = 0 + # 插件公钥 + plugin_public_key: Optional[str] = None class PluginDashboard(Plugin): diff --git a/app/utils/common.py b/app/utils/common.py index 5708fbfd..5980b3b7 100644 --- a/app/utils/common.py +++ b/app/utils/common.py @@ -6,6 +6,8 @@ from typing import Any from Crypto import Random from Crypto.Cipher import AES +from app.schemas import ImmediateException + def retry(ExceptionToCheck: Any, tries: int = 3, delay: int = 3, backoff: int = 2, logger: Any = None): @@ -23,6 +25,8 @@ def retry(ExceptionToCheck: Any, while mtries > 1: try: return f(*args, **kwargs) + except ImmediateException: + raise except ExceptionToCheck as e: msg = f"{str(e)}, {mdelay} 秒后重试 ..." if logger: diff --git a/app/utils/crypto.py b/app/utils/crypto.py new file mode 100644 index 00000000..2bc9b99f --- /dev/null +++ b/app/utils/crypto.py @@ -0,0 +1,89 @@ +import base64 + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import rsa, padding + + +class RSAUtils: + + @staticmethod + def generate_rsa_key_pair() -> (str, str): + """ + 生成RSA密钥对并返回Base64编码的公钥和私钥(DER格式) + :return: Tuple containing Base64 encoded public key and private key + """ + # 生成RSA密钥对 + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + public_key = private_key.public_key() + + # 导出私钥为DER格式 + private_key_der = private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + + # 导出公钥为DER格式 + public_key_der = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + # 将DER格式的密钥编码为Base64 + private_key_b64 = base64.b64encode(private_key_der).decode('utf-8') + public_key_b64 = base64.b64encode(public_key_der).decode('utf-8') + + return private_key_b64, public_key_b64 + + @staticmethod + def verify_rsa_keys(private_key: str, public_key: str) -> bool: + """ + 使用 RSA 验证公钥和私钥是否匹配 + :param private_key: 私钥字符串 (Base64 编码,无标识符) + :param public_key: 公钥字符串 (Base64 编码,无标识符) + :return: 如果匹配则返回 True,否则返回 False + """ + if not private_key or not public_key: + return False + + try: + # 解码 Base64 编码的公钥和私钥 + public_key_bytes = base64.b64decode(public_key) + private_key_bytes = base64.b64decode(private_key) + + # 加载公钥 + public_key = serialization.load_der_public_key(public_key_bytes, backend=default_backend()) + + # 加载私钥 + private_key = serialization.load_der_private_key(private_key_bytes, password=None, + backend=default_backend()) + + # 测试加解密 + message = b'test' + encrypted_message = public_key.encrypt( + message, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + decrypted_message = private_key.decrypt( + encrypted_message, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + return message == decrypted_message + except Exception as e: + print(f"RSA 密钥验证失败: {e}") + return False diff --git a/app/utils/http.py b/app/utils/http.py index 6f76082f..bdef4272 100644 --- a/app/utils/http.py +++ b/app/utils/http.py @@ -1,5 +1,5 @@ from typing import Union, Any, Optional -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse, parse_qs, urlencode, urlunparse import requests import urllib3 @@ -255,3 +255,37 @@ class RequestUtils: return endpoint host = RequestUtils.standardize_base_url(host) return urljoin(host, endpoint) if host else endpoint + + @staticmethod + def combine_url(host: str, path: Optional[str] = None, query: Optional[dict] = None) -> Optional[str]: + """ + 使用给定的主机头、路径和查询参数组合生成完整的URL。 + :param host: str, 主机头,例如 https://example.com + :param path: Optional[str], 包含路径和可能已经包含的查询参数的端点,例如 /path/to/resource?current=1 + :param query: Optional[dict], 可选,额外的查询参数,例如 {"key": "value"} + :return: str, 完整的请求URL字符串 + """ + try: + # 如果路径为空,则默认为 '/' + if path is None: + path = '/' + host = RequestUtils.standardize_base_url(host) + # 使用 urljoin 合并 host 和 path + url = urljoin(host, path) + # 解析当前 URL 的组成部分 + url_parts = urlparse(url) + # 解析已存在的查询参数,并与额外的查询参数合并 + query_params = parse_qs(url_parts.query) + if query: + for key, value in query.items(): + query_params[key] = value + + # 重新构建查询字符串 + query_string = urlencode(query_params, doseq=True) + # 构建完整的 URL + new_url_parts = url_parts._replace(query=query_string) + complete_url = urlunparse(new_url_parts) + return str(complete_url) + except Exception as e: + logger.debug(f"Error combining URL: {e}") + return None diff --git a/config/app.env b/config/app.env index 956d17b8..a0213757 100644 --- a/config/app.env +++ b/config/app.env @@ -13,6 +13,10 @@ SUPERUSER=admin BIG_MEMORY_MODE=false # 是否启用DOH域名解析,启用后对于api.themovie.org等域名通过DOH解析,避免域名DNS被污染 DOH_ENABLE=true +# 使用 DOH 解析的域名列表,多个域名使用`,`分隔 +DOH_DOMAINS=api.themoviedb.org,api.tmdb.org,webservice.fanart.tv,api.github.com,github.com,raw.githubusercontent.com,api.telegram.org +# DOH 解析服务器列表,多个服务器使用`,`分隔 +DOH_RESOLVERS=1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112 # 元数据识别缓存过期时间,数字型,单位小时,0为系统默认(大内存模式为7天,滞则为3天),调大该值可减少themoviedb的访问次数 META_CACHE_EXPIRE=0 # 自动检查和更新站点资源包(索引、认证等)